Effective React Tests

How to write effective React tests using Cypress

Note: this blog posts closely follows How to write effective tests for React apps with react testing library? but uses Cypress and cypress-react-unit-test to write tests that work in the real browser.

In this blog post I will show how to write good tests for React components, for React components using a Redux store, and for custom React hooks.

🎁 You can find the source code for this post in the bahmutov/effective-react-tests repo.

The Setup

Let's create a few React components, and I will use react-scripts to bundle them

1
2
3
4
5
$ yarn add -D react react-dom react-scripts
info Direct dependencies
├─ [email protected]
├─ [email protected]
└─ [email protected]

After we have installed half of Internet, let's write a component that submits a form.

src/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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React from 'react';
function App() {
const [state, setState] = React.useState({
name: '',
age: '',
});
const handleSubmit = e => {
e.preventDefault();
}
const updateName = e => {
e.preventDefault();
setState({...state, name: e.target.value})
}
const updateAge = e => {
e.preventDefault();
setState({...state, age: e.target.value})
}
return (
<div className="App">
<h1>Welcome {state.name}</h1>
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={state.name} onChange={updateName} />
</label>
<br />
<label>
Age:
<input type="number" value={state.age} onChange={updateAge} />
</label>
<br />
<input type="submit" value="Submit" disabled={state.name === ''}/>
</form>
</div>
);
}
export default App;

Let's test it to ensure that the Age input has attribute type=number, and the Submit button is disabled when the Name field is empty.

Installing Cypress and cypress-react-unit-test

Cypress is a full end-to-end test runner, and cypress-react-unit-test is an adaptor that allows Cypress to mount React components. Once mounted, the components become full-fledged web applications, and you can use all Cypress commands to test the application like a real user would.

1
2
3
4
$ yarn add -D cypress cypress-react-unit-test
info Direct dependencies
├─ [email protected]
└─ [email protected]

Open Cypress once to scaffold its spec files

1
$ yarn cypress open

Then run the init command (this alias comes with cypress-react-unit-test install) to show you how to use cypress-react-unit-test with Cypress.

1
$ yarn init

The init wizard determines how React app is bundled

Cypress can detect and hook into bundlers using react-scripts (CRA), Next.js, Babel, and Webpack configs. In this case, we confirm that we want to use create-react-app settings used by the app. Then the wizard shows the code to paste into the cypress.json and cypress/plugins and cypress/support files to make sure the tests are bundled using your application's settings.

The suggestions shown by the wizard

Testing a React component

Let's write our first test.

src/App.spec.js
1
2
3
4
5
6
7
8
9
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import App from './App'

describe('App', () => {
it('works', () => {
mount(<App />)
})
})

Start Cypress and click the spec

1
$ yarn cypress open

The first test shows the component in action

Once we mount the component, we can write the test to check everything it needs to do. I am showing writing the test in the short video below.

The final test checks the Submit button, the welcome message, and the Age number input:

src/App.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import App from './App'

describe('App', () => {
it('works', () => {
mount(<App />)
// submit should be disabled
cy.get('input[type=submit]').should('be.disabled')
// type name, and the
cy.contains('label', 'Name:').find('input').type('Gleb')
// welcome message should have that name
cy.contains('h1', 'Welcome Gleb')
// Age input should have type number
cy.contains('label', 'Age:').find('input[type=number]')
})
})

The finished test

Note: you can argue that cy.contains('h1', 'Welcome Gleb') unnecessarily ties the text to the <h1> element. You might say the exact selector is an implementation detail. In that case, you may use command cy.contains('Welcome Gleb') to search just by text or regular expression, or use some other selector - it is your choice.

Code coverage

Code coverage is built into cypress-react-unit-test and does not need further configuration. Every time we run the tests, the coverage report in different formats is saved locally. Open the HTML static report to see every source files and how it was covered by the tests.

1
$ open coverage/lcov-report/index.html

Seems like we missed a couple of lines

The test missed a few source lines

You can extend the test and make sure every source line is executed. This end-to-end and by extension the component tests in the real browser are an excellent way to quickly reach 100% code coverage.

Mocking API requests

Let's look at a form and see how we can confirm the right data is being submitted. Let's take a Form component below

src/Form.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
import React from 'react';
import * as apiService from './api'

function SimpleAPIForm() {
const [state, setState] = React.useState({
message: '',
post: ''
});
const handleSubmit = async e => {
e.preventDefault();
const response = await apiService.postProducts(state.post);
if(response.ok) {
setState({
message: 'Successfully Created Post!!',
post: ''
})
}
}
const updateEmail = e => {
e.preventDefault();
setState({...state, post: e.target.value})
}
return (
<div className="App">
{state.message ? <h1>{state.message}</h1> : null}
<form onSubmit={handleSubmit}>
<label>
Body:
<input type="text" value={state.post} onChange={updateEmail} />
</label>
<br />
<input type="submit" value="Post" />
</form>
</div>
);
}
export default SimpleAPIForm;

