Use Cypress For API Testing

How to write API tests using Cypress end-to-end test runner.

Cypress is known as an End-To-End test runner, but it can happily run component, unit, and even API tests. I have projects that use Cypress just for API testing. It works pretty nicely: rich set of commands and assertions, good user interface, easy to run on CI. Using plugins like @bahmutov/cy-api and cypress-plugin-api you can even give API tests nice graphical interface inside the browser.

In this blog post I will give examples of Cypress api tests using the use-cypress-for-api-testing application as my example. The application is a simple TodoMVC web application on top of REST API. There are endpoints to create, delete, and modify Todo items, plus an endpoint to reset the data:

  • GET /todos returns all todo items
  • POST /todos adds a new todo item
  • PATCH /todos/:id updates the given todo item
  • DELETE /todos/:id deletes on todo item
  • POST /reset replaces the entire backend data with the given object

Let's write a few API tests.

Table of Contents

Adding new todos

Let's write a simple API test that resets all todos, adds a todo, then fetches all items. We will use the core Cypress command cy.request to make all HTTP calls

cypress/e2e/api.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('TodoMVC API', () => {
it('adds one item', () => {
// clear all existing backend data
cy.request('POST', '/reset', { todos: [] })
// confirm there are no todo items
cy.request('GET', '/todos')
.its('body')
.should('have.length', 0)
// add a new todo item
const todo = {
title: 'write a test',
completed: false,
id: 1,
}
cy.request('POST', '/todos', todo)
// confirm all todos now has a single item
cy.request('GET', '/todos')
.its('body')
.should('deep.equal', [todo])
})
})

The test passes. There is nothing to render in the Cypress application frame on the right, since we never did cy.visit in the test. Still, the Command Log on the left shows every API test command.

The passing API test

If we are using the Cypress interactive mode cypress open we can click on the REQUEST command to see the request and the response. For example, let's examine the cy.request('POST', '/todos', todo) command

Inspect the new item sent by the test

When using the interactive cypress open mode you can inspect each call by clicking on the command.

Update an item

Once we know our backend API supports adding and fetching new items, let's confirm that we can complete an item.

cypress/e2e/api.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
24
describe('TodoMVC API', () => {
it('completes an item', () => {
// clear all existing backend data
cy.request('POST', '/reset', { todos: [] })
// add a new todo item
const todo = {
title: 'write a test',
completed: false,
id: 1,
}
cy.request('POST', '/todos', todo)
// complete an item by patching the existing item
cy.request('PATCH', '/todos/1', { completed: true })
// confirm the item is now completed
cy.request('GET', '/todos')
.its('body')
.should('deep.equal', [
{
...todo,
completed: true,
},
])
})
})

Complete an item API test

Delete an item

Let's confirm that we can delete an item using an API call. We can take a shortcut and set the backend data using POST /reset instead of creating individual items via POST /todos. We know that adding items is working from the first test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
describe('TodoMVC API', () => {
it('deletes an item', () => {
// set several todos at once
const todos = [
{
title: 'write a test',
completed: false,
id: 1,
},
{
title: 'write another test',
completed: true,
id: 2,
},
]
cy.request('POST', '/reset', { todos })
// delete the first item
cy.request('DELETE', '/todos/1')
// confirm the remaining items
cy.request('GET', '/todos')
.its('body')
.should('deep.equal', [todos[1]])
})
})

Deleting an item via API works

Show more data during assertions

In our tests we used the built-in Chai assertions like deep.equal. By default, these assertions truncate the data shown in the Command Log. Let's increase the truncation threshold to see more information.

1
2
3
4
5
6
// show more information in each assertion
chai.config.truncateThreshold = 300

describe('TodoMVC API', () => {
...
})

Short objects are shown fully in the Chai assertions

Show the full request and response

Since we have an empty web application frame on the right and want to see more information for each request, let's redirect the request / response to the frame. I will use the cypress-plugin-api plugin to give my API tests a nice UI.

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
// https://github.com/filiphric/cypress-plugin-api
import 'cypress-plugin-api'

// show more information in each assertion
chai.config.truncateThreshold = 300

