Testing Angular application via App Actions

How to bypass user interface to directly dispatch actions to Angular 8 application from Cypress end-to-end tests.

You can find the entire source code for this example at bahmutov/angular-heroes-app-actions.

Introduction

In my previous blog posts I have shown how end-to-end tests do not always have to go through the user interface to interact with the application 1, 2, 3. Those examples used Vue, React and Overmind.js front-end libraries. In this blog post I will show how to access application state directly from the test code for Angular 8 application. We will access the state both to make assertions against it during test, and to dispatch actions against it. This allows us to be both quick and build our tests on top of the component's API, not on top of the page DOM.

Regular test

But first, let me give an example of a "normal" end-to-end test. We will write these tests first to cover individual features of the app, simulating the behavior of a normal human user. Let's take a user story like this

1
2
3
4
5
6
- user goes to /heroes view
- user sees list of heroes
- "Dr Nice" is at the top of the list
- user does not like "Dr Nice" and deletes him from the list
- "Dr Nice" is gone
- when the user reloads the page, "Dr Nice" appears again

Here is the corresponding Cypress test, reading almost as naturally as the English sentences above.

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
it('Returns deleted hero after reload', () => {
cy.visit('/heroes')
cy.get('ul.heroes li').should('have.length', 10)
.first().should('include.text', 'Dr Nice')
.find('.delete').click()
cy.get('ul.heroes li')
.should('have.length', 9).and('not.include.text', 'Dr Nice')
cy.reload()
// Dr Nice is back
cy.get('ul.heroes li')
.should('have.length', 10)
.first().should('include.text', 'Dr Nice')
})

Deleting a hero and reloading the page test

This test uses the application through the user interface and confirms the "delete" behavior. We now that the UI element <button class="delete"> is really working. Let's never press it again.

When writing other tests, we can bypass clicking on "Delete" button, instead we can call the underlying component's "delete" method. This will allow us to build up better application's internal API, which are public methods in each component, rather than try to come up with a page object that encapsulates cy.find('.delete').click() commands.

But first we will need to somehow expose a reference to a component we want to control, so our test can even get to the component.

Exposing Heroes component

To reach inside the application from our test, we need to pass a reference to a component or service from the app to the spec. The easiest way to do this is by attaching the reference to the window object. Let's pass reference to the Heroes component.

heroes.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes: Hero[];

constructor(private heroService: HeroService) {
// @ts-ignore
if (window.Cypress) {
// @ts-ignore
window.HeroesComponent = this
}
}

// the rest of the component
}

If our application is running inside Cypress tests, we will set window.HeroesComponent. Notice // @ts-ignore directives - I need to use them because normally window object has neither Cypress, nor HeroesComponent property. The beauty of TypeScript. We will fix this later.

From our tests, we can reach into the application's window and use the property HeroesComponent when it gets set. We will do this via assertions taking advantage of retry-ability. Imagine the window starting without HeroesComponent and we want to wait until the property gets set. We need to use should assertion - it will get retried and retried until our application starts and window.HeroesComponent = this is executed.

spec.ts
1
2
3
4
5
it('sets reference to HeroesComponent', () => {
cy.visit('/heroes')
cy.window()
.should('have.property', 'HeroesComponent') // yields window.HeroesComponent
})

The assertion "have.property" automatically yields that property to the next command in the chain. Thus we can check and assert the number of heroes initially.

spec.ts
1
2
3
4
5
6
7
it('starts with 10 heroes', () => {
cy.visit('/heroes')
cy.window()
.should('have.property', 'HeroesComponent') // yields window.HeroesComponent
.should('have.property', 'heroes') // yields window.HeroesComponent.heroes array
.should('have.length', 10)
})

If you click on any SHOULD command in the Command Log on the left, the yielded object will be printed to the DevTools console. You can see what the application state was at each step, which makes it simple to understand the test and application behavior.

The HeroesComponent

It is a good idea to factor out the access to the application's component into a utility function for reuse.

spec.ts
1
2
3
4
5
6
7
8
9
10
const getHeroesComponent = () =>
cy.window()
.should('have.property', 'HeroesComponent') // yields window.HeroesComponent

it('starts with 10 heroes', () => {
cy.visit('/heroes')
getHeroesComponent()
.should('have.property', 'heroes') // yields window.HeroesComponent.heroes array
.should('have.length', 10)
})

Or if you prefer to a custom command

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
Cypress.Commands.add('getHeroesComponent', () => {
// yields window.HeroesComponent
return cy.window().should('have.property', 'HeroesComponent')
})

it('starts with 10 heroes (custom command)', () => {
cy.visit('/heroes')
// @ts-ignore
cy.getHeroesComponent()
.should('have.property', 'heroes') // yields window.HeroesComponent.heroes array
.should('have.length', 10)
})

