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
- Exposing Heroes component
- Asserting application state
- Changing data inside the component
- Triggering application update
- Tests using app actions
- Fixing TypeScript
- Conclusions
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 | - user goes to /heroes view |
Here is the corresponding Cypress test, reading almost as naturally as the English sentences above.
1 | it('Returns deleted hero after reload', () => { |
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.
1 | ({ |
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.
1 | it('sets reference to 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.
1 | it('starts with 10 heroes', () => { |
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.
It is a good idea to factor out the access to the application's component into a utility function for reuse.
1 | const getHeroesComponent = () => |
Or if you prefer to a custom command
1 | Cypress.Commands.add('getHeroesComponent', () => { |
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.
1 | it('Returns deleted hero after reload - with assertions against data', () => { |
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.
1 | it('sets zero heroes', () => { |
Hmm, the test claims that is has cleared the list of heroes, but the app still shows all of them.
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:
1 | import { NgModule } from '@angular/core' |
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.
1 | import { NgModule, DoBootstrap, ApplicationRef } from '@angular/core' |
Now we can force the application re-render from the DevTools console.
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 | it('deletes all heroes through UI', () => { |
This works, because our application shows ALL items on the same page. Cypress just licks each delete button one by one.
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.
1 | const getHeroesComponent = () => |
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.
1 | const getAppRef = () => |
The test passes.
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
1 | /** |
Ohhh, the test fails, guess I am no hero.
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.
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:
1 | add(name: string) { |
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.
- Add delays to app actions that involve asynchronous application methods. For example
addName
test function can just delay the next test command.
1 | const addHero = (name: string) => |
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.
- 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 useshould(cb)
to retry until the array gets an extra item.
1 | /** |
The test works, and it is as fast it can be.
- 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.
1 | add(name: string): Promise<void> { |
Cypress automatically waits for promise returned from cy.invoke(...)
to resolve, thus our test becomes really simple.
1 | /** |
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.
1 | import { HeroesComponent } from './app/heroes/heroes.component'; |
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:
1 | constructor(private heroService: HeroService) { |
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 | { |
Now when we write a test, the window will have optional application properties, like this:
Let's cast the property returned by the getHeroes()
utility function so that our specs "know" what kind of object they are asserting.
1 | import { ApplicationRef } from '@angular/core' |
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.
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
1 | declare namespace Cypress { |
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
- Find the source code from this blog post at bahmutov/angular-heroes-app-actions
- Read Stop using page objects and start using app actions
- See more Cypress recipes and read my blog posts about Cypress and the official Cypress blog
- Read Using Cypress App Action With ngrx/store Angular Applications