preface
This blog provides a good example of how to migrate from Jest+React Testing Library to Cypress + cypress-react-unit-test. Read this article to understand the React test primer for both libraries.
start
Create a standard React app using CRP(create-react-app). By default, an app created using CRP already has the Jest+React Testing Library built in.
."dependencies": {
"@testing-library/jest-dom": "^ 5.11.4." "."@testing-library/react": "^ 11.1.0"."@testing-library/user-event": "^ 12.1.10"."react": "^ 17.0.1"."react-dom": "^ 17.0.1"."react-scripts": "4.0.1"."web-vitals": "^ 0.2.4." "},...Copy the code
Now install our main cypress and Cypress React unit test plugin:
npm install --save-dev cypress cypress-react-unit-test @testing-library/cypress
Copy the code
To initialize cypress, run the following command to generate the cypress folder and cypress.json:
npx cypress open
Copy the code
Json is the global configuration of Cypress. Here we enable Component Testing and Fetch Polyfill experiments:
{
"experimentalComponentTesting": true,
"experimentalFetchPolyfill": true,
"testFiles": "**/*cy-spec.js",
"componentFolder": "src"
}
Copy the code
When testing with Jest, we prefer to test files close to our source files, but in Cypress, all test cases are grouped in the __tests__ folder. Jest uses the.spec.js suffix by default to match testFiles. Here we use testFiles to configure the suffix.cy-spec.js.
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
Copy the code
Since this is a migration article, let’s also configure Jest:
// package.json
{
"jest": {
"testMatch": [
"**/__tests__/**/*.spec.js"]}}Copy the code
Our project is created by create-react-app, and the CRP created project configuration is stored in react-scripts under node_modules. By looking at package.json, you can see that all scripts are actually running commands under react-scripts:
"scripts": {
"start": "react-scripts start"."build": "react-scripts build"."test": "react-scripts test"."eject": "react-scripts eject"
},
Copy the code
Why do we care about this? We need to make Cypress use the same configuration as React-Scripts so that it understands the packaging mechanism:
// cypress/plugins/index.js
modules.exports = (on, config) = > {
require('cypress-react-unit-test/plugins/react-scripts')(on, config);
return config;
}
Copy the code
Finally, we need to load @testing-library/cypress and cypress-react-unit-test into cypress support, which will introduce query commands. For example, cy.findByText(similar to React Testing Library).
// cypress/support/index.js
// 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');
Copy the code
Hello World(mount and element fetch)
We can start with the Jest+RTL example hello.spec.js, which has no corresponding component file because it uses inline JSX:
// src/components/__tests__/Hello.spec.js
import React from 'react';
import { render, screen } from '@testing-library/react';
test('hello world'.() = > {
const { getByText } = render(<p>Hello Jest!</p>);
expect(getByText('Hello Jest! ')).toBeTruthy();
// or
expect(screen.getByText('Hello Jest! ')).toBeTruthy();
})
Copy the code
When using cypress-react-unit-test to write test cases with the same function, we use mount instead of render and findByText instead of getByText:
// src/components/__tests__/Hello.cy-spec.js
import React from 'react';
import { mount } from 'cypress-react-unit-test';
it('hello world'.() = > {
mount(<p>Hello Jest!</p>);
cy.findByText('Hello Jest! ');
})
Copy the code
We then run the Cypress test with the following command:
npx cypress open
// or
yarn cypress open
Copy the code
You can see that the test passed, similar to the following
In the mount Log, you can see <Unknown… >. This is because our component is not named.
In the actual test example, the component we rendered would have a name, either a function name or a class name:
it('hello world component'.() = > {
const HelloWorld = () = > <p>Hello World!</p>;
mount(<HelloWorld>);
// or cy.contains
cy.findByText('Hello World! ');
})
Copy the code
The Cy. contains API provided by Cypress in the E2E test can also be used directly, similar to cy.findByTextBy. If the text does not exist in the DOM (4 seconds), cy.contains will fail. If our application is fast enough, we can set the wait time for each retrieval globally or on a per-command basis.
it('fails if text is not found'.() = > {
const HelloWorld = () = > <p>Hello World!</p>;
mount(<HelloWorld>);
cy.contains('Hello Mocha', {timeout: 200});
})
Copy the code
Real Life Example – Scaling up a folded component test (event handling and asynchronous element review)
Suppose you have an ExpandCollapse component that does what its name says, expanding and collapsing. Expand to show incoming children, shrink to hide incoming children:
const ExpandCollapse = (props) = > {
const { children } = props;
const [isExpanded, setExpanded] = useState(false);
return (
<>
<button data-testid="expandCollapseBtn" onClick={()= >setExpanded(! isExpanded)}> click</button>
{isExpanded ? children : null}
</>
);
}
Copy the code
If we were to write a test case for this component, first click Button to see if children are displayed, and then click Button to see if children are hidden. Here is a sample of the test written by Jest (the original example uses ARIA, the concept of barrierless reading, which is so expensive to understand and remember that I have used the attribute of testid instead) :
// src/components/__tests__/ExpandCollapse.spec.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ExpandCollapse from '.. /ExpandCollapse.js';
it('ExpandCollapse Test'.() = > {
const children = 'Hello World';
render(<ExpandCollapse>{children}</ExpandCollapse>);
expect(screen.queryByText(children)).not.toBeInTheDocument();
// View the DOM for each stage with screen.debug.
screen.debug();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
expect(screen.queryByText(children)).toBeInTheDocument();
screen.debug();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
expect(screen.queryByText(children)).not.toBeInTheDocument();
screen.debug();
});
Copy the code
Let’s look at the equivalent test case, this time written using cypress and cypress-react-unit-test:
import React from 'react';
import { mount } from 'cypress-react-unit-test';
import ExpandCollapse from '.. /ExpandCollapse';
it('ExpandCollapse Test'.() = > {
const children = 'Hello World';
mount(<ExpandCollapse>{children}</ExpandCollapse>);
cy.findByText(children).should('not.exist');
cy.findByTestId('expandCollapseBtn').click();
cy.findByText(children); // Built-in assertion
cy.findByTestId('expandCollapseBtn').click();
cy.findByText(children).should('not.exist');
});
Copy the code
Both of the above test cases are synchronous (assert immediately after click), but in reality, every interaction, such as clicking a Button, is asynchronous, and should be asynchronous after the action is performed. For example, let’s modify the Button click event:
<button data-testid="expandCollapseBtn" onClick={() = > setTimeout(() = >setExpanded(! isExpanded),1000)}>
click
</button>
Copy the code
Using Jest to execute the test case, you can see an error message:
In fact, testing with Jest will report an error even if the test case just changed to 0ms. Using Cypress to execute the Test case, it can be seen that no error is reported. This is because the command of Cypress is asynchronous. Even if we change the update of the component from synchronous to asynchronous, or set delay, the Test Runner of Cypress will still try the command repeatedly until DOM update.
By default, however, Cypress will only retry within 4000ms, and an error will be reported after 4000ms, such as changing the previous example to the following:
<button data-testid="expandCollapseBtn" onClick={() = > setTimeout(() = >setExpanded(! isExpanded),5000)}>
click
</button>
Copy the code
Cypress test case error:
Use Jest+ react-testing-library to examine asynchronous elements
Can we get elements asynchronously with Jest+RTL? Sure, Jest supports asynchronous test cases, and RTL supports asynchronous element review
- The asynchronous judgment element exists: waitFor + getBy/queryBy or findBy
- Asynchronous judgment element does not exist: waitFor + getBy/queryBy or waitForElementToBeRemoved
// src/components/__tests__/ExpandCollapse.spec.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ExpandCollapse from '.. /ExpandCollapse.js';
it('ExpandCollapse Test'.async() = > {const children = 'Hello World';
render(<ExpandCollapse>{children}</ExpandCollapse>);
expect(screen.queryByText(children)).not.toBeInTheDocument();
// View the DOM for each stage with screen.debug.
screen.debug();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
expect(await screen.findByText(children)).toBeInTheDocument();
/* await waitFor(() => { expect(screen.getByText(children)).toBeInTheDocument(); }) * /
screen.debug();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
await waitForElementToBeRemoved(() = > queryByText(children));
/* await waitFor(() => { expect(queryByText(children)).not.toBeInTheDocument(); }) * /
screen.debug();
});
Copy the code
If you test with the 5000ms example above, you will still get an error because waitFor and findBy default timeout is 1000ms, and Jest’s default timeout is 5000ms. The timeout for waitFor can be passed in as the second argument, and the timeout for findBy can be passed in as the third argument:
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ExpandCollapse from '.. /ExpandCollapse.js';
// Set jest timeout
jest.setTimeout(10000);
it('ExpandCollapse Test'.async() = > {const children = 'Hello World';
render(<ExpandCollapse>{children}</ExpandCollapse>);
expect(screen.queryByText(children)).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
// await waitFor(() => {
// expect(screen.queryByText(children)).toBeInTheDocument();
// }, { timeout: 5000 });
expect(await screen.findByText(children, {}, { timeout: 5000 })).toBeInTheDocument();
fireEvent.click(screen.getByTestId('expandCollapseBtn'));
await waitFor(() = > {
expect(screen.queryByText(children)).not.toBeInTheDocument();
}, { timeout: 5000 });
});
Copy the code
Review with Cypress asynchronous elements
Similarly, Cypress also provides an asynchronous review mechanism, which is also implemented by passing timeout:
import React from 'react';
import { mount } from 'cypress-react-unit-test';
import ExpandCollapse from '.. /ExpandCollapse';
it('ExpandCollapse Test'.() = > {
const children = 'Hello World';
mount(<ExpandCollapse>{children}</ExpandCollapse>);
cy.findByText(children).should('not.exist');
cy.findByTestId('expandCollapseBtn').click();
cy.findByText(children, {
timeout: 5000
}); // Built-in assertion
cy.findByTestId('expandCollapseBtn').click();
cy.findByText(children, {
timeout: 5000
}).should('not.exist');
});
Copy the code
Login Form(callback processing)
The following example is a form with a submit button. When the user fills the input field and clicks the submit button, the onSubmit method passed in by the parent component will be called:
// src/components/Login.js
export default function Login ({ onSubmit }) {
const [username, setUsername] = React.useState(' ');
const [paswword, setPassword] = React.useState(' ');
const handleSubmit = event= > {
event.preventDefault();
onSubmit({ username, password });
};
return (
<form onSubmit={handleSubmit} data-test-id="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" data-testid="loginForm-submit">Log in</button>
</form>)}Copy the code
For the example above, we need to pass in the onSubmit function to the component under test
- Whether the function is called, and how often
- Whether the parameters passed in when the call is called are as expected
Use Jest to write event callback handling
Jest. Fn creates a function, passes this function to the component under test, and tests it with Jest’s assertion function:
// src/components/__tests__/Login.spec.js
import React from 'react';
import Login from '.. /Login';
import { screen, render, fireEvent } from '@testing-library/react';
describe('form'.async () => {
it('submits username and password usting testing-library'.() = > {
const username = 'me';
const password = 'please';
const onSubmit = Jest.fn();
render(<Login onSubmit={onSubmit} />);
fireEvent.onChange(screen.queryByLabelText(/username/i), {
target: {
value: username
}
});
fireEvent.onChange(screen.queryByLabelText(/username/i), {
target: {
value: username
}
});
await fireEvent.submit(screen.queryTestId('loginForm-submit'));
expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ username, password }); })})Copy the code
Use Cypress to write event callback handling
It is possible to create a function using cy.stub as the parent component’s onSubmit to the component under test. The function created by cy.stub will be reset in each test case, so we do not have to reset it manually.
// src/components/__tests__/Login.cy-spec.js
import React from 'react';
import Login from '.. /Login';
import { mount } from 'cypress-react-unit-test';
describe('form'.() = > {
it('submits username and password usting 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 })
.submit()
.then(() = > {
expect(onSubmit).to.be.calledOnce;
expect(onSubmit).to.be.calledWith({
username,
password,
});
});
});
});
Copy the code
Pizza Toppings (Web request)
The final example is a list component of the Pizza menu.
export default function Pizza({ ingredients }) {
return (
<>
<h3>Pizza</h3>
<ul>
{ingredients.map(ingredient => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
</>)}Copy the code
The test case is simple if the list is passed in from outside through Props.
// src/components/__tests__/Pizza.cy-spec.js
// Write via cypress
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 ofingredients) { cy.findByText(ingredient); }});Copy the code
In real life, however, the component might request the list data:
import React from 'react';
export default function RemotePizza() {
const [ingredients, setIngredients] = React.useState([]);
const handleCook = async() = > {const result = await window.fetch('/api/pizza', {
method: 'GET'.headers: {'Content-Type': 'application/json'}}); result.json().then((data) = >{ setIngredients(data); })};return (
<>
<h3>Pizza</h3>
<button data-testid="fetch-button" onClick={handleCook}>Cook</button>
{ingredients.length > 0 && (
<ul>
{ingredients.map((ingredient) => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
)}
</>
);
}
Copy the code
Cypress makes it easy to intercept requests and set the data to be returned:
//src/components/__tests__/RemotePizza.cy-spec.js
import React from 'react';
import { mount } from 'cypress-react-unit-test';
import RemotePizza from '.. /RemotePizza';
describe('RemotePizza Testing'.() = > {
beforeEach(() = > {
cy.server();
cy.fixture('ingredients')
.as('ingredients')
.then((ingredients) = > {
cy.route({
method: 'GET'.url: '/api/pizza'.response: ingredients
}).as(
'pizza'); })}); it('download ingredients from internets (network mock)'.function () {
mount(<RemotePizza />);
cy.contains('button'./cook/i).click();
cy.wait('@pizza'); // make sure the network stub was used
for (const ingredient of this.ingredients) { cy.contains(ingredient); }})})Copy the code
Cypress operating results:
With Jest, we need to use the Mock Service Worker (MSW). Note that the original example waited for a DOM element to appear before making assertions. In this case, I used a jest function called inside the server’s callback function, and waited for the function to be called, indicating that it had returned a result, which is more reliable to assert.
import React from 'react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import ingredients from '.. /.. /.. /cypress/fixtures/ingredients.json';
import RemotePizza from '.. /RemotePizza';
const test = jest.fn();
const server = setupServer(
rest.get('/api/pizza'.(req, res, ctx) = > {
test();
return res(ctx.json(ingredients))
})
);
beforeAll(() = > server.listen());
afterEach(() = > server.resetHandlers());
afterAll(() = > server.close());
it('RemotePizza Testing'.async () => {
render(<RemotePizza />);
fireEvent.click(screen.queryByTestId('fetch-button'));
screen.debug();
await waitFor(() = > {
expect(test).toHaveBeenCalledTimes(1);
});
screen.debug();
for (const ingredient ofingredients) { expect(screen.queryByText(ingredient)).toBeInTheDocument(); }})Copy the code
conclusion
- Test cases written by Jest + RTL can be completely migrated to Cypress+ cypress-react-unit-test, and their apis are basically the same.
- Cypress has some unique advantages over Jest running on the command line
- Cypress supports a real-world browser runtime environment
- Have each command execution log and time tracing function
- Element selection tool
- There are screenshots when it fails
- Each of cypress’s commands is asynchronous, some of Jest is synchronous, some of jest is asynchronous, but it also works.