Test The Interface Not The Implementation

Moving from Jest + RTL to Cypress + @testing-library/cypress for testing React components

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
2
3
4
$ npm i -D cypress cypress-react-unit-test @testing-library/cypress
+ @testing-library/[email protected]
+ [email protected]
+ [email protected]

The cypress.json file has all Cypress global configuration settings, where I enable component testing and fetch polyfill experimental features.

cypress.json
1
2
3
4
5
6
{
"experimentalComponentTesting": true,
"experimentalFetchPolyfill": true,
"testFiles": "**/*cy-spec.js",
"componentFolder": "src"
}

The project uses react-scripts to run the application, thus we should point Cypress to bundle specs using the same settings as the application.

cypress/plugins/index.js
1
2
3
4
module.exports = (on, config) => {
require('cypress-react-unit-test/plugins/react-scripts')(on, config);
return 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.

cypress/support/index.js
1
2
3
4
// https://github.com/bahmutov/cypress-react-unit-test#install
require('cypress-react-unit-test/support');
// https://testing-library.com/docs/cypress-testing-library/intro
require('@testing-library/cypress/add-commands');

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
src/components/
__tests__/
# Jest + RTL test files
ExpandCollapse.spec.js
Hello.spec.js
Login.spec.js
Pizza.spec.js
RemotePizza_*.spec.js
# Cypress + CTL test files
ExpandCollapse.cy-spec.js
Hello.cy-spec.js
Login.cy-spec.js
Pizza.cy-spec.js
RemotePizza.cy-spec.js

# component source files
ExpandCollapse.js
Login.js
Pizza.js
RemotePizza.js

To limit Jest to only run __tests_/*.spec.js files we can add the following to the package.json file

package.json
1
2
3
4
5
6
7
{
"jest": {
"testMatch": [
"**/__tests__/**/*.spec.js"
]
}
}

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.

src/components/__tests__/Hello.spec.js
1
2
3
4
5
6
7
import React from 'react';
import { render } from '@testing-library/react';

test('hello world', () => {
const { getByText } = render(<p>Hello Jest!</p>);
expect(getByText('Hello Jest!')).toBeTruthy();
});

The same test but using cypress-react-unit-test replaces render with mount and synchronous calls like render, getByText with implicitly asynchronous commands.

src/components/__tests__/Hello.cy-spec.js
1
2
3
4
5
6
7
import React from 'react';
import { mount } from 'cypress-react-unit-test';

it('hello world', () => {
mount(<p>Hello Jest!</p>);
cy.findByText('Hello Jest!');
});

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
2
3
$ npx cypress open
# or
$ yarn cypress open

Click on the spec filename

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.

Hello world test

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.

Inspecting the results of command cy.findByText

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
2
3
4
5
it('hello world component', () => {
const HelloWorld = () => <p>Hello Jest!</p>;
mount(<HelloWorld />);
cy.findByText('Hello Jest!');
});

Component name displayed in the Command Log

Tip 2: Cypress has built-in .contains command that searches by text or regular expression. Thus the above test could be written as

1
2
3
4
5
it('hello world component', () => {
const HelloWorld = () => <p>Hello Jest!</p>;
mount(<HelloWorld />);
cy.contains('Hello Jest!');
});

Just like cy.findTextBy, if the text does not appear in the DOM within 4 seconds, the cy.contains command fails.

1
2
3
4
5
it('fails if text is not found', () => {
const HelloWorld = () => <p>Hello Jest!</p>;
mount(<HelloWorld />);
cy.contains('Hello Mocha!');
});

Command fails after retrying for four seconds

We can shorten the retry time globally or per command if we know that our application updates faster.

1
2
3
4
5
it('fails if text is not found', () => {
const HelloWorld = () => <p>Hello Jest!</p>;
mount(<HelloWorld />);
cy.contains('Hello Mocha!', {timeout: 200});
});

Command fails after retrying for 200 milliseconds

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.

src/components/__tests__/ExpandCollapse.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ExpandCollapse from '../ExpandCollapse';

test('button expands and collapses the content', () => {
const children = 'Hello world';
const { getByRole, queryByText } = render(
<ExpandCollapse excerpt="Information about dogs">{children}</ExpandCollapse>
);

expect(queryByText(children)).not.toBeTruthy();

fireEvent.click(getByRole('button', { name: /expand/i }));

expect(queryByText(children)).toBeTruthy();

fireEvent.click(getByRole('button', { name: /collapse/i }));

expect(queryByText(children)).not.toBeTruthy();
});

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
2
3
4
<button
aria-expanded={isExpanded ? 'true' : 'false'}
onClick={() => setExpanded(!isExpanded)}
>

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.

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

