- Background
- The setup
- Hello World
- ExpandCollapse
- Login form
- Network requests
- Conclusion
- Relate blog posts
Note: you can find the source code for this blog post with both Jest + RTL and Cypress + CTL specs in the repository rtl-article-2019.
Background
This blog post is based on the excellent series of posts from Artem Sapegin about testing front-end code. In particular, this blog post follows the examples from Modern React testing, part 3: Jest and React Testing Library blog post. As the title says, we will be testing React components, only instead of using Jest test runner plus @testing-library/react (known as RTL) I will be using Cypress + cypress-react-unit-test to run the tests. Fear not - the change will be minimal, because we also will be using @testing-library/cypress (also called CTL). Thus our component tests will look exactly (well, almost, they will in fact be simpler) like before.
The setup
We install the testing tools using NPM commands
1 | npm i -D cypress cypress-react-unit-test @testing-library/cypress |
The cypress.json
file has all Cypress global configuration settings, where I enable component testing and fetch polyfill experimental features.
1 | { |
The project uses react-scripts to run the application, thus we should point Cypress to bundle specs using the same settings as the application.
1 | module.exports = (on, config) => { |
Finally, we need to load the @testing-library/cypress
from the Cypress support file - this will set up the querying commands like cy.findByText
we will use in our tests.
1 | // https://github.com/bahmutov/cypress-react-unit-test#install |
I like having component and unit tests close to the source files, thus our tests will live in the src/components/__tests__
folder. There are Jest + RTL spec files there already; they use suffix .spec.js
, so I will give Cypress component spec files .cy-spec.js
suffix.
1 | src/components/ |
To limit Jest to only run __tests_/*.spec.js
files we can add the following to the package.json
file
1 | { |
There are several RemotePizza_*.spec.js
Jest files showing the different ways of dealing with the network calls. We will look at them later; dealing with method stubbing and network control is one of the nicer Cypress features. For now, let's start with "Hello World" example.
Hello World
We can start by inspecting a Jest + RTL spec file Hello.spec.js
- it does not even have a corresponding component source file, since it renders an inline JSX.
1 | import React from 'react'; |
The same test but using cypress-react-unit-test
replaces render
with mount
and synchronous calls like render
, getByText
with implicitly asynchronous commands.
1 | import React from 'react'; |
Note: In @testing-library/cypress v6 synchronous commands like getBy*
were removed in favor of asynchronous commands like findBy*
Open Cypress and click the spec filename
1 | npx cypress open |
The component is mounted and shows up on the right side of the browser. The commands from the test mount
and findByText
are shown in the Command Log on the left.
The Command Log is magical. This is where you can time travel and get more information about every command by clicking on it. Open the DevTools to see more information - because this is a real browser and real DOM elements.
You can see how cy.findByText
command searched the document to find the element. Because this is a real DOM node, it is highlighted on the right automatically as you hover over its reference.
Tip: you see the <Unknown ...>
in the Command Log for the mount
command - this is because the component has no JSX name. In majority of cases you would see the JSX name because there would be a function name or component class name:
1 | it('hello world component', () => { |
Tip 2: Cypress has built-in .contains command that searches by text or regular expression. Thus the above test could be written as
1 | it('hello world component', () => { |
Just like cy.findTextBy
, if the text does not appear in the DOM within 4 seconds, the cy.contains
command fails.
1 | it('fails if text is not found', () => { |
We can shorten the retry time globally or per command if we know that our application updates faster.
1 | it('fails if text is not found', () => { |
The test still fails searching for the text that is not there, but now it fails after retrying for only 200ms. Cypress test runner has the built-in retry-ability which allows the tests to be less flaky, and allows us to write tests where every command is asynchronous, even if the test code looks "simple". This is well shown in the next spec file testing the ExpandCollapse.js
component.
ExpandCollapse
The original Jest component test is below. It renders the component, clicks on the button and checks if the children elements are shown. Then it clicks on the button again and asserts the children elements are hidden.
1 | import React from 'react'; |
The test is synchronous - every action like firing the click event MUST be handled by the component synchronously in order for the test to work. For example, the component has the following logic to re-render on click:
1 | <button |
Later we will break the above test by introducing setTimeout
into onClick
handler, breaking the test. But first let's see the equivalent Cypress spec file.
1 | import React from 'react'; |
The test looks almost exactly the same, and very similar to the HelloWorld
spec.
- we use
cy.findByText
andcy.findByRole
commands to find the elements to test - we can add an assertion to "flip" the meaning of the command. For example, after clicking on the "Collapse" button, the element with text "Hello world" should not longer exist in the DOM
1 | cy.findByRole('button', { name: /collapse/i }).click(); |
The Cypress commands are declarative and asynchronous. The component might have the internal logic to only re-render after 1000ms when clicking the "Collapse" button - let's change the component to this:
1 | <button |
Instead of immediately updating the DOM, the component now "waits" 1 second. Our Jest test fails.
In fact, the Jest test as written fails even if the component has a delay of just zero milliseconds: setTimeout(..., 0)
. The Cypress tests meanwhile are happy as a clam. We can change the component update from synchronous to asynchronous, we can change the delay - it is fine, the Test Runner will retry its commands until the timeout or the DOM updates
The component tests in Cypress are meant to interact with the component using its public API: the props and the DOM without assuming anything about its internal code.
Login form
Our next example component renders a form with submit button. When the user fills the input fields and clicks the Submit button, the function onSubmit
passed from the parent component as a prop is called.
1 | export default function Login({ onSubmit }) { |
The Cypress test is below; we create a stub function using the built-in cy.stub command. The stubs are reset automatically before each test, thus we don't need to worry about resetting them ourselves.
1 | import React from 'react'; |
The test passes - our component does call the passed onSubmit
prop with the password and the username object.
Tip: Cypress takes a video of the entire test run and screenshot images on test failures by default, you probably want to keep the sensitive data like the password out of the Command Log. Read the blog post Keep passwords secret in E2E tests to learn how.
BDD assertions
The assertion block using .then
that checks the function stub gives away the asynchronous nature of the test.
1 | cy.findByRole('button', { name: /log in/i }) |
We need this .then
block to run the synchronous code expect(onSubmit)...
assertions after the click()
command. We can avoid using .then
by giving the stub an alias using .as command. Later we can retrieve the stub using this alias and use BDD assertions. The relevant changes in the test are below:
1 | // create a stub and save under an alias |
The test passes and you can see the alias in the Command Log when the stub is called.
Cypress stubs and spies are built on top of the powerful Sinon.js library, and Cypress includes Sinon-Chai assertion matchers. These assertions help you confirm the methods are called precisely as intended.
Selector Playground
When the test typed the username and the password, we used @testing-library/cypress
command findByLabelText
.
1 | cy.findByLabelText(/username/i).type(username); |
The Login
component does have good testing attributes on those input fields though:
1 | <input name="username" value={username} |
Thus we can let Cypress pick the selector for us using Selector Playground. It will inspect every DOM element we hover over to suggest the most precise Cypress built-in command to select that element. In our case, the Selector Playground suggest using the data-testid
attribute.
The copied command cy.get('[data-testid=loginForm-username]')
can be pasted directly into the spec file, and then we need to add .type()
command.
1 | cy.get('[data-testid=loginForm-username]').type(username) |
Picking selector commands using the Selector Playground is a nifty little tool for quickly writing tests.
Network requests
Pizza toppings
The final component we are going to test renders the list of pizza toppings. If the list of topics is passed via a prop, the test is simple.
1 | export default function Pizza({ ingredients }) { |
1 | import React from 'react'; |
RemotePizza with Ajax call
But what if the component fetches the list of toppings from a remote REST API? Let's look at the RemotePizza
component
1 | import React from 'react'; |
Let's write component tests confirming the fetched pizza toppings are displayed correctly. We can take several approaches to this.
1. Dependency injection
The component allows passing the fetcher function via a prop. Thus we can pass a stub like before
1 | import React from 'react'; |
2. Dependency injection with delay
How does our component behave when the remote server responds after a delay? Let's pass a stub that resolves after one second delay using the Bluebird Promise library bundled with Cypress.
1 | it('stubs via prop (di with delay)', () => { |
The test passes just fine - but it shows the need for some kind of loading indicator. Our users would not know what is happening while the toppings are being fetched.
3. Stubbing the default property
We can "reach" into the component and replace the default fetch method from our test using the defaultProps
object exposed by the component.
1 | RemotePizza.defaultProps = { |
Our test stubs the method RemotePizza.defaultProps
1 | it('mocks method via defaultProps', () => { |
4. Mocking named imports
Our RemotePizza
component imports the default fetcher from the services module.
1 | import { fetchIngredients as defaultFetchIngredients } from '../services'; |
The Jest test mocked the exported function fetchIngredients
.
1 | import { fetchIngredients } from '../../services'; |
In Cypress we can mock the fetchIngredients
import using the included Sinon cy.stub just like before.
1 | // prepare for import mocking |
To mock a named ES6 module import, we import the entire module using wildcard syntax, which gives us an object. Then we stub the method of that object, just like we did when stubbing method fetchIngredients
of the object RemotePizza.defaultProps
.
Note that all Cypress spies and stubs created during the test are reset automatically, thus you do not have to reset them manually.
5. Stubbing network
The above tactics for stubbing the component's method all reached deep into its implementation. With Cypress' built-in network control we can avoid testing the component's internals and stub the outgoing Ajax request instead.
1 | it('download ingredients from internets (network mock)', () => { |
The test passes and we can see the Ajax request information in the ROUTES table of the Command Log.
We can also add a delay to the network response to simulate a slow server response:
1 | cy.route({ |
Using cy.route we can spy or stub network requests made by the component with ease.
Conclusion
- If you have existing Jest + React Testing Library tests you can quickly port them to run as Cypress component tests using cypress-react-unit-test + @testing-library/cypress. The commands are identical.
- Cypress Test Runner offers advantages over running tests in the terminal
- full browser with DevTools: Electron, Chrome, Firefox, Edge instead of its emulation
- Command Log with time-traveling debugger
- Selector Playground
- screenshots on failure
- videos of the test runs on the CI server
- Cypress test syntax is declarative, and every command runs asynchronously - you can change the implementation of your components and the tests still work correctly using the built-in automatic retries
- You can control the behavior of the component by passing properties when mounting it, stubbing exposed method, mocking module imports and stubbing network calls
You can fine the source code and the spec files described in this blog post in repo rtl-article-2019.
Relate blog posts
- My Vision for Component Tests in Cypress
- Unit Testing React components with Cypress
- Test React Component with cypress-react-unit-test Example
- Tic-Tac-Toe Component Tests
- Using .env and .env.test from React component tests
- Visual testing for React components using open source tools
- 12 Recipes for testing React applications using cypress-react-unit-test (compare to 12 Recipes for testing React applications using Testing Library)
- Cypress Unit Testing React Components With TypeScript