Check Redux Store Using cy-spok

How to validate a Redux state using cy-spok plus dispatch actions from a Cypress test.

This blog post shows my two favorite things about Cypress tests:

  1. Validating complex objects and property types using cy-spok
  2. Driving the application by dispatching actions directly rather than always going through the UI

🎁 You can find the source application used to write this blog post int the repo bahmutov/todo-react-redux. You can also find the concepts described in this blog post explained through hands-on exercises in my course Cypress Plugins.

The application and the first test

Our application is a TodoMVC where the user can add and delete todo items.

TodoMVC application

We can write an end-to-end test that adds the todos and checks the page to make sure the expected todos are displayed.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
it('adds todos', () => {
// add a few todos using the application UI
cy.visit('/')
cy.get('input[placeholder="Add new todo here"]')
.type('Learn Cypress{enter}')
.type('Learn JavaScript{enter}')
// confirm the page works
cy.get('[data-cy=todo]').should('have.length', 2)
cy.contains('[data-cy="pending-count"]', '2')
})

The first end-to-end test

The Redux store

Our application uses the Redux store to keep all its data.

src/store/store.ts
1
2
3
4
5
6
7
8
9
10
11
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './reducers/todosSlice'

export const store = configureStore({
reducer: {
todos: todosReducer,
},
})

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
src/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
import ReactDOM from 'react-dom'
import './index.scss'
import App from './App'
import { store } from './store/store'
import { Provider } from 'react-redux'

ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
)

What does the store keep inside? Is the data there correct and follows the expected rules as we manipulate the application through the user interface? If we have React DevTools browser extension, we can see the component and find the store, even if we cannot call getState() method to see the actual values (as far as I know)

Redux store shown using the React DevTools browser extension

How can we see what's inside that data store? By placing it on the window object during Cypress tests:

src/store/store.ts
1
2
3
4
5
6
7
export const store = configureStore({ ... })

// @ts-ignore
if (window.Cypress) {
// @ts-ignore
window.store = store
}

Note: I make the TS types check pass using @ts-ignore comments. You could also extend the global application window type definition as described in Convert Cypress Specs from JavaScript to TypeScript to make the types work.

You should be able to open the DevTools in the Cypress browser, change the context to "Your project" and access the data directly using window.store.getState() method call.

Access the Redux store from DevTools console

Great, now let's check the data in the store.

Check the data using cy-spok

Once the test adds two todos, let's confirm the internal redux data. We will use cy.window, cy.its, and cy.invoke.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('adds todos', () => {
// add a few todos using the application UI
cy.visit('/')
cy.get('input[placeholder="Add new todo here"]')
.type('Learn Cypress{enter}')
.type('Learn JavaScript{enter}')
// confirm the page works
cy.get('[data-cy=todo]').should('have.length', 2)
cy.contains('[data-cy="pending-count"]', '2')

// we assume the application sets "window.store"
// if running inside the Cypress test
cy.log('**check the Redux state**')
cy.window()
.its('store')
.invoke('getState')
.should(...)
})

Hmm, what should we write inside the should? We don't know the random IDs, so we cannot use deep.equal assertion. This is where the cy-spok shines. It lets us confirm complex data and/or types.

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
import spok from 'cy-spok'

// we assume the application sets "window.store"
// if running inside the Cypress test
cy.log('**check the Redux state**')
cy.window()
.its('store')
.invoke('getState')
.should(
spok({
todos: {
data: [
{
$topic: 'The second todo',
text: 'Learn JavaScript',
id: spok.number,
},
{
$topic: 'The first todo',
text: 'Learn Cypress',
id: spok.number,
},
],
},
})
)

Check the Redux store using cy-spok

The spok(...) functions the actual callback that will be called with the data:

1
2
3
4
5
const callback = spok({ ... })
cy.window()
.its('store')
.invoke('getState')
.should(callback)

Now that we can access the Redux store to check what it contains, what else can we do?

Drive application by dispatching actions

Take a look at Todo component. It calls the removeTodo Redux action when the user clicks the Trash can button.

src/components/Todo.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import style from './todo.module.scss'
import remove from '../assets/icons/remove.svg'
import { useAppDispatch } from '../store/hooks'
import { removeTodo } from '../store/reducers/todosSlice'

function Todo(props: { text: string; id: number }) {
const dispatch = useAppDispatch()

function removeHandler() {
dispatch(removeTodo(props.id))
}

return (
<div className={style.todo} data-cy="todo">
<span>{props.text}</span>
<img src={remove} alt="remove" onClick={removeHandler} />
</div>
)
}

export default Todo

Can we call the removeTodo action from the test bypassing the user interface. This is what I call App Actions and it is possible because the Cypress spec runs in the same window as the application. We will remove the first todo item using its ID.

spec.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
44
45
46
import spok from 'cy-spok'
// import a function from the application code
// to help us construct an action to dispatch
import { removeTodo } from '../../src/store/reducers/todosSlice'

it('adds todos', () => {
// add a few todos using the application UI
cy.visit('/')
cy.get('input[placeholder="Add new todo here"]')
.type('Learn Cypress{enter}')
.type('Learn JavaScript{enter}')
// confirm the page works
cy.get('[data-cy=todo]').should('have.length', 2)
cy.contains('[data-cy="pending-count"]', '2')

// we assume the application sets "window.store"
// if running inside the Cypress test
cy.log('**check the Redux state**')
cy.window()
.its('store')
.invoke('getState')
.should(
spok({
todos: {
data: [
{
$topic: 'The second todo',
text: 'Learn JavaScript',
id: spok.number,
},
{
$topic: 'The first todo',
text: 'Learn Cypress',
id: spok.number,
},
],
},
}),
)
// grab the ID of the first todo item
// grab the Redux store again and dispatch the remove todos action
.its('todos.data.0.id')
.then((id) => {
cy.window().its('store').invoke('dispatch', removeTodo(id))
})
})

Once we get the store, we invoke the dispatch method and we even use Redux action removeTodo from the application source to help us construct the argument simpler.

Dispatching the Redux action from the test

Beautiful. All we need to do is to confirm the application has correctly updated its DOM.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cy.window()
.its('store')
.invoke('getState')
.should(spok(...))
// grab the ID of the first todo item
// grab the Redux store again and dispatch the remove todos action
.its('todos.data.0.id')
.then((id) => {
cy.window().its('store').invoke('dispatch', removeTodo(id))
})
// once we remove the first todo, the UI should update
// confirm the UI changes and the single todo remains
cy.contains('[data-cy="pending-count"]', '1')
cy.contains('[data-cy=todo]', 'Learn Cypress')

Next, we can use cy-spok again to check the updated Redux store state:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// get the Redux store again and confirm the data in it
cy.log('**redux store**')
cy.window()
.its('store')
.invoke('getState')
.should(
spok({
todos: {
data: [
{
$topic: 'The first todo',
text: 'Learn Cypress',
id: spok.number,
},
],
},
}),
)

Very nice.

See also