Change E2E Tests From UI To API To App Actions

How to write independent tests that do not duplicate the application's logic.

Let's write a few TodoMVC end-to-end tests. Simple, right? I will start by testing adding a new todo. Because the backend is shared by all tests, and we are not resetting the data, I will use random number to ensure the right todo is created.

cypress/e2e/adding.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
it('adds a new todo', () => {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
// tip: use the test name + random number to
// generate unique data
const title = `${Cypress.currentTest.title} ${Cypress._.random(1e5)}`
cy.get('.new-todo').type(title + '{enter}')
cy.contains('.todo', title).should('be.visible')
// confirm the todo is preserved on the server
cy.reload()
cy.contains('.todo', title).should('be.visible')
})

Adding a new todo test

Super, the application is adding todo items.

🎁 You can find the source code for this blog post in repo bahmutov/ui-to-api-to-app-actions.

Completing items

Let's write a test to verify we can complete an item. Hmm, we need an item. We don't want to write a test dependent on the previous test, so we copy the "adds a new todo" code into new test.

cypress/e2e/complete.cy.ts
1
2
3
4
5
6
7
8
9
10
11
it('completes a new todo', () => {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
// tip: use the test name + random number to
// generate unique data
const title = `${Cypress.currentTest.title} ${Cypress._.random(1e5)}`
cy.get('.new-todo').type(title + '{enter}')
cy.contains('.todo', title).should('be.visible').find('.toggle').click()
cy.contains('.todo', title).should('have.class', 'completed')
})

The test passes.

The test adds and completes a todo

Hmm, our two tests have mostly the same code.

The two tests are mostly the same

UI code refactoring

At this point, people start refactoring their test code to avoid code duplication. They create utility functions or page objects to interact with the "TodoMVC" page.

cypress/e2e/utils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const TodoPage = {
visit() {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
},
addTodo(title: string) {
cy.get('.new-todo').type(title + '{enter}')
cy.contains('.todo', title).should('be.visible')
},
hasTodo(title: string) {
// return the chainable object
// to allow adding more assertions
return cy.contains('.todo', title).should('be.visible')
}
}
cypress/e2e/adding.cy.ts
1
2
3
4
5
6
7
8
9
10
11
import {TodoPage} from './utils'
it('adds a new todo', () => {
TodoPage.visit()
// tip: use the test name + random number to
// generate unique data
const title = `${Cypress.currentTest.title} ${Cypress._.random(1e5)}`
TodoPage.addTodo(title)
// confirm the todo is preserved on the server
cy.reload()
TodoPage.hasTodo(title)
})
cypress/e2e/complete.cy.ts
1
2
3
4
5
6
7
8
9
10
import {TodoPage} from './utils'
it('completes a new todo', () => {
TodoPage.visit()
// tip: use the test name + random number to
// generate unique data
const title = `${Cypress.currentTest.title} ${Cypress._.random(1e5)}`
TodoPage.addTodo(title)
TodoPage.hasTodo(title).find('.toggle').click()
TodoPage.hasTodo(title).should('have.class', 'completed')
})

We are creating a hierarchy of code to deal with the page instead of addressing the problem directly: we need a Todo item to complete it.

Use API

Our frontend code is making REST API calls to load and create items. Let's confirm the calls. At the end of the "adds a new todo" test, let's spy on the POST /todos network call and confirm the request object the app sends.

cypress/e2e/api/adding.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
it('adds a new todo', () => {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
// tip: use the test name + random number to
// generate unique data
const title = `${Cypress.currentTest.title} ${Cypress._.random(1e5)}`
// spy on the API call the app is making to create a todo
cy.intercept('POST', '/todos').as('addTodo')
cy.get('.new-todo').type(title + '{enter}')
cy.contains('.todo', title).should('be.visible')
// confirm the todo is preserved on the server
cy.reload()
cy.contains('.todo', title).should('be.visible')
// confirm the API request
cy.wait('@addTodo')
.its('request.body')
.should('deep.include', {
title,
completed: false,
})
.and('have.property', 'id')
})

The application is making an API call to add a todo

Great, we have confirmed the main 2 properties of the API request. We can only confirm the property id exists on the sent object, since it is dynamic and we don't control its value. Here is how we can start the "completes a new todo" test - we will make the item ourselves by making a similar API call:

cypress/e2e/api/complete.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('completes a new todo', () => {
const id = String(Cypress._.random(1e5))
const title = `${Cypress.currentTest.title} ${id}`
cy.request('POST', '/todos', {
title,
completed: false,
id,
})
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
cy.contains('.todo', title).should('be.visible').find('.toggle').click()
cy.contains('.todo', title).should('have.class', 'completed')
})

The application completes the item created via API call

Boom, in a single call we got our item created and ready to be completed. We can then visit the page and interact with the item like a regular user. Let's write a test to delete an item. We need to create an item, and we again can use the cy.request command to make the API call ourselves. To confirm the application deletes the item, let's fetch the items from the server after clicking the delete button.

cypress/e2e/api/delete.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('deletes a todo', () => {
const id = String(Cypress._.random(1e5))
const title = `${Cypress.currentTest.title} ${id}`
cy.request('POST', '/todos', {
title,
completed: false,
id,
})
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
cy.contains('.todo', title)
.should('be.visible')
.find('.destroy')
.click({ force: true })
cy.contains('.todo', title).should('not.exist')
cy.request('GET', '/todos')
.its('body')
.then((list) => {
expect(Cypress._.find(list, { id }), 'item with ID').to.be.undefined
})
})