describe('TodoMVC API', () => {
...
it('deletes an item', () => {
// set several todos at once
const todos = [
{
title: 'write a test',
completed: false,
id: 1,
},
{
title: 'write another test',
completed: true,
id: 2,
},
]
cy.api('POST', '/reset', { todos })
// delete the first item
cy.api('DELETE', '/todos/1')
// confirm the remaining items
cy.api('GET', '/todos')
.its('body')
.should('deep.equal', [todos[1]])
})
})

We simply imported the plugin and used cy.api instead of cy.request to make the calls. The Cypress UI is now much nicer.

cypress-plugin-api reflects each request and response in the web application frame

We can even combine cy.request and cy.api to only show the relevant test information. For example, we might not want to see the POST /reset calls while being interested in the other calls in the test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('completes an item', () => {
// clear all existing backend data
cy.request('POST', '/reset', { todos: [] })
// add a new todo item
const todo = {
title: 'write a test',
completed: false,
id: 1,
}
cy.api('POST', '/todos', todo)
// complete an item by patching the existing item
cy.api('PATCH', '/todos/1', { completed: true })
// confirm the item is now completed
cy.api('GET', '/todos')
.its('body')
.should('deep.equal', [
{
...todo,
completed: true,
},
])
})

Combine cy.request and cy.api commands

Complex data assertions

When creating a new item using POST /todos call, the app sends the item, and the server responds with the same item. If the sent item does not include the id property, the server will assign one. The server signals the success by returning status code 201. Let's confirm this.

cypress/e2e/api.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('gets an id from the server', () => {
cy.request('POST', '/reset', { todos: [] })
const todo = {
title: 'write a test',
completed: false,
}
cy.api('POST', '/todos', todo).then((response) => {
expect(response.status, 'status code').to.equal(201)
// we don't know what id the server will assign
// thus we cannot compare the whole response body
expect(response.body, 'body').to.deep.include(todo)
expect(response.body.id, 'id').to.be.a('number')
})
})

The assigned id test

The test is becoming verbose, and the Command Log is getting noisy. Let's use cy-spok plugin to make our assertions more powerful.

cypress/e2e/api.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// https://github.com/filiphric/cypress-plugin-api
import 'cypress-plugin-api'
// https://github.com/bahmutov/cy-spok
import spok from 'cy-spok'

it('gets an id from the server (cy-spok)', () => {
cy.request('POST', '/reset', { todos: [] })
const todo = {
title: 'write a test',
completed: false,
}
cy.api('POST', '/todos', todo).should(
spok({
status: 201,
body: {
...todo,
id: spok.number,
},
}),
)
})

Using a single cy-spok assertion we can match complex objects with nested properties and predicate checks.

The nice cy-spok assertions

Async mode

Often API tests get something from the server and use that piece of data to make new calls. For example, if we want to get the ID of the created item to delete it, we could write the following test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('deletes the created item using its id', () => {
cy.request('POST', '/reset', { todos: [] })
const todo = {
title: 'write a test',
completed: false,
}
cy.api('POST', '/todos', todo)
.its('body.id')
.should('be.a', 'number')
// any time we get something from the application
// we need to "pass it forward" into the next command
.then((id) => {
cy.api('DELETE', `/todos/${id}`)
.its('status')
.should('equal', 200)
})
})

Anytime the test gets something from the application, it needs to pass it forward to the cy.then(callback) or to the next assertion. Some people are intimidated by such coding style; they miss async / await syntax. For them I wrote cypress-await plugin that allows you to use the await syntax with the Cypress command chains. Here is how we can rewrite the above test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/filiphric/cypress-plugin-api
import 'cypress-plugin-api'

it('deletes the created item using its id (cypress-await)', async () => {
await cy.request('POST', '/reset', { todos: [] })
const todo = {
title: 'write a test',
completed: false,
}
const id = await cy
.api('POST', '/todos', todo)
.its('body.id')
.should('be.a', 'number')
await cy.log(`deleting todo ${id}`)
await cy
.api('DELETE', `/todos/${id}`)
.its('status')
.should('equal', 200)
})

The test works the same, but it might be more intuitive to read.

Using await in Cypress tests via cypress-await plugin

Bonus: cypress-await plugin includes a sync mode which allows you to drop the verbose await in front of every cy command. The test runs the same and becomes simply:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// sync mode from cypress-await
it('deletes the created item using its id (cypress-await)', () => {
cy.request('POST', '/reset', { todos: [] })
const todo = {
title: 'write a test',
completed: false,
}
const id = cy
.api('POST', '/todos', todo)
.its('body.id')
.should('be.a', 'number')
cy.log(`deleting todo ${id}`)
cy.api('DELETE', `/todos/${id}`)
.its('status')
.should('equal', 200)
})

