The best way of writing deterministic fast tests that control the server data.
Imagine we are testing a web page that receives two numbers from the server and then shows their sum
In the Command Log you can see the two GET /random-digit calls the web page makes to the server. The server responds with {n: ...} JSON object. The page should add these two numbers and show the correct sum
How would you test this page? I see 4 different approaches
it('checks numbers shown on the page', () => { cy.visit('/add/index.html') cy.get('#addition.loaded').within(() => { // get the two numbers using ids "num1" and "num2" // and convert their text to integers cy.get('#num1') .invoke('text') .then(parseInt) .then((a) => { cy.get('#num2') .invoke('text') .then(parseInt) .then((b) => { // compute the sum ourselves const sum = a + b cy.get('#sum').should('have.text', sum) }) }) }) })
This approach has drawbacks
we trust the page to show the numbers correctly
we compute the expected sum inside the test
there is a pyramid of Doom of nested cy.then callbacks as we extract each number
My rule of thumb is to never trust the page to show the data correctly and avoid duplicating app logic inside the test. Computing the sum inside the test is one such duplication example.
Get the data from the network calls
Instead of looking up the numbers in the DOM, let's grab the numbers sent by the server by spying on the network calls GET /random-digit
1 2 3 4 5 6 7 8 9 10 11 12 13 14
it('checks the numbers using API traffic', () => { cy.intercept('/random-digit').as('randomDigit') cy.visit('/add/index.html') cy.wait(['@randomDigit', '@randomDigit']).spread( (intercept1, intercept2) => { const a = intercept1.response.body.n const b = intercept2.response.body.n // compute the sum ourselves const sum = a + b // confirm the sum shown on the page is correct cy.get('#sum').should('have.text', sum) }, ) })
This solution is better
no more querying the page
simpler test syntax
Still, the test is non-deterministic and we compute the sum inside the test, complicating the test logic
Stub the network calls
Instead of spying on the network calls, let's stub them and return deterministic data to the app
it('controls the numbers received by the page', () => { cy.intercept( { pathname: '/random-digit', times: 1, }, { n: 7 }, ).as('secondNumber') cy.intercept( { pathname: '/random-digit', times: 1, }, { n: 3 }, ).as('firstNumber') cy.visit('/add/index.html') cy.wait(['@firstNumber', '@secondNumber']) // confirm the two numbers are shown correctly on the page // confirm the sum shown on the page is correct cy.get('#addition.loaded').within(() => { cy.get('#num1').should('have.text', '3') cy.get('#num2').should('have.text', '7') cy.get('#sum').should('have.text', '10') }) })
This solution is much simpler. We do not need cy.then callbacks, all assertions are easy to understand, there is no computation in the test. But this test has one drawback: if the API call GET /random-digit changes, we STILL will return {n: ...} objects.
Verify then control the data
My last solution will add a schema check to verify what the server sends, before returning mock data to the web application.
it('verifies the server data and controls the numbers received by the page (refactored)', () => { cy.intercept( { pathname: '/random-digit', times: 1, }, checkAndStub({ n: 7 }), ).as('secondNumber') cy.intercept( { pathname: '/random-digit', times: 1, }, checkAndStub({ n: 3 }), ).as('firstNumber')
I really like this approach. We verify the server response to follow the expected schema. Then we substitute our own mock data to return to the application. All is left is to check what the page computes and shows.