I am not a big fan of custom commands, because at least in TypeScript specs you need to either add // @ts-ignore or have a separate TS files that extends cy with new commands, see Cypress TypeScript page.

Asserting application state

Let's use the reference to the component to check its state. We will take the same test as before and will add a few more assertions to confirm the first hero in the list before and after "delete", and after page reload.

spec.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
it('Returns deleted hero after reload - with assertions against data', () => {
cy.visit('/heroes')
// confirm the data in the component
getHeroes().should('have.length', 10)
// @ts-ignore
.its('0')
.should('deep.equal', {
id: 11,
name: 'Dr Nice'
})
// confirm the UI
cy.get('ul.heroes li').should('have.length', 10)
.first().should('include.text', 'Dr Nice')
.find('.delete').click()
cy.get('ul.heroes li')
.should('have.length', 9).and('not.include.text', 'Dr Nice')
// confirm the data in the component
getHeroes()
// @ts-ignore
.its('0').should('deep.equal', {
id: 12,
name: 'Narco'
})
cy.reload()
// Dr Nice is back
cy.get('ul.heroes li')
.should('have.length', 10)
.first().should('include.text', 'Dr Nice')
})

Having assertions against the internal application data ties your tests to the implementation, so judge if it is necessary yourself. If the application's implementation has reached maturity, and won't change much in the future, it is probably ok, as it allows you to lock down the internal data details.

Changing data inside the component

We know how to access data inside a component, now let's change it. Let's set the number of heroes to zero for example.

spec.ts
1
2
3
4
5
6
7
8
9
10
11
it('sets zero heroes', () => {
cy.visit('/heroes')
// @ts-ignore
cy.getHeroesComponent()
.should('have.property', 'heroes') // yields window.HeroesComponent.heroes array
.should('have.length', 10)
.then(heroes => {
heroes.length = 0
cy.log('cleared heroes')
})
})

Hmm, the test claims that is has cleared the list of heroes, but the app still shows all of them.

Application still shows all heroes

Triggering application update

So far we have changed the data inside the application, yet the user interface does not refresh - the application has no idea that it needs to re-render. Let's force the update. To do this, we need to get a reference to ApplicationRef instance, so we can call appRef.tick() method.

In order to do this, we will change how the application bootstraps in app.module.ts. Usually we just list component to be bootstrapped like this:

app.module.ts
1
2
3
4
5
6
import { NgModule } from '@angular/core'
import { AppComponent } from './app.component'
@NgModule({
bootstrap: [ AppComponent ]
})
export class AppModule {}

But we will implement the bootstrap interface DoBootstrap ourselves - because the callback gets the ApplicationRef argument we want to access later. Here is the small change.

app.module.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { NgModule, DoBootstrap, ApplicationRef } from '@angular/core'
import { AppComponent } from './app.component'
@NgModule({
// instead of elements to bootstrap
// just put app component in the entry components list
entryComponents: [AppComponent]
// and remove the "bootstrap" property
})
export class AppModule implements DoBootstrap {
ngDoBootstrap(appRef: ApplicationRef) {
// bootstrap AppComponent ourselves
appRef.bootstrap(AppComponent)
// @ts-ignore
if (window.Cypress) {
// and save the application reference!
// @ts-ignore
window.appRef = appRef
}
}
}

Now we can force the application re-render from the DevTools console.

Using application reference tick to re-render DOM

If we can control our application from DevTools console, we can control it from Cypress - it is just JavaScript.

Tests using app actions

Avoid setup using UI

What happens when there are no heroes and the user does a search? Let's test it. Our test needs to delete all heroes from the app and then search. Hmm, deleting heroes is complicated because we don't know how many heroes the application loads. Of course Cypress has a way to click multiple buttons like this

1
2
3
4
5
6
7
8
it('deletes all heroes through UI', () => {
cy.visit('/heroes')
// confirm the heroes have loaded and select "delete" buttons
cy.get('ul.heroes li button.delete')
.should('have.length.gt', 0)
// and delete all heroes
.click({ multiple: true })
})

This works, because our application shows ALL items on the same page. Cypress just licks each delete button one by one.

Deleting Heroes by clicking

But I advise against this type of test, because in general a test that performs a variable number of steps is prone to be flaky and hard to understand and debug in the future. We call such tests non-deterministic and advise against them. The test should always follow the same scenario - it should prepare the data beforehand to always follow the same path.

Imagine our application changes, and starts with zero heroes. The test will FAIL because it cannot click zero delete buttons! Imagine the test starts with 100 heroes. The test will take a long time just to delete a hundred items by clicking a hundred buttons. What if the application paginates the list? Deleting of heroes through the user interface suddenly becomes a hard problem by itself, and any test that needs to have zero heroes becomes flaky and complicated.