it('button expands and collapses the content', () => {
const children = 'Hello world';
mount(
<ExpandCollapse excerpt="Information about dogs">{children}</ExpandCollapse>
);

cy.findByText(children).should('not.exist');
cy.findByRole('button', { name: /expand/i }).click();
cy.findByText(children); // should exist assertion is built-in
cy.findByRole('button', { name: /collapse/i }).click();
cy.findByText(children).should('not.exist');
});

The test looks almost exactly the same, and very similar to the HelloWorld spec.

  • we use cy.findByText and cy.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
2
cy.findByRole('button', { name: /collapse/i }).click();
cy.findByText(children).should('not.exist');

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
2
3
4
<button
aria-expanded={isExpanded ? 'true' : 'false'}
onClick={() => setTimeout(() => setExpanded(!isExpanded), 1000)}
>

Instead of immediately updating the DOM, the component now "waits" 1 second. Our Jest test fails.

Jest test fails if the component updates asynchronously

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

Modifying the component's delay while the test re-runs

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.

src/components/Login.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
export default function Login({ onSubmit }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const handleSubmit = event => {
event.preventDefault();
onSubmit({ username, password });
};
return (
<form onSubmit={handleSubmit} data-testid="loginForm">
<h3>Login</h3>
<label>
Username
<input
name="username"
value={username}
onChange={event => setUsername(event.target.value)}
data-testid="loginForm-username"
/>
</label>
<label>
Password
<input
name="password"
type="password"
value={password}
onChange={event => setPassword(event.target.value)}
data-testid="loginForm-password"
/>
</label>
<button type="submit">Log in</button>
</form>
);
}

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.

src/components/__tests__/Login.cy-spec.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
import React from 'react';
import Login from '../Login';
import { mount } from 'cypress-react-unit-test';

describe('form', () => {
it('submits username and password using testing-library', () => {
const username = 'me';
const password = 'please';
const onSubmit = cy.stub();
mount(<Login onSubmit={onSubmit} />);

cy.findByLabelText(/username/i).type(username);
cy.findByLabelText(/password/i).type(password);
cy.findByRole('button', { name: /log in/i })
.click()
.then(() => {
// use explicit assertion using sinon-chai matchers
/* eslint-disable-next-line no-unused-expressions */
expect(onSubmit).to.be.calledOnce;
expect(onSubmit).to.be.calledWith({
username,
password,
});
});
});
});

Login test

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
2
3
4
5
6
7
8
9
10
11
cy.findByRole('button', { name: /log in/i })
.click()
.then(() => {
// use explicit assertion using sinon-chai matchers
/* eslint-disable-next-line no-unused-expressions */
expect(onSubmit).to.be.calledOnce;
expect(onSubmit).to.be.calledWith({
username,
password,
});
});

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
2
3
4
5
6
7
8
9
10
11
// create a stub and save under an alias
mount(<Login onSubmit={cy.stub().as('submit')} />);
...
cy.findByRole('button', {
name: /log in/i,
}).click();

cy.get('@submit')
.should('be.calledOnce')
// .and is an alias to .should
.and('calledWith', { username, password });

The test passes and you can see the alias in the Command Log when the stub is called.

Stub alias shows when the function was 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
2
cy.findByLabelText(/username/i).type(username);
cy.findByLabelText(/password/i).type(password);

The Login component does have good testing attributes on those input fields though:

1
2
3
4
5
6
7
8
<input name="username" value={username}
onChange={event => setUsername(event.target.value)}
data-testid="loginForm-username"
/>
<input name="password" type="password" value={password}
onChange={event => setPassword(event.target.value)}
data-testid="loginForm-password"
/>

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.

Selector Playground suggesting command for selecting the username input field

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.