It uses api.js to submit the form

src/api.js
1
2
3
4
5
6
7
8
9
export async function postProducts(title) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
};
const response = await fetch('https://reqres.in/api/products', requestOptions);
return response;
}

Imagine we are writing a test for the Form component using cypress-react-unit-test. How would we go about confirming the form executes a network call correctly with expected parameters?

We can submit the form using the user interface

src/Form.spec.js
1
2
3
4
5
6
7
8
9
10
import React from 'react'
import Form from './Form'
import { mount } from 'cypress-react-unit-test'

describe('Form', () => {
it('calls the api method to submit the form', () => {
mount(<Form />)
cy.contains('label', 'Body:').find('input').type('Sample{enter}')
})
})

How should we confirm it?

Via DOM

We can confirm the form has been submitted using the DOM change.

1
2
3
4
5
6
7
describe('Form', () => {
it('calls the api method to submit the form', () => {
mount(<Form />)
cy.contains('label', 'Body:').find('input').type('Sample{enter}')
cy.contains('h1', 'Successfully Created Post!!')
})
})

The test passes:

The form is submitted

Now let's confirm the form is being sent to the server with the right data. We can spy or stub the form submission in several places, as shown in the diagram below: at the module boundary, at the window.fetch level, or at the network call level.

API and Ajax and Network APIs we can observe from the test

Let's see an example of every stub in action.

💡 I personally recommend stubbing Ajax requests at the network level. Stubbing at the module API level is prone to changes when the components change. Stubbing at the window.fetch also seems like stubbing an implementation detail, while network control gives you a superpower.

Via module API

Well, the Form component is using api.js module to actually post the form. Maybe our test can use the module's method to assert the submission? Yes you can! Any ES6 export can be spied or stubbed using cypress-react-unit-test.

Here is the test that stubs the module import from api.js module and confirms it was called with expected arguments

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react'
import Form from './Form'
import { mount } from 'cypress-react-unit-test'
import * as api from './api'

describe('Form', () => {
it('stubs the api method', () => {
mount(<Form />)
cy.stub(api, 'postProducts').resolves({ok: true}).as('postProducts')
cy.contains('label', 'Body:').find('input').type('Sample{enter}')
cy.get('@postProducts').should('have.been.calledOnce')
.its('args.0').should('deep.equal', ['Sample'])
})
})

Testing the API method call via stubbed export

Via stubbing window.fetch

We have just stubbed the internal module import from Form component to the api module it is using. The internal APIs of the code we write is always fluid and hard to predict - since we and other teams control them. Plus we want to test the api.js code too! Thus it might be better to intercept the window.fetch call the api.js module is making - because the fetch API is standard and will not change.

Let's stub window.fetch method instead. This is the method api.js is calling after all to send the Ajax request to the server. If we can observe it, then we can refactor Form and api modules and as long as the test is passing we will be good.

1
2
3
4
5
6
7
8
9
10
11
12
13
it('stubs the window.fetch method', () => {
mount(<Form />)
cy.stub(window, 'fetch').resolves({ok: true}).as('fetch')
cy.contains('label', 'Body:').find('input').type('Sample{enter}')
cy.get('@fetch').should('have.been.calledOnce')
.its('args.0').should('deep.equal', ['https://reqres.in/api/products', {
body: '{"title":"Sample"}',
method: "POST",
headers: {
'Content-Type': "application/json",
}
}])
})

The test is passing - the window.fetch is happening as expected

Testing that fetch happens

Via network stub

Finally, the fetch or XMLHttpRequest methods used to send the form to the backend are implementation details. If the call to the backend is happening, we should not tie the test to the implementation details. Let's instead of stubbing window.fetch method stub the underlying network call using cy.route2 method.

1
2
3
4
5
6
7
it('observes the network call', () => {
mount(<Form />)
// stub any POST request to the given URL and respond with an empty object
cy.route2('POST', 'https://reqres.in/api/products', {}).as('submit')
cy.contains('label', 'Body:').find('input').type('Sample{enter}')
cy.wait('@submit')
})

The test intercepts the network call outside the browser, thus we can fully confirm te Ajax call happens and we do not care if it was made using fetch protocol or XMLHttpRequest method.

Testing that Ajax happens

Testing Redux component

Now let's add a bare-bones Redux data store to our application (and to our components), and see how our tests can work with it. The application's index.js file sets everything up, surrounding the top-level component with a Redux store.

1
2
3
4
$ yarn add -D react-redux redux
info Direct dependencies
├─ [email protected]
└─ [email protected]
src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import Form from './Form';
import Counter from './Count';
import { Provider } from 'react-redux';
import store from './store';

ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<Counter />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);

The src/store.js creates the store

src/store.js
1
2
3
import { createStore } from "redux";
import rootReducer from "./reducers";
export default createStore(rootReducer)

The reducers file increments and decrements the counter in response to actions

