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 | yarn add -D react react-dom react-scripts |
After we have installed half of Internet, let's write a component that submits a form.
1 | import React from 'react'; |
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 | yarn add -D cypress cypress-react-unit-test |
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 |
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.
Testing a React component
Let's write our first test.
1 | import React from 'react' |
Start Cypress and click the spec
1 | yarn cypress open |
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:
1 | import React from 'react' |
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
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
1 | import React from 'react'; |
It uses api.js
to submit the form
1 | export async function postProducts(title) { |
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
1 | import React from 'react' |
How should we confirm it?
Via DOM
We can confirm the form has been submitted using the DOM change.
1 | describe('Form', () => { |
The test passes:
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.
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 | import React from 'react' |
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 | it('stubs the window.fetch method', () => { |
The test is passing - the window.fetch
is happening as expected
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 | it('observes the network call', () => { |
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 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 | yarn add -D react-redux redux |
1 | import React from 'react'; |
The src/store.js
creates the store
1 | import { createStore } from "redux"; |
The reducers file increments and decrements the counter in response to actions
1 | const initialState = {count: 0} |
Finally, the Count
component renders the current count and dispatches increment and decrement events.
1 | import React from 'react'; |
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.
1 | import React from 'react' |
We can see the mounted component and even can exercise it ourselves - it is a fully running web application, after all.
Let's repeat the above steps using cy
commands.
1 | it('redux component functionality', () => { |
The test exercises the component, just like a real user does
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 | import store from './store' |
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 a custom hook
Let's say we want to write a custom React hook, like this one
1 | import React from 'react'; |
We can follow the hooks example, and test this hook using mountHook
function from cypress-react-unit-test
library.
1 | import { mountHook } from 'cypress-react-unit-test' |
The above code mounts the hook, and exposes the result.
Now let's change the hook's data by calling the exposed method hook.current.multiply
1 | import { mountHook } from 'cypress-react-unit-test' |
The test passes
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.
1 | import React from 'react' |
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.