Magic Backed For E2E Testing

A Cypress plugin for recording and replaying backend API requests.

I want to show you something cool I have been working on: cypress-magic-backend. It is a simple Cypress plugin for automatically recording and replaying API network calls during end-to-end tests. I think this plugin allows the holy grail of fast web testing: run the tests super quickly by removing the real API backend completely.

Let's take an example application cypress-magic-backend-example. It is a TodoMVC application. You load the front-end application, the front-end code loads the data by making API calls to the endpoint /todos. The website makes lots of network calls:

  • the HTML page itself
  • the CSS styles resources
  • the JavaScript scripts
  • an XMLHttpRequest API request to load the todo items

All network calls the browser makes to load the initial TodoMVC

The last XMLHttpRequest network call is the difficult one; all other requests can be served by any static HTTP server (nginx, apache, etc). The API call GET /todos is the hard one, since it requires executing server logic and possible database access and even calls to other APIs.

GET /todos API call

This GET /todos API call is the one returning the data.

GET /todos API response

Serving static HTML, CSS, and JavaScript resources is simple. Many platforms even allow serving such static resources for free (think GitHub Pages). Running API servers is much harder; you need to execute the actual server code. This makes it difficult and sometimes slow to run end-to-end tests, since you need the real API, the real backend during testing.

Enter cypress-magic-backend. You probably do need the real backend while writing the end-to-end test. In my example application, I have started the local application and opened Cypress. Let's write a test for adding a new todo item.

cypress/e2e/add-todo.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
beforeEach(() => {
cy.request('POST', '/reset', { todos: [] })
})

it('adds a todo', () => {
cy.visit('/')
cy.log('**confirm the items are loaded**')
cy.get('.loaded')
cy.get('.new-todo').type('item 1{enter}')
cy.get('li.todo').should('have.length', 1)
cy.get('.new-todo').type('item 2{enter}')
cy.get('li.todo').should('have.length', 2)
cy.log('**confirm the items are saved**')
cy.reload()
cy.get('li.todo').should('have.length', 2)
})

Run the tests

Great, the test is working, but it feels slow. Of course, our local / dev / staging API server is underpowered compared to the production. A typical situation, don't you think? No problem. Our test makes the same API calls during the test, and those calls like GET /todos and POST /todos are slow; each takes 1 second.

Each API call to /todos takes 1 second

So... we want to do two things:

  1. Remove the need to run API server while testing our frontend logic
  2. Make the test much much faster

Let's do our magic backend thing. We want to mock all calls to /todos API endpoint. Let's go into cypress.config.js and configure our magic backend

cypress.config.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
const { defineConfig } = require('cypress')

module.exports = defineConfig({
env: {
// https://github.com/bahmutov/cypress-magic-backend
magicBackend: {
// this app makes "XHR" calls to load and update "/todos"
apiCallsToIntercept: {
method: '*',
// match calls like
// GET /todos
// POST /todos
// DELETE /todos/1234
pathname: '/todos{/*,}',
},
},
},
e2e: {
// baseUrl, etc
baseUrl: 'http://localhost:3000',
fixturesFolder: false,
setupNodeEvents(on, config) {
// implement node event listeners here
// and load any plugins that require the Node environment
},
},
})

We are only interested in every call to /todos endpoint, so we define it in apiCallsToIntercept following the same syntax as any cy.intercept command. Tip: network call interception uses minimatch library under the hood. You can even test the match expression in your Cypress browser!

Testing the API intercept pattern using Cypress.minimatch utility

Let's load the cypress-magic-backend plugin from our E2E support file

cypress/support/e2e.js
1
2
// https://github.com/bahmutov/cypress-magic-backend
import 'cypress-magic-backend'

Good. Now let's click the "Record" button "🪄 🎥" the plugin adds to the top of the Command Log. It simply runs the spec again, but this time it intercepts and saves all observed network calls into a JSON file

The saved JSON file is unique per spec + test title. In our case it has 4 API calls:

  • the initial GET /todos returns an empty list of todos
  • adding the new todos via POST /todos calls. Each call sends the Todo object with its title, completed, and id fields.
  • the last GET /todos call after the page reloads
