Using Cypress App Action With ngrx/store Angular Applications

Dispatching the actions from Cypress end-to-end tests to avoid the need to the complicated page objects.

Let's say you are writing end-to-end tests for a modern Angular application. The app is showing a list of customers, so your first test is checking if adding a customer works. You are testing the application the way the user would do it: by filling the input fields and clicking the "Save" button.

e2e/src/e2e/add-customer.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// https://github.com/bahmutov/cypress-slow-down
import { slowCypressDown } from 'cypress-slow-down';
// slow each Cypress command by 100ms
slowCypressDown(100);

describe('Customers', { viewportHeight: 800 }, () => {
beforeEach(() => {
cy.visit('/');
});

it('adds a customer', () => {
cy.contains('a', 'Customers').click();
cy.location('pathname').should('eq', '/customer');
cy.contains('a', 'Add Customer').click();
cy.location('pathname').should('eq', '/customer/new');
cy.get('input[formcontrolname=firstname]').type('Tom');
cy.get('input[formcontrolname=name]').type('Lincoln');
cy.contains('mat-label', 'Country').click();
cy.contains('mat-option', 'USA').click();
cy.contains('mat-label', 'Birthdate').click();
cy.get('input[formcontrolname=birthdate]').type('12.09.1995');
cy.contains('button', 'Save').click();
cy.location('pathname').should('eq', '/customer');
// confirm the record is in the list
cy.contains('Tom Lincoln')
.should('exist')
.invoke('css', 'border', '2px solid red');
});
});

🎁 You can find the source code for this blog post and the test code in the branch add-via-store of my repo bahmutov/basta-spring-2024-cypress-and-playwright.

The test is passing. For clarity, I am using cypress-slow-down plugin to slow down each Cypress command by 100ms.

Nice.

The second test

Once we have tested adding a customer, we can test so many other things. For example, we can test deleting a customer. To delete a customer, we need to ... add a customer first. Will you write almost the same test as before?

e2e/src/e2e/delete-customer.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
it('deletes a customer', () => {
cy.contains('a', 'Customers').click();
cy.location('pathname').should('eq', '/customer');
cy.contains('a', 'Add Customer').click();
cy.location('pathname').should('eq', '/customer/new');
cy.get('input[formcontrolname=firstname]').type('Tom');
cy.get('input[formcontrolname=name]').type('Lincoln');
cy.contains('mat-label', 'Country').click();
cy.contains('mat-option', 'USA').click();
cy.contains('mat-label', 'Birthdate').click();
cy.get('input[formcontrolname=birthdate]').type('12.09.1995');
cy.contains('button', 'Save').click();
cy.location('pathname').should('eq', '/customer');
// delete the customer
cy.contains('[data-testid=row-customer]', 'Tom Lincoln')
.contains('a', 'edit')
.click();
cy.location('pathname').should('match', /\/customer\/\d+$/);
cy.contains('button', 'Delete').click();
cy.location('pathname').should('eq', '/customer');
cy.get('[data-testid=row-customer]');
cy.contains('Tom Lincoln').should('not.exist');
});

The test passes.

Hmm. If you look at the Command Log column, 24 out of 36 commands are the same as in the "adds a customer" test and are adding a new customer record using the page elements. It is slow too.

Will we write a page object to abstract adding a customer? We could.

e2e/src/pom/customer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { format } from 'date-fns';

class Customer {
setFirstname(firstname: string) {
cy.testid('inp-firstname').clear().type(firstname);
}

setName(name: string) {
cy.testid('inp-name').clear().type(name);
}

setCountry(country: string) {
cy.testid('sel-country').click();
cy.testid('opt-country').contains(country).click();
}

setBirthday(date: Date) {
cy.testid('inp-birthdate').clear().type(format(date, 'dd.MM.yyyy'));
}

submit() {
cy.testid('btn-submit').click();
}
}

export const customer = new Customer();

We could use the above page object in our test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { sidemenu } from '../pom/sidemenu';
import { customer } from '../pom/customer';
import { customers } from '../pom/customers';

it('adds a customer via UI', () => {
sidemenu.open('Customers');
cy.testid('btn-customers-add').click();
customer.setFirstname('Tom');
customer.setName('Lincoln');
customer.setCountry('USA');
customer.setBirthday(new Date(1995, 9, 12));
customer.submit();

customers.goTo('Tom Lincoln').invoke('css', 'border', '2px solid red');
});

I don't like such page objects. All they do is "hide" individual cy.contains and cy.get commands in their tiny methods. Of course, we could create an abstraction on top of these tiny methods.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { customer } from './customer';

export class Customers {
submitForm(
firstname: string,
name: string,
country: string,
birthdate: Date
) {
customer.setFirstname(firstname);
customer.setName(name);
customer.setCountry(country);
customer.setBirthday(birthdate);
customer.submit();
}
}
export const customers = new Customers();

Then the test could use customers.submitForm()

1
2
3
4
5
6
7
it('adds a customer via UI (2nd version)', () => {
sidemenu.open('Customers');
cy.testid('btn-customers-add').click();
customers.submitForm('Tom', 'Lincoln', 'USA', new Date(1995, 9, 12));

customers.goTo('Tom Lincoln').invoke('css', 'border', '2px solid red');
});