Prefer controlling app directly

There is a better way.

Let's avoid the unknown number of clicks problem. We can clear the list by reaching inside the application and just setting the length of list of heroes to zero.

spec.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
const getHeroesComponent = () =>
cy.window()
.should('have.property', 'HeroesComponent') // yields window.HeroesComponent

/**
* yields window.HeroesComponent.heroes array
* @example
* // starts with 10 heroes
* cy.visit('/heroes').should('have.length', 10)
*/
const getHeroes = () =>
getHeroesComponent().should('have.property', 'heroes')

/**
* Sets the length of heroes array to 0
*/
const clearHeroes = () =>
getHeroes()
.then(heroes => {
cy.log(`clearing ${heroes.length} heroes`)
// @ts-ignore
heroes.length = 0
})

it('deletes all heroes through app action', () => {
cy.visit('/heroes')
// confirm the heroes have loaded - because the array has items
getHeroes().should('have.length.gt', 0)
clearHeroes()
})

Great, we are settings heroes.length to zero, we need to re-render the application, right. So let's add appRef.tick() call. We will add utility function to access window.appRef and then to call tick method.

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const getAppRef = () =>
cy.window().should('have.property', 'appRef')

/**
* Calls `appRef.tick()` to force UI refresh
*/
const tick = () =>
getAppRef()
// @ts-ignore
.invoke('tick')

it('deletes all heroes through app action', () => {
cy.visit('/heroes')
// confirm the heroes have loaded - because the array has items
getHeroes().should('have.length.gt', 0)
clearHeroes()
tick()
})

The test passes.

Deleting Heroes by app action

There is nothing non-deterministic about this test. It will just work, assuming the initial list has more than zero items. It is also faster that going through the DOM, although in this case the difference is small in absolute terms - 1 second vs 2 seconds.

Avoiding race conditions

Let's extend the test. After clearing the list of heroes using app action, let's add a new hero, again using an app action and then confirm the new hero shows up in the UI. We are using the same helper functions plus a new one - addHero

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Adds a new hero.
*/
const addHero = (name: string) =>
cy.window()
// @ts-ignore
.its('HeroesComponent')
.invoke('add', name)

it('shows new hero', () => {
cy.visit('/heroes')
clearHeroes()
tick()

addHero('Gleb') // the world needs a new hero
tick()
cy.contains('.heroes li', 'Gleb')
})

Ohhh, the test fails, guess I am no hero.

The new hero does not appear

Hmm, there is something weird going on. The list has been cleared, and the UI has refreshed after that. But where is the new hero? Even more suspicious is an observation that if while the cy.contains command is spinning trying to find the new text, I click "Clear" button, the new record suddenly appears, and the test passes.

Clicking clear suddenly brings the new record to UI

Seems like our tick() action did NOT refresh the user interface after adding a new item, yet it did work when clearing the list of heroes. What is the difference between the two actions? Let's look at the code.

When we are clearing heroes, we are just setting heroes.length = 0. This is a synchronous action, thus when the test executes tick() the list has zero items. But the addHero app action calls the following code in the component:

heroes.component.ts
1
2
3
4
5
6
7
8
add(name: string) {
name = name.trim();
if (!name) { return }
this.heroService.addHero({ name } as Hero)
.subscribe(hero => {
this.heroes.push(hero);
})
}

Hmm, there is an Observable there - this is an asynchronous method, and if we call tick() from the test ... which runs in the same event loop as the application code, we refresh the UI before the asynchronous subscribe callback even runs! We have a race condition between the test calling tick() and calling application code.

We can try solving this problem in several ways, depending on how much we can modify our application code or slow down our tests.

  1. Add delays to app actions that involve asynchronous application methods. For example addName test function can just delay the next test command.
1
2
3
4
5
6
const addHero = (name: string) =>
cy.window()
// @ts-ignore
.its('HeroesComponent')
.invoke('add', name)
.wait(1000)

Ughh, waiting an entire second? Might be too slow for the interactive mode when Cypress is running locally, yet not enough for running tests on CI, leading to flaky tests.

  1. Wait until the heroes list increases its length by 1, which means the application code has finished running this.heroes.push(hero). Here is how to save the initial length of array, then call app action, then use should(cb) to retry until the array gets an extra item.
spec.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
/**
* Adds a new hero. Waits for number of heroes to increase by 1
*/
const addHero = (name: string) => {
// first, save the number of items in the list
// save under alias "n", available in the test context "this.n"
getHeroes().its('length').as('n')
cy.window()
// @ts-ignore
.its('HeroesComponent')
.invoke('add', name)
// now retry reading "heroes" array until its length has increased by 1
getHeroes().should(function (heroes) {
// use "function () {...}" callback to make sure
// "this" points at the test context
// and we can access previously saved alias "n"
expect(heroes).to.have.length(this.n + 1)
})
}