cypress/magic-backend/cypress/e2e/add-todo.cy.js_adds_a_todo_api_calls.json
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
{
"name": "cypress-magic-backend",
"version": "1.0.3",
"apiCallsInThisTest": [
{
"method": "GET",
"url": "http://localhost:3000/todos",
"request": "",
"response": [],
"duration": 1012
},
{
"method": "POST",
"url": "http://localhost:3000/todos",
"request": {
"title": "item 1",
"completed": false,
"id": "6632484606"
},
"response": {
"title": "item 1",
"completed": false,
"id": "6632484606"
},
"duration": 1009
},
{
"method": "POST",
"url": "http://localhost:3000/todos",
"request": {
"title": "item 2",
"completed": false,
"id": "4342009180"
},
"response": {
"title": "item 2",
"completed": false,
"id": "4342009180"
},
"duration": 1010
},
{
"method": "GET",
"url": "http://localhost:3000/todos",
"request": "",
"response": [
{
"title": "item 1",
"completed": false,
"id": "6632484606"
},
{
"title": "item 2",
"completed": false,
"id": "4342009180"
}
],
"duration": 1009
}
]
}

Once the spec has been recorded, click the Replay "🪄 🎞️" button.

Notice the test became much faster; it went from 6 seconds to 2 seconds because all GET and POST calls to /todos were mocked. We no longer need the real API endpoint, we simply need the frontend to do the same API calls, which it should. We can make the test even faster. When replaying the backend calls, there is no need to reset the items. Thus we can skip the cy.request call at the start

cypress/e2e/add-todo.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
beforeEach(() => {
const mode = Cypress.env('magic_backend_mode')
if (mode !== 'playback') {
mode && cy.log(`during the test the mode is "${mode}"`)
cy.request('POST', '/reset', { todos: [] })
}
})

it('adds a todo', () => {
cy.visit('/')
cy.log('**confirm the items are loaded**')
cy.get('.loaded')
cy.get('.new-todo').type('item 1{enter}')
cy.get('li.todo').should('have.length', 1)
cy.get('.new-todo').type('item 2{enter}')
cy.get('li.todo').should('have.length', 2)
cy.log('**confirm the items are saved**')
cy.reload()
cy.get('li.todo').should('have.length', 2)
})

The test can check the current backend mode by fetching it from the Cypress.env object and control its behavior.

The test is now super fast, since ALL api calls are mocked and the initial cy.request /reset is skipped. We went from 6 seconds to 500ms. You can see all stubbed network calls since they have the 🪄 🎞️ alias.

Replay backend mode finishes in 500ms by automatically stubbing all API calls with pre-recorded JSON file

Great, how do we use it?

  • We record all specs ourselves running the tests locally or on CI once per day.
  • We commit the cypress/magic-backend folder with JSON files into the source control.
  • When we want to run fast, we simply click the 🪄 🎞️ button
  • On CI, we can set the replay mode via Cypress env option

Here is GitHub Actions workflow executing both jobs in parallel. One job records the API calls, another simply replays them.

.github/workflows/ci.yml
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
name: ci
on: push
jobs:
record-mode:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Record responses
uses: cypress-io/github-action@v6
with:
start: npm start
env:
# record all API calls
CYPRESS_magic_backend_mode: recording

- name: Show changes JSON files
run: git status

playback-mode:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Cypress against static backend
uses: cypress-io/github-action@v6
with:
start: npm run start:static
env:
# assume that all backend calls to "/todos" are pre-recorded
# in the cypress/magic-backend folder
# and on CI the API backend should be in playback mode
# and completely stubbed
CYPRESS_magic_backend_mode: playback

The npm run start:static script runs a static server without /todos resource API endpoint. Can you get which spec execution corresponds to the record-mode vs playback-mode job?

The difference in timings between real API calls and mocked backend

Nice and fast.

Wait, what does the 3rd button do?

What does the "🪄 🧐" button do?

Stay tuned, it is something really special!

👨🏻‍⚖️ I have released cypress-magic-backend under a dual license. If you are an open-source project, a non-profit, or a for-profit company under 100 employees, feel free to use the plugin for your testing needs. If you are a commercial company with more than a 100 employees and individual contractors, you need to buy a license to use the plugin, even internally. The license fees allow me to work on this and many more testing plugins.