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.