Run tests on CI

We want to run API tests on CI from day one. Let's use GitHub Actions. We can use cypress-io/github-action to manage dependencies, caching, and running Cypress for us:

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: ci
on: [push]
jobs:
tests:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
# https://github.com/cypress-io/github-action
- name: Cypress run
uses: cypress-io/github-action@v6
with:
# check the spec types
build: npm run lint
# start the application before running Cypress
start: npm start

Running Cypress tests on GitHub Actions

Beautiful. Want even more power? Learn how to run tests in parallel using GitHub Actions in this video. In general, Cypress API tests running in the browser are slower than an equivalent Node-only API tests. But the debugging is faster, and on CI I can parallelize the tests using a one minute change; see cypress-split and cypress-workflows.

Loop through data

Because Cypress queues up its commands, it is easy to iterate over collections. For example, let's see how we can delete each item one by one

cypress/e2e/delete.cy.js
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
// https://github.com/filiphric/cypress-plugin-api
import 'cypress-plugin-api'

// show more information in each assertion
chai.config.truncateThreshold = 300

describe('TodoMVC API', () => {
beforeEach(() => {
// load the fixture with four todos
cy.fixture('four-todos').as('data')
})

// we can access the fixture data using "this.data"
// inside the "it... function () {}"
it('deletes all items one by one', function () {
expect(this.data, 'loaded todos')
.to.have.property('todos')
.and.have.length(4)
cy.request('POST', '/reset', this.data)
// confirm there are four todo items
cy.api('GET', '/todos')
.its('body')
.should('have.length', 4)
// delete each item one by one
this.data.todos.forEach((todo) => {
cy.api('DELETE', `/todos/${todo.id}`)
})
// there should be no todos left
cy.api('GET', '/todos')
.its('body')
.should('have.length', 0)
})
})

The expression to delete each Todo item simply queues up 4 Cypress commands:

1
2
3
4
// delete each item one by one
this.data.todos.forEach((todo) => {
cy.api('DELETE', `/todos/${todo.id}`)
})

Deleting each todo one by one

Produce detailed report

Since API tests do not use the browser to show the web page, there is less information to go on when the test fails. We can produce detailed terminal log (stdout and even JSON) using the plugin cypress-terminal-report. Here is an example terminal report output for the above "deletes all items one by one" test produced.

Terminal report for the API test

Combine API and Web testing

Finally, a nice feature of Cypress API tests is how easy it is to combine them with the UI web tests. Let's see one such example. The example application reloads all todos every minute:

todomvc/app.js
1
2
3
4
// how would you test the periodic loading of todos?
setInterval(() => {
this.$store.dispatch('loadTodos')
}, 60_000)

Let's write a test that quickly tests this feature. We will create the initial data using API calls, then visit the site and confirm the initial list is visible. Then we can delete an item using an API call, fast-forward the app clock by 1 minute, and confirm the web view has updated and shows N - 1 todos.

cypress/e2e/mixed-test.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// show more information in each assertion
chai.config.truncateThreshold = 300

describe('TodoMVC API', () => {
it('reloads the todos every minute', () => {
cy.fixture('four-todos').then((data) => {
cy.request('POST', '/reset', data)
})
cy.clock()
cy.visit('/')
cy.get('.todo-list li').should('have.length', 4)
// delete the first todo
// use cy.request and not cy.api to avoid
// overwriting the web application
cy.request('DELETE', '/todos/101')
// advance the clock by 1 minute
cy.tick(60_000)
// confirm the new data has loaded
cy.get('.todo-list li').should('have.length', 3)
})
})

I am using cy.clock and cy.tick commands to control the web application's clock. In between, we are deleting the data using the cy.request('DELETE', '/todos/101') command.

Test time-depending app features by controlling the clock

For more clock examples, see Spies, Stubs & Clocks.

Final thoughts

Cypress can be a powerful API testing tool. It has good API, assertions, and a visual interactive test runner great for seeing the test run. It also can be extended using open source plugins to provide even better API testing experience.

See also