src/reducers.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const initialState = {count: 0}
export default function reducers(state = initialState, action) {
switch (action.type) {
case 'INCREMENT':
return {
count: state.count + 1,
}
case 'DECREMENT':
return {
count: state.count - 1,
}
case 'RESET':
return {
count: 0
}
default:
return state
}
}

Finally, the Count component renders the current count and dispatches increment and decrement events.

src/Count.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
import React from 'react';
import {useSelector, useDispatch} from 'react-redux';

function Count() {
const state = useSelector(state => state.count);
const dispatch = useDispatch();
const decrement = () => {
dispatch({type: 'DECREMENT'})
}
const increment = () => {
dispatch({type: 'INCREMENT'})
}
const reset = () => {
dispatch({type: 'RESET'})
}
return (
<div>
<h2>Current Count: {state}</h2>
<div>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
</div>
)
}
export default Count;

Let's set up our test src/Count.spec.js to just mount the component. Since the component needs the Redux store, we surround the component with the store's provider.

src/Count.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import Count from './Count'
import { Provider } from 'react-redux'
import store from './store'

describe('Count', () => {
it('redux component functionality', () => {
// Count component runs as a full application
// during the test. Thus we need to provide a store
// just like a real application would do
mount(
<Provider store={store}>
<Count />
</Provider>
)
})
})

We can see the mounted component and even can exercise it ourselves - it is a fully running web application, after all.

Mounted Count component is working when using manually

Let's repeat the above steps using cy commands.

src/Count.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('redux component functionality', () => {
// Count component runs as a full application
// during the test. Thus we need to provide a store
// just like a real application would do
mount(
<Provider store={store}>
<Count />
</Provider>
)
cy.contains('Current Count: 0')
cy.contains('+').click()
cy.contains('Current Count: 1')
cy.contains('-').click().click()
cy.contains('Current Count: -1')
cy.contains('Reset').click()
cy.contains('Current Count: 0')
})

The test exercises the component, just like a real user does

Full Count spec

One benefit the component tests have over end-to-end tests is easy access to the objects passed as props. For example, when we mounted the Count component, we have passed the Redux store object. By controlling the component through its DOM, we have changed the internal store. From the test, we can assert the changes by directly inspecting the store object

1
2
3
4
5
import store from './store'
...
cy.contains('-').click().click()
cy.contains('Current Count: -1')
cy.wrap(store).invoke('getState').should('deep.equal', {count: -1})

By using Cypress time-traveling debugger, we can click on the cy.wrap command and see the component's DOM state at that moment, and see that our assertion has passed. The Redux store indeed contains count: -1.

Testing the Redux store

Testing a custom hook

Let's say we want to write a custom React hook, like this one

src/CustomHook.js
1
2
3
4
5
6
7
import React from 'react';
function useMultiplier({ initialNum = 1 } = {}) {
const [num, setNum] = React.useState(initialNum);
const multiply = factor => setNum( initialNum * factor );
return { num, multiply }
}
export {useMultiplier};

We can follow the hooks example, and test this hook using mountHook function from cypress-react-unit-test library.

src/CustomHook.spec.js
1
2
3
4
5
6
7
8
9
10
import { mountHook } from 'cypress-react-unit-test'
import { useMultiplier } from './CustomHook'

describe('useMultiplier', () => {
it('works by itself', () => {
mountHook(() => useMultiplier({ initialNum: 1 })).then(hook => {
console.log(hook)
})
})
})

The above code mounts the hook, and exposes the result.

Mounted hook exposes current value and the update method

Now let's change the hook's data by calling the exposed method hook.current.multiply

src/CustomHook.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { mountHook } from 'cypress-react-unit-test'
import { useMultiplier } from './CustomHook'

describe('useMultiplier', () => {
it('works by itself', () => {
mountHook(() => useMultiplier({ initialNum: 1 })).then(hook => {
expect(hook.current.num).to.equal(1)
hook.current.multiply(5)
expect(hook.current.num).to.equal(5)
hook.current.multiply(7)
expect(hook.current.num).to.equal(7)
})
})
})

The test passes

Exercising the custom  hook

The above approach works - but it acts more like an isolated unit test. We would rather test the hook inside a test React component. We can create such component on the fly inside the test.

src/Counter.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { useMultiplier } from './CustomHook'

it('works inside a component', () => {
let result = null;
function Test() {
result = useMultiplier({initialNum: 1});
return <div>Result {result.num}</div>;
}
mount(<Test />)
cy.contains('Result 1')
.then(() => {
result.multiply(4)
})

cy.contains('Result 4')
.then(() => {
expect(result.num).to.equal(4)
})
})

Testing custom hook using a test component

The component test again gives us an advantage - it allows us to access the hook's current state whenever it is updated, or to trigger an update. The test drives the component using the hook and can verify that the hook works by checking the DOM, or by checking the hook's internal property.

Learn more

Find lots of examples in cypress-react-unit-test and read multiple blog posts. Testing React components inside a real browser should be efficient and even fun.