The application deletes the item created via API call

At Mercari US we run a lot of end-to-end tests and we almost exclusively use API calls to create data for the UI tests to use. We use cy.request command to make GraphQL calls.

App actions

How do we know what API calls to make? We spied on the POST /todos network call and inspected the request body. But the request could be complicated. There could be several requests needed to set up data, and we would be adding more complexity to our tests. On the other hand, our application knows how to make a REST API call to create the new data item - it makes it itself when the user clicks the "Enter" key. Why can't our tests use the same code to avoid recreating it? After all, we have already confirmed the page UI is working correctly in the test "adds a new todo", the next test can simply call the code executed when the user clicks the "Enter" key.

To do this, our application needs to let the test call its "actions". In my application code I am setting the app variable on the window object when running inside Cypress test:

todomvc/app.js
1
2
3
if (window.Cypress) {
window.app = app
}

The app object is a Vue instance (but the implementation does not really matter, you can expose methods in any framework). Here is my page markup:

todomvc/index.html
1
2
3
4
5
6
7
8
9
<input
class="new-todo"
autofocus
autocomplete="off"
placeholder="What needs to be done?"
:value="newTodo"
@input="setNewTodo"
@keyup.enter="addTodo"
/>

The page calls "addTodo" method when the user clicks "Enter". The implementation of this method grabs the passed value and dispatches Vuex actions to create the item to the server and update the internal data store.

todomvc/app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
addTodo(e) {
if (typeof e === 'string') {
this.$store.dispatch('setNewTodo', e)
this.$store.dispatch('addTodo')
this.$store.dispatch('clearNewTodo')
return
}

// do not allow adding empty todos
if (!e.target.value.trim()) {
throw new Error('Cannot add a blank todo')
}
e.target.value = ''
this.$store.dispatch('addTodo')
this.$store.dispatch('clearNewTodo')
}

Ok, do we see this method from the browser? Yes - from the DevTools you can see window.app object with the method addTodo

You can access the window.app object from the browser DevTools

Anything your application sets as a property of the window object can then be accessed using the cy.window command. In the test below, we get the app's window after we visit the page, from the window object we grab the property app using cy.its command, then invoke the addTodo method using cy.invoke command.

cypress/e2e/app-actions/complete.cy.ts
1
2
3
4
5
6
7
8
9
10
11
it('completes a new todo', () => {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
const id = String(Cypress._.random(1e5))
const title = `${Cypress.currentTest.title} ${id}`
// execute app action from our test
cy.window().its('app').invoke('addTodo', title)
cy.contains('.todo', title).should('be.visible').find('.toggle').click()
cy.contains('.todo', title).should('have.class', 'completed')
})

The application's code does everything for us: we neither duplicate any logic, nor tie our specs to the implementation details.

Test uses application code to create a new item

Similarly, we can create an item to delete it.

cypress/e2e/app-actions/delete.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it('deletes a todo', () => {
cy.visit('/')
// let the application load the data from the server
cy.get('body').should('have.class', 'loaded')
const id = String(Cypress._.random(1e5))
const title = `${Cypress.currentTest.title} ${id}`
// execute app action from our test
cy.window().its('app').invoke('addTodo', title)
cy.contains('.todo', title)
.should('be.visible')
.find('.destroy')
.click({ force: true })
cy.contains('.todo', title).should('not.exist')
cy.request('GET', '/todos')
.its('body')
.then((list) => {
expect(Cypress._.find(list, { id }), 'item with ID').to.be.undefined
})
})

The lines calling the application's method are the key to the app action principle.

1
2
// execute app action from our test
cy.window().its('app').invoke('addTodo', title)

If you understand how to apply this concept from Cypress end-to-end and component tests, you will never look back 😉

Code coverage

You might ask me: aren't we bypassing the application code when calling app actions? Yes we do - but only in some tests. We still exercise the "normal" application code that adds an item in the spec "adding.cy.ts". We then bypass it in other specs that test other features. The combined code coverage when you run all specs together would show 100% code coverage.

Bonus 1: Use cy-spok with network requests

When spying on the API calls made by the application, we could only validate some properties of the request object, since the id is a dynamic random string.

1
2
3
4
5
6
7
8
9
10
11
// spy on the API call the app is making to create a todo
cy.intercept('POST', '/todos').as('addTodo')
...
// confirm the API request
cy.wait('@addTodo')
.its('request.body')
.should('deep.include', {
title,
completed: false,
})
.and('have.property', 'id')

I strongly recommend using cy-spok plugin to validate complex objects. In our case, the ID is a 10-character string of digits.

The todo item in the network request sent to the backend

Let's validate the entire object.

cypress/e2e/api/adding-cy-spok.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// spy on the API call the app is making to create a todo
cy.intercept('POST', '/todos').as('addTodo')
...
cy.wait('@addTodo')
.its('request.body')
.should(
spok({
title,
completed: false,
// id property should match this regular expression
id: spok.test(/^\d{10}$/),
}),
)

The Command Log shows each property passing its validation

Confirming the Todo object using cy-spok

For more cy-spok examples read the blog posts How To Check Network Requests Using Cypress and Server Running Inside Cypress Plugin Process.

See also