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'
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.
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.
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:
// 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'
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()
functionrender(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) })
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:
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.
functionthreeTodos(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)) }, }) }
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.