Cypress Tests For Apps That Use Central State

Access the application state for faster and more powerful e2e tests.

Let's say you are testing an app that derives its UI from its state object. All data is stored in an object that serialized in our example to/from localStorage.

public/state.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// application data. Increment the suffix counter
// if the schema changes to get fresh state
// or implement data migration logic
const storeKey = 'todos-state-based-1'

export function getState() {
const defaultState = {
todos: [],
}
return JSON.parse(localStorage.getItem(storeKey)) || defaultState
}

export function saveState(state) {
localStorage.setItem(storeKey, JSON.stringify(state))
}
public/app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { getState, saveState } from './state.js'

// DOM elements
const app = document.querySelector('#app')
const form = document.querySelector('#add-todo')

const state = getState()
...
/**
* Create the HTML based on the app state
*/
function getHTML(state) {
...
}

// Add todos when form is submitted
form.addEventListener('submit', addTodo)

// Remove todos when delete button is clicked
document.addEventListener('click', removeTodo)

// Render the UI
app.innerHTML = getHTML(state)

The app is very simple: add and delete Todos

The application

🎁 You can find the source code for this blog post in the repo bahmutov/state-ui-example.

How can we test this application more efficiently?

UI E2E tests

We can add Cypress and write end-to-end tests for the main features.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('Todo app', () => {
it('adds todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
})

it('removes todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

The second test "removes todo" repeats the first one. Let's simplify it. The application renders its page based on what it reads from the localStorage. Let's take the second test and start it from a state with 3 todo items.

cypress/e2e/spec2.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
describe('Todo app', () => {
it('adds todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
})

it('removes todos', () => {
const todos = ['write code', 'write tests', 'deploy']
localStorage.setItem('todos-state-based-1', JSON.stringify({ todos }))

cy.visit('/')
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

The two first lines are crucial

1
2
const todos = ['write code', 'write tests', 'deploy']
localStorage.setItem('todos-state-based-1', JSON.stringify({ todos }))

The test looks exactly like before, only it is much faster: 914ms for the original test vs 224ms for setting the state. Even in our simple app, going directly to the application without using the page UI pays off.

Import application code from the spec

Very nice. But we don't want to hard-code the localStorage.setItem('todos-state-based-1', JSON.stringify({ todos })) logic. Let's simply use the application's own source code.

cypress/e2e/spec3.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { saveState } from '../../public/state'

describe('Todo app', () => {
it('adds todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
})

it('removes todos', () => {
const todos = ['write code', 'write tests', 'deploy']
saveState({ todos })

cy.visit('/')
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

Works. What if we don't have access to the source files? We can use the application code via window objects, what I call "app actions". Modify the application source code to set the functions you want to call from the test code first:

public/state.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// application data. Increment the suffix counter
// if the schema changes to get fresh state
// or implement data migration logic
const storeKey = 'todos-state-based-1'

export function getState() {
const defaultState = {
todos: [],
}
return JSON.parse(localStorage.getItem(storeKey)) || defaultState
}

export function saveState(state) {
localStorage.setItem(storeKey, JSON.stringify(state))
}

if (window.Cypress) {
window.getState = getState
window.saveState = saveState
}

Now use the window.saveState from the test. The only problem: the application immediately calls getState on load:

public/app.js
1
2
3
4
5
6
7
8
import { getState, saveState } from './state.js'

// DOM elements
const app = document.querySelector('#app')
const form = document.querySelector('#add-todo')

const state = getState()
...

So our test needs to be fast.

cypress/e2e/spec4.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
describe('Todo app', () => {
it('adds todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
})

it('removes todos', () => {
const todos = ['write code', 'write tests', 'deploy']

cy.visit('/', {
onBeforeLoad(win) {
let saveState
Object.defineProperty(win, 'saveState', {
get() {
return saveState
},
set(fn) {
saveState = fn
// immediately set the state from the test
saveState({ todos })
},
})
},
})
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

We prepare the test by spying on the application setting the window.saveState property, and as soon as we get the function reference, we call it to set the state object. Thus during testing it looks like this:

app.js
1
2
3
4
5
6
7
import { getState, saveState } from './state.js'

// calls window.saveState = fn
// test calls fn({ todos })

const state = getState()
// application has the state set by the spec

Nice.

Reload the page

We can do another thing. We could visit the page, get the reference to the saveState method, and then reload the page to have application load it.

cypress/e2e/spec4.cy.js
1
2
3
4
5
6
7
8
9
10
it('removes todos (with reload)', () => {
const todos = ['write code', 'write tests', 'deploy']

// cy.visit yields the "window" object, thus we can
// quickly invoke the "saveState" method
cy.visit('/').invoke('saveState', { todos }).reload()
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})

If we are ok exposing the render method from the application code:

public/app.js
1
2
3
4
5
6
7
8
9
10
let state = getState()

function render(s) {
state = s
// Render the UI
app.innerHTML = getHTML(state)
}
if (window.Cypress) {
window.render = render
}

Then we can avoid the reload and simply call render

1
2
3
4
5
6
7
8
9
10
11
it('removes todos (using app render)', () => {
const todos = ['write code', 'write tests', 'deploy']

// cy.visit yields the "window" object
// once the app sets the "window.render" method
// we can call it with new state
cy.visit('/').invoke('render', { todos })
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})

Set the state using the app render method

State checkpoints

How do we know if we set the right state at the start of the second test? It should be whatever the state was at the end of the first test. If we are ok with introducing the test order, we could do the following:

cypress/e2e/spec5.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
import { getState } from '../../public/state'

describe('Todo app', () => {
let state

it('adds todos', () => {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item')
.should('have.length', 3)
.then(() => {
state = getState()
expect(state, 'state').to.deep.equal({
todos: ['write code', 'write tests', 'deploy'],
})
})
})

it('removes todos (with reload)', () => {
cy.visit('/').invoke('saveState', state).reload()
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

The state checkpoint at the end of the first test

We can verify the entire state or parts of it. The second test starts where the first test finished.

Cypress data session

Finally, if you are not using cypress-data-session in your tests, you should do it. Here is how I would write the same test that would reuse the same state between the test but allows each test to be independent.

cypress/e2e/spec6.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
34
35
36
37
38
39
40
41
42
43
// https://github.com/bahmutov/cypress-data-session
import 'cypress-data-session'

function threeTodos(recompute = false) {
// clear the data session forcing it to call the `setup` method
// and use the UI to create the todos and grab the state
if (recompute) {
Cypress.clearDataSession('threeTodos')
}
cy.dataSession({
name: 'threeTodos',
setup() {
cy.visit('/')
cy.get('input#todo')
.type('write code{enter}')
.type('write tests{enter}')
.type('deploy{enter}')
cy.get('.todos .item').should('have.length', 3)
cy.window().invoke('getState')
},
// if there is a state in memory, use it
// to set the application instantly
recreate(state) {
// it helps to clone the object
// to prevent mutations from changing the value
// inside the data session
cy.visit('/').invoke('render', structuredClone(state))
},
})
}

describe('Todo app', () => {
it('adds todos', () => {
threeTodos(true)
})

it('removes todos', () => {
threeTodos()
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})
})

The first test "adds todos" always goes through the UI and the state is saved in the data session called threeTodos. The second test starts by recreating the application from the state.

Both tests ran together

Imagine you want to run the second test only

1
2
3
4
5
6
it.only('removes todos', () => {
threeTodos()
cy.get('.todos .item').should('have.length', 3)
cy.contains('.item', 'write tests').contains('button', 'Delete').click()
cy.get('.todos .item').should('have.length', 2)
})

The memory data session is still there, so it immediately recreates the session using the 3 todos.

The second test in isolation reused the state session

Nice!