src/components/Pizza.js
1
2
3
4
5
6
7
8
9
10
11
12
export default function Pizza({ ingredients }) {
return (
<>
<h3>Pizza</h3>
<ul>
{ingredients.map(ingredient => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
</>
);
}
src/components/__tests__/Pizza.cy-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import { mount } from 'cypress-react-unit-test';
import Pizza from '../Pizza';

it('contains all ingredients', () => {
const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];
// component Pizza shows the passed list of toppings
mount(<Pizza ingredients={ingredients} />);

for (const ingredient of ingredients) {
cy.findByText(ingredient);
}
});

Verifying every topping is rendered

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

src/components/RemotePizza.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
import React from 'react';
import { fetchIngredients as defaultFetchIngredients } from '../services';

export default function RemotePizza({ fetchIngredients }) {
const [ingredients, setIngredients] = React.useState([]);

const handleCook = () => {
fetchIngredients().then((response) => {
setIngredients(response.args.ingredients);
});
};

return (
<>
<h3>Pizza</h3>
<button onClick={handleCook}>Cook</button>
{ingredients.length > 0 && (
<ul>
{ingredients.map((ingredient) => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
)}
</>
);
}

RemotePizza.defaultProps = {
fetchIngredients: (url) => defaultFetchIngredients(url),
};

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

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

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

describe('RemotePizza', () => {
it('stubs via prop (di)', () => {
const fetchIngredients = cy.stub().resolves({ args: { ingredients } });
mount(<RemotePizza fetchIngredients={fetchIngredients} />);
cy.contains('button', /cook/i).click();

for (const ingredient of ingredients) {
cy.contains(ingredient);
}
});
});

Passing fetch stub as a prop

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it('stubs via prop (di with delay)', () => {
const fetchIngredients = cy
.stub()
// resolves after 1 second delay
.resolves(
Cypress.Promise.resolve({ args: { ingredients } }).delay(1000)
);

mount(<RemotePizza fetchIngredients={fetchIngredients} />);
cy.contains('button', /cook/i).click();

for (const ingredient of ingredients) {
cy.contains(ingredient);
}
});

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.

The component shows nothing 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.

src/components/RemotePizza.js
1
2
3
RemotePizza.defaultProps = {
fetchIngredients: (url) => defaultFetchIngredients(url),
};

Our test stubs the method RemotePizza.defaultProps

src/components/__tests__/RemotePizza.cy-spec.js
1
2
3
4
5
6
7
8
9
10
11
it('mocks method via defaultProps', () => {
cy.stub(RemotePizza.defaultProps, 'fetchIngredients').resolves({
args: { ingredients },
});
mount(<RemotePizza />);
cy.contains('button', /cook/i).click();

for (const ingredient of ingredients) {
cy.contains(ingredient);
}
});

Stubbing a method in the default props object

4. Mocking named imports

Our RemotePizza component imports the default fetcher from the services module.

src/components/RemotePizza.js
1
import { fetchIngredients as defaultFetchIngredients } from '../services';

The Jest test mocked the exported function fetchIngredients.

src/components/__tests__/RemotePizza_jestmock.spec.js
1
2
3
4
5
6
7
8
9
10
import { fetchIngredients } from '../../services';

jest.mock('../../services');

afterEach(() => {
fetchIngredients.mockReset();
});

// during test
fetchIngredients.mockResolvedValue({ args: { ingredients } });

In Cypress we can mock the fetchIngredients import using the included Sinon cy.stub just like before.

src/components/__tests__/RemotePizza.cy-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
// prepare for import mocking
import * as services from '../../services';

it('mocks named import from services', () => {
cy.stub(services, 'fetchIngredients').resolves({ args: { ingredients } });
mount(<RemotePizza />);
cy.contains('button', /cook/i).click();

for (const ingredient of ingredients) {
cy.contains(ingredient);
}
});

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.

Stubbing named ES6 module import

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.

src/components/__tests__/RemotePizza.cy-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('download ingredients from internets (network mock)', () => {
cy.server();
// using https://on.cypress.io/route
// to stub every request to particular URL a response
cy.route('https://httpbin.org/anything*', { args: { ingredients } }).as(
'pizza'
);

mount(<RemotePizza />);
cy.contains('button', /cook/i).click();
cy.wait('@pizza'); // make sure the network stub was used

for (const ingredient of ingredients) {
cy.contains(ingredient);
}
});

The test passes and we can see the Ajax request information in the ROUTES table of the Command Log.

Stubbing Ajax network request

We can also add a delay to the network response to simulate a slow server response:

1
2
3
4
5
cy.route({
url: 'https://httpbin.org/anything*',
response: { args: { ingredients } },
delay: 1000,
}).as('pizza');

Stubbing Ajax network request with delay of 1 second

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