it('shows new hero', () => {
cy.visit('/heroes')
clearHeroes()
tick()

addHero('Gleb') // the world needs a new hero
tick()
cy.contains('.heroes li', 'Gleb')
})

The test works, and it is as fast it can be.

Waiting until array increases its length by one

  1. Refactor application code to signal when its data has finished updating. Simply, let's return a promise from component's method addName. Then the Cypress test can wait for this promise to resolve.
heroes.component.ts
1
2
3
4
5
6
7
8
9
10
11
add(name: string): Promise<void> {
name = name.trim();
if (!name) { return Promise.resolve(); }
return new Promise((resolve) => {
this.heroService.addHero({ name } as Hero)
.subscribe(hero => {
this.heroes.push(hero);
resolve()
});
})
}

Cypress automatically waits for promise returned from cy.invoke(...) to resolve, thus our test becomes really simple.

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Adds a new hero. If the application method "add(name)" returns a promise,
* the Cypress test command chain automatically waits for the promise to resolve.
*/
const addHero = (name: string) =>
cy.window()
// @ts-ignore
.its('HeroesComponent')
.invoke('add', name)

it('shows new hero', () => {
cy.visit('/heroes')
clearHeroes()
tick()

addHero('Gleb') // the world needs a new hero
tick()
cy.contains('.heroes li', 'Gleb')
})

Cypress test waits for application's promise to resolve

Fixing TypeScript

Let's tell TypeScript that window object can have our new properties set. Create a new file src/index.d.ts and describe new properties that the application can add to the window object.

src/index.d.ts
1
2
3
4
5
6
7
8
9
import { HeroesComponent } from './app/heroes/heroes.component';
import { ApplicationRef } from '@angular/core';
declare global {
interface Window {
Cypress?: unknown
appRef?: ApplicationRef
HeroesComponent?: HeroesComponent
}
}

The file src/index.d.ts will be automatically loaded by TypeScript compiler while process .ts files in src folder, so the window object will be updated. Great, now we can remove all // @ts-ignore from the source code to be simply:

src/heroes.component.ts
1
2
3
4
5
constructor(private heroService: HeroService) {
if (window.Cypress) {
window.HeroesComponent = this
}
}

Now let's tell our spec files that these new properties are available on the window. Include the src/index.d.ts from the cypress/tsconfig.json file:

1
2
3
4
5
6
7
8
{
"extends": "../tsconfig.json",
"include": [
"*/*.ts",
"../node_modules/cypress",
"../src/index.d.ts"
]
}

Now when we write a test, the window will have optional application properties, like this:

IntelliSense shows the new HeroComponent property exists

Let's cast the property returned by the getHeroes() utility function so that our specs "know" what kind of object they are asserting.

spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ApplicationRef } from '@angular/core'
import { Hero } from "app/hero"

/**
* yields window.HeroesComponent.heroes array
* @example
* // starts with 10 heroes
* cy.visit('/heroes').should('have.length', 10)
*/
const getHeroes = () =>
getHeroesComponent().should('have.property', 'heroes')
.then(list => <Hero[]><unknown>list) // make the type work

const getAppRef = () =>
cy.window().should('have.property', 'appRef')
.then(x => <ApplicationRef><unknown>x) // make the type work

/**
* Calls `appRef.tick()` to force UI refresh
*/
const tick = () =>
getAppRef()
.invoke('tick')

Now we can remove all // @ts-ignore from the spec file. When we have types, even hovering over getAppRef().invoke method correctly shows only the methods available on the ApplicationRef type.

TypeScript provides intelligent code completion for `cy.invoke` over ApplicationRef

Finally, let's add a custom command we have added cy.getHeroesComponent() to TypeScript. As shown in Cypress TypeScript documentation, to add new commands to the global cy object type we need to create cypress/support/index.d.ts

cypress/support/index.d.ts
1
2
3
4
5
declare namespace Cypress {
interface Chainable {
getHeroesComponent(): Chainable<any>
}
}

For some reason I could not import HeroesComponent and return Chainable<HeroesComponent> - TypeScript compiler would complain about generic interface. The beauty of TypeScript.

Conclusions

In this blog post I have shown how to expose an Angular component instance and access it from Cypress tests. Using the instance reference we can check the internal application's state, and also trigger data changes, bypassing user interface. I have also shown how to trigger user interface updates by getting a reference to the application during bootstrap. We have seen several solutions to race conditions between the app and the test runner. Finally I have shown how to fix TypeScript errors when we extend global window and cy objects with custom properties.

See more