Verify Then Control The Data

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

App shows the sum of two numbers

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

GET /random-digit response

How would you test this page? I see 4 different approaches

4 different approaches to checking the sum of numbers sent by the server

🎓 This blog post is based on the lessons from my Cypress Network Testing Exercises course.

Compute the data in the test

The simplest approach we can take is to look up the numbers shown on the page and compute the expected sum inside the test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
})
})
})
})

Solution 1: read the page

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)
},
)
})

Solution 2: spy on the network calls

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

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
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')
})
})

Solution 3: mock the network calls

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.

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
34
35
36
37
38
39
40
41
42
43
it('verifies the server data and controls the numbers received by the page', () => {
cy.intercept(
{
pathname: '/random-digit',
times: 1,
},
(req) =>
req.continue((res) => {
expect(res.body, 'response body').to.have.keys([
'n',
])
expect(res.body.n, 'server number').to.be.within(
0,
10,
)
res.body = { n: 7 }
}),
).as('secondNumber')
cy.intercept(
{
pathname: '/random-digit',
times: 1,
},
(req) =>
req.continue((res) => {
expect(res.body, 'response body').to.have.keys([
'n',
])
expect(res.body.n, 'server number').to.be.within(
0,
10,
)
res.body = { n: 3 }
}),
).as('firstNumber')
cy.visit('/add/index.html')
cy.wait(['@firstNumber', '@secondNumber'])
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')
})
})

Of course, we can refactor the response check into a utility function for simplicity

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
34
35
36
37
38
function checkAndStub(body) {
return (req) =>
req.continue((res) => {
expect(res.body, 'response body').to.have.keys([
'n',
])
expect(res.body.n, 'server number').to.be.within(
0,
10,
)
res.body = body
})
}

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')

cy.visit('/add/index.html')
cy.wait(['@firstNumber', '@secondNumber'])
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')
})
})

Solution 4: verify the server response before substituting our mock data

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.

See also