Cypress Test-Driven Development Example

How to implement a web application feature while writing API and E2E tests using Cypress test runner.

🎁 You can find the pull request with the tests from this blog post in the branch implement-toggle-video of the repo bahmutov/cypress-workshop-basics. The code can be seen in the pull request #75.

The step by step tests and the application changes described in this blog post are shown in my new video below

The application

In the workshop repository, I have a simple TodoMVC with the items stored by the backend server. Unfortunately, the application does not implement storing the "completed" item property. The user interface shows the item as completed, but when reloading the page, the completed status disappears.

Todo item loses the completed status after the page reload

The testing plan

Let's implement this feature. While working on the feature I will follow the test-drive development practice. I will first write a failing Cypress test, then implement the application code to make the test green. To start testing, I first will plan my "attack" by looking at the application architecture. The application has the web page interface, which shows the data in the Veux data store. The data store is synced with the backend via REST API calls. My first test will verify the REST API is working correctly. Then I will test how the web application passes the "completed" property to the Veux data store. Finally, I will test the web page UI and confirm it calls the backend API by using the cy.intercept command.

The application layers and the order of testing and implementation steps

The API test

We first confirm that our backend supports changing the todo's "completed" property and correctly updates the database. We can write a Cypress API test. Cypress can be quite happily calling the HTTP endpoints and verifying the results, as the video below shows:

Let's write our test:

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('completes an item using API', () => {
cy.request('POST', '/reset', { todos: [] })
cy.request('GET', '/todos').its('body').should('deep.equal', [])
cy.request('POST', '/todos', { title: 'first', completed: false })
.its('body')
.should('deep.include', { title: 'first', completed: false })
.its('id')
.then(cy.log)
.then((id) => {
cy.request('PATCH', `/todos/${id}`, { completed: true })
.its('status')
.should('eq', 200)
cy.request('GET', `/todos/${id}`)
.its('body')
.should('deep.equals', { id, title: 'first', completed: true })
})
})

The API test

The above test uses the cy.request command to make HTTP calls. We first create an item using the POST /todos <item> call. Then we verify the todos by asking the server using GET /todos. We then use the REST convention to PATCH /todos/:id <changed properties>. Finally, we verify the server has saved the changed "completed" property by request the Todo item and confirmed its properties.

1
2
3
cy.request('GET', `/todos/${id}`)
.its('body')
.should('deep.equals', { id, title: 'first', completed: true })

Tip: while running an API test, the application is not visited, leaving the entire iframe empty. You can use it to display the requests and responses by using the cy-api plugin.

The UI to Vuex data store test

The REST part was easy - our server already supports updating an object using the PATCH method. Can our application call these methods? Let's write a test to first confirm that the UI updates the internal data store. To access the internal Vuex data store, we can expose it to the test by setting it as a window property.

app.js
1
2
// our application instance
window.app = app

Then from the test we can check the data store using the cy.window and cy.its commands.

1
2
cy.window()
.its('app.$store.state.todos')

The entire test

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it('updates the Vuex store', () => {
cy.request('POST', '/reset', { todos: [] })
cy.request('POST', '/todos', { title: 'first', completed: false })
cy.visit('/')

cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('not.have.class', 'completed')
.find('.toggle')
.should('not.be.checked')
.click()
cy.contains('.todo-list li', 'first').should('have.class', 'completed')
cy.window()
.its('app.$store.state.todos')
.should('have.length', 1)
.its(0)
.should('have.property', 'completed', true)
})

Testing the updated Vuex data store

Tip: notice that the above test has a combination of API calls (to reset all existing todos and to create the first todo) and page commands (to verify the list of todos). I like using REST API calls to set the data really quickly.

Super, our application UI does update the data store. But does the store update the backend? Let's make the test fail.

Observe the network test

Let's spy on the network traffic to confirm if the Vuex data store calls the backend with PATCH method.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('toggles an item', () => {
cy.request('POST', '/reset', { todos: [] })
cy.request('POST', '/todos', { title: 'first', completed: false })
cy.visit('/')

cy.intercept('PATCH', '/todos/*').as('patch')
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('not.have.class', 'completed')
.find('.toggle')
.should('not.be.checked')
.click()
cy.wait('@patch')
.its('request.body')
.should('deep.equal', { completed: true })
})

The application never makes the expected PATCH call

Ok, our Vuex data store that syncs the data with the backend never calls the backend to update the Todo item when we click the "toggle" checkbox. Let's implement it in the app.js

app.js
1
2
3
4
5
6
7
8
9
10
11
// add toggleTodo to Vuex actions
toggleTodo({ commit }, todo) {
track('todo.toggle', todo.title)

axios
.patch(`/todos/${todo.id}`, { completed: !todo.completed })
.then(() => {
console.log('toggled todo', todo.id)
commit('TOGGLE_TODO', todo)
})
},

The test turns green.

Now the application updates the server via a PATCH call

We can even confirm that clicking the item again sends the 2nd call to clear the property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it('toggles an item', () => {
cy.request('POST', '/reset', { todos: [] })
cy.request('POST', '/todos', { title: 'first', completed: false })
cy.visit('/')

cy.intercept('PATCH', '/todos/*').as('patch')
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('not.have.class', 'completed')
.find('.toggle')
.should('not.be.checked')
.click()
cy.wait('@patch')
.its('request.body')
.should('deep.equal', { completed: true })

// toggle back
cy.contains('.todo-list li', 'first').find('.toggle').click()
cy.wait('@patch')
.its('request.body')
.should('deep.equal', { completed: false })
cy.contains('.todo-list li', 'first').should('not.have.class', 'completed')
})

The final test

Let's put the final test together that reloads the page and checks the completed property is still there.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it('stays completed', () => {
// reset + create the first todo
cy.request('POST', '/reset', {
todos: [{ id: 1, title: 'first', completed: false }]
})
cy.visit('/')
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('not.have.class', 'completed')
.find('.toggle')
.should('not.be.checked')
.click()
cy.contains('.todo-list li', 'first')
.should('have.class', 'completed')
.wait(1000, { log: false }) // for clarity
// the item stays completed
cy.reload()
cy.contains('.todo-list li', 'first').should('have.class', 'completed')
})

The final test ensures the completed items stay completed after a page reload

Happy Testing!