Even with multiple levels of abstractions, we still have a test that repeats 2/3 of its commands between the test "adds a customer" and "deletes a customer". We also built up levels of testing code in the page objects ... that the actual user of our web page does not see or benefit from.

Page objects on top of page UI

You are building levels of code on top of the elements on the page. The end user does not benefit from this code, and the tests simply repeat actions on the page from the other tests. By the time you get to the "deletes a customer" test, you know that adding a customer works. So repeating the same clicks and typing in the test simply spends time.

A better way

Inside the application's code, the event handlers take the input from the page and pass it on to the business logic. For example, when you add a customer, here is what the application code is doing:

src/app/customer/customer/customer.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Component, inject, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
...

@Component({
selector: 'app-customer',
templateUrl: './customer.component.html',
styleUrls: ['./customer.component.scss'],
standalone: true,
...
})
export class CustomerComponent implements OnInit {
#store = inject(Store);

submit() {
if (this.formGroup.valid) {
const customer = this.formGroup.getRawValue();
if (customer.id > 0) {
this.#store.dispatch(customerActions.update({ customer }));
} else {
const action = customerActions.add({ customer });
this.#store.dispatch(action);
}
}
}
...
}

The most important lines are:

1
2
const action = customerActions.add({ customer });
this.#store.dispatch(action);

If you print the constructed action, it looks like this:

Adding the customer event object

Ok, so all those UI clicks and typings lead to calling this.#store.dispatch(action). We can do it ourselves from the test. First, we need to expose the store object so that the test can access it. No biggie. We can expose the global store from any top-level component.

src/app/customer/customers/customers.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
export class CustomersComponent implements OnInit {
#store = inject(Store);
data$ = this.#store.select(fromCustomer.selectCustomersAndPage);

ngOnInit() {
// expose the store instance
if (window.Cypress) {
window.store = this.#store;
}
...
}
};

Technical detail: in Angular applications we need to dispatch the actions using ngZone wrapper. This will force all components to update their user interface after propagating our action. Thus we need to expose the ngZone instance.

src/app/app.component.ts
1
2
3
4
5
6
7
8
9
export class AppComponent implements OnInit {
constructor(private ngZone: NgZone) {}

ngOnInit() {
if (window.Cypress) {
window.ngZone = this.ngZone;
}
}
}

Let's update our test.

e2e/src/e2e/delete-customer.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
it('deletes a customer (app action)', () => {
const firstname = 'Tom';
const name = 'Lincoln';
const fullName = `${firstname} ${name}`;

const addCustomer = {
customer: {
id: 0,
firstname,
name,
country: 'AT',
birthdate: '1985-12-12T05:00:00.000Z',
},
type: '[Customer] add',
};
cy.visit('/customer');
cy.window().should('have.property', 'store');
cy.window().then((win) => {
win.ngZone!.run(() => {
win.store!.dispatch(addCustomer);
win.store!.dispatch({ type: '[Customer] load' });
});
});
// delete the customer
cy.contains('[data-testid=row-customer]', 'Tom Lincoln')
.contains('a', 'edit')
.click();
cy.location('pathname').should('match', /\/customer\/\d+$/);
cy.contains('button', 'Delete').click();
cy.location('pathname').should('eq', '/customer');
cy.get('[data-testid=row-customer]');
cy.contains('Tom Lincoln').should('not.exist');
});

We build an object with the customer information, then dispatch an NgRx action. We wait for the window object to have the store instance - then we know the app has finished loading.

1
2
3
4
5
6
7
cy.window().should('have.property', 'store');
cy.window().then((win) => {
win.ngZone!.run(() => {
win.store!.dispatch(addCustomer);
win.store!.dispatch({ type: '[Customer] load' });
});
});

Because of the design of our application, we need to dispatch the load event too in order to update the entire list. Here is how the test looks.

The test loads the page and immediately creates a customer record, simply by calling win.store!.dispatch(addCustomer);. Then we can use the page UI and delete that customer, just like a real user. We lost nothing in our testing by removing the duplicate commands between the two tests. The "adds a customer" still exercises the page object flow for adding a new record. The current test simply reaches into the app and calls the same production code as the regular page ui would. Want to have a better test experience? Improve the application code, don't create levels of "better" testing code.

App actions are built on top of the application code

We can still use some small Page Object utilities, like opening a specific menu

e2e/src/pom/sidemenu.ts
1
2
3
4
5
6
7
class Sidemenu {
open(name: 'Customers' | 'Holidays') {
cy.testid(`btn-${name.toLowerCase()}`).click();
}
}

export const sidemenu = new Sidemenu();
1
2
import { sidemenu } from '../pom/sidemenu';
sidemenu.open('Customers');

But in general, we can do larger actions to add new data by calling the app's code. Cypress tests run in the same browser as the app code, so no problems with passing the real object references, circular objects, etc.

🎓 If you want to see what I think a Page Object should have, check out a free lesson "Lesson b5: Write a Page Object" in my course Write Cypress Tests Using GitHub Copilot.

A note about types: We need to "tell" the application and the testing code that the window object might have properties Cypress, ngZone, and store. We can use src/index.d.ts file for this:

src/index.d.ts
1
2
3
4
5
6
7
8
9
10
import type { NgZone } from '@angular/core';
import type { Store } from '@ngrx/store';

declare global {
interface Window {
Cypress?: unknown;
ngZone?: NgZone;
store?: Store<any>;
}
}

See also