Let's take an application that fetches data from the server and write end-to-end tests for it. What happens if the application takes a little bit longer than usual to bootstrap, get the data, and render it? Will the test fail because it does not wait for the data to finish loading?
The example I am about to show comes from bahmutov/cypress-workshop-basics. The test visits the application URL and confirms there are no items on the page.
1 | it('starts with zero items', () => { |
Hmm, the test passes, yet I can clearly see 2 todo items on the page? Is the assertion cy.get('li.todo').should('have.length', 0)
not doing its job?
The test finishes too quickly. While the application is still loading the data, the assertion cy.get('li.todo').should('have.length', 0)
checks the empty initial page. There are no todo items, and the test completes. Only then the Ajax call returns and the 2 items are rendered on the page. By then the test has finished.
Note that the .should('have.length', 0)
assertion is similar to the .should('not.exist')
assertion. Such negative assertions are dangerous in my opinion, they can pass for the wrong reason (like in our case). Read the blog posts Be Careful With Negative Assertions and Negative Assertions And Missing States for more examples.
Waiting for the data load
The simplest solution to this problem, is to make the test wait for the data to load. Perhaps a one second delay would be enough?
1 | it('starts with zero items (waits)', () => { |
Yes, now the test fails as expected, because the page shows the loaded data when the test checks it.
Is one second wait enough? It might be enough when running the application locally. When running the tests against a remote server, we might need to wait longer. The worst is when the data load takes approximately one second. It leads to a race condition between the application and the test. Sometimes the application takes slightly longer than one second, and the test fails to detect the data, leading to the flaky tests.
Waiting for network call to finish
A much better solution is to spy on the Ajax call made by the application and wait for the data to return before checking the page. You can use cy.intercept command to spy on calls made by the application. You can spy on Ajax calls or any resource requested by the browser.
1 | it('starts with zero items', () => { |
The command cy.wait('@todos')
waits for the network call, even if the call is made later.
In my application example, I can force the application to wait N seconds before making the initial load.
The application might delay the Ajax call.
1 | // state.delay comes from URL query parameter |
The delay makes no difference, the test still correctly waits for the application to load its initial data.
1 | it('starts with zero items (delay)', () => { |
Look at the recording below. The Ajax call is detected two seconds after the visit, yet cy.wait('@todos')
happily waits for it before Cypress proceeds to the next command.
Render delay
What if our application has a delay between receiving the data and rendering it on the page?
1 | axios |
Hmm, our test again passes when it should have failed.
1 | it('starts with zero items (delay plus render delay)', () => { |
The "missing" step between finishing the network call and rendering the data on the page is exactly the problem I have described in the blog post Negative Assertions And Missing States. The test does not "know" that the application is still not done loading. We need an explicit way of signalling from the application to the test runner "I am done loading the data".
Observe the page
The best approach to tell that the application has finished loading ... is for application to set something observable to tell the test runner (and the human users) that the data load is done. For example, the application can set a CSS class or a utility data attribute
1 | SET_LOADING(state, flag) { |
Then the test can tell when the application has finished loading by observing the <body>
element.
1 | it('starts with zero items (check body.loaded)', () => { |
The test behaves correctly and fails as expected.
Note: the assertion .should('have.class', 'loaded')
passes after 3.5 seconds; the application requests the data after 2 seconds, and renders it after 1.5 seconds. The assertion might time out if the data load takes slightly longer, since the default command timeout is 4 seconds. I would suggest using a longer command time out in this case:
1 | cy.get('body', { timeout: 7_000 }).should('have.class', 'loaded') |
Check the window object
We can use another mechanism to signal the test runner that the application has finished loading the data. We can even pass the data we got from the server!
1 | SET_TODOS(state, todos) { |
The test then can detect when the window.todos
property is set using cy.window and cy.its commands.
1 | it('starts with zero items (check the window)', () => { |
If we can detect when the window.todos
property is set, we can read the actual todos and use them to check the rendered page.
1 | it('starts with N items', () => { |
You can even use the window.todos
to check what the page renders.
1 | it('starts with N items and checks the page', () => { |
For more on accessing the application data from the Cypress test via the window
object, see the blog post Stub Objects By Passing Them Via Window Property.
Conclusion
The test runner should not "run away" from the application. If the application is still loading the data, the test runner should wait for the data to load and the page to be ready to continue testing. In this blog post, I have shown how to wait for the data to load using:
- a hard-coded
cy.wait(N)
command - network spy using the
cy.intercept
command - a
body
element property added after the load - a property on the
window
object set by the application after the load
Happy testing!