reactReact Testing Library

Introduction

Manual Testing

Drawbacks:

  • Time consuming
  • Complex repetitive tasks carries the risk of human error
  • May not be able to test all features

Automated Testing

An automated test is code that throws an error if the output of a piece of code is not what was expected.

Advantages:

  • Not time consuming
  • Reliable, consistent and not error prone
  • Easy to identify and fix features that break tests
  • Higher shipping confidence

Jest vs RTL

  • Jest is a test runner that finds tests, runs the tests, and reports the results in a human readable format.
  • React Testing Library (RTL) is a JavaScript testing utility that provides a virtual DOM for testing React components, which we can use to interact with and verify the behavior of a React component.
  • Testing Library is a family of packages which help test UI components.
  • The core library is called DOM Testing Library and RTL is simply a wrapper around this core library to make it easier to test React components.
  • RTL does not care about the implementation details of a component. It only cares about the behavior of a component. If you refactor your component, your tests should still pass.

Types of Tests

Unit Tests

  • Focus is on testing the individual building blocks of an application such as a class or a function or a component.
  • Each unit or building block is tested in isolation, independent of other units.
  • Dependencies are mocked.
  • They run in a short amount of time and make it very easy to pinpoint failures.
  • Relatively easy to write and maintain.

Integration Testing

  • Focus is on testing a combination of units and ensuring they work together.
  • Take longer than unit tests.

End-to-End Testing

Focus is on testing the entire application flow and ensuring it works as designed from start to finish. Involves a real UI, a real backend database, real services, etc. Takes the longest as they cover the most amount of code. May have to incur costs for running tests that interact with third party services such as external APIs.

Test Driven Development (TDD)

Write tests before writing the actual code. Once the tests have been written, write the code to make the tests pass.

  1. Create tests that verify the functionality of a specific feature
  2. Write code that will run the tests successfully
  3. Refactor the code for optimization while ensuring the tests continue to pass

Also called red-green testing as the tests are written before the code is written.

Jest

Anatomy of a Test

test(name, fn, timeout)

  • name: A string that describes the test
  • fn: A function that contains the expectations to test
  • timeout: The number of milliseconds to wait before considering the test failed
View code
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";
 
test("renders learn react link", () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i); // case insensitive regex
  expect(linkElement).toBeInTheDocument();
});

test and expect are global functions provided by Jest. With create-react-app, the developer does not need to import them.

Instead of the test function, the it function can also be used.

View code
it("renders learn react link", () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i); // case insensitive regex
  expect(linkElement).toBeInTheDocument();
});

Watch Mode

Watch mode runs unit tests for files that have been changed since the last commit.

Press w in the terminal to see the list of options available in watch mode.

Filtering tests

  • Filter tests in the terminal by typing in a regex pattern to match the test name or the file name
  • Use test.only to run only the test that is being worked on
  • Use test.skip to skip a test
  • Tests can also be skipped with xit

Grouping Tests

Use describe to group tests.

describe(name, fn)

  • name: A string that describes the group of tests
  • fn: A function that contains the expectations to test
View code
describe("App", () => {
  test("renders learn react link", () => {
    render(<App />);
    const linkElement = screen.getByText(/learn react/i);
    expect(linkElement).toBeInTheDocument();
  });
  test("renders learn react link", () => {
    render(<App />);
    const linkElement = screen.getByText(/learn react/i);
    expect(linkElement).toBeInTheDocument();
  });
});

One file can have multiple describe blocks. It is also possible to nest describe blocks.

A test suite corresponds to a file and not describe blocks.

Filename Conventions

Jest looks for files with the following naming conventions:

  • Files with test.js or test.tsx suffix
  • Files with spec.js or spec.tsx suffix
  • Files with .js or .tsx suffix in a __tests__ directory

Although tests can be located anywhere in the codebase, it is recommended to put your tests next to the code they are testing to that relative imports are shorter.

Code Coverage

Code coverage is a metric that measures the amount of code that is tested.

  • Statement coverage: number of statements that have been executed
  • Branch coverage: number of branches of the control structure that have been executed
  • Function coverage: number of functions that have been called
  • Line coverage: number of lines of source code that have been executed

For coverage, we need to watch all files and not just the changed files.

npm test -- --coverage --watchAll

Ask Jest to only cover certain files by using the collectCoverageFrom option in the Jest configuration file.

npm test -- --coverage --watchAll --collectCoverageFrom=src/components/**/*.{ts,tsx}

collectCoverageFrom can also be used to ignore certain files if we add a leading ! to the path.

It is also possible to specify a threshold for coverage. If the coverage falls below the threshold, the test will fail.

// in package.json
"jest": {
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  }
}

Assertions

Assertions check if the values meet certain conditions, and they decide if the test passes or fails.

Assertions are carried out with the global expect function.

Expect is used with matchers to check if the value meets the expected value.

View code
expect(value).toBe(expectedValue);

Out of the box, Jest provides matcher functions that does not involve the UI or the DOM.

For testing React components, we can import additional matchers from a library called @testing-library/jest-dom.

Jest DOM comes pre-installed with create-react-app.

View code
// setupTests.js
import "@testing-library/jest-dom";

React Testing Library

Writing Tests

What to test

  • Test component renders
  • Test component renders with props
  • Test component renders in different states
  • Test component reacts to events

What not to test

  • Implementation details
  • Third party code
  • Code that is not important from the user’s perspective

RTL Queries

Queries are the methods that the testing library provides to find elements on the page.

To find a single element on the page, use

  • getBy
  • findBy
  • queryBy

To find multiple elements on the page, use

  • getAllBy
  • findAllBy
  • queryAllBy

The suffix can be one of the following:

  • Text
  • LabelText
  • PlaceholderText
  • DisplayValue
  • AltText
  • Title
  • Role
  • TestId

getByRole

getByRole is used to find elements by their role attribute (ARIA-roles). Button element has a button role, input element has a textbox role, etc.

If you are working with elements that do not have a default role or if you want to specify a different role, you can use the role attribute to specify the role.

View code
const button = screen.getByRole("button");

getByRole also accepts an options object as the second argument.

View code
const button = screen.getByRole("button", { name: "Click me" });
// the name option is case sensitive

The name option is helpful when you have multiple elements with the same role. The level option is used to specify the level of the element in the accessibility tree, such as heading level 1, heading level 2, etc. There’s also options such as pressed, expanded, checked, selected, etc.

getByLabelText

getByLabelText is used to find elements by their label text. It is useful for finding form elements such as input, select, textarea, etc.

<label for="username">Username</label> <input id="username" />
View code
const input = screen.getByLabelText("Username");

getByLabelText also accepts for wrapper elements such as label.

<label>
  <input type="checkbox" id="terms" /> I agree to the terms and conditions
</label>
View code
const checkbox = screen.getByLabelText("I agree to the terms and conditions");

getByPlaceholderText

getByPlaceholderText is used to find elements by their placeholder text.

<input type="text" placeholder="Enter your username" />
View code
const input = screen.getByPlaceholderText("Enter your username");

getByText

getByText is used to find elements by their text content.

<button>Click me</button>
View code
const button = screen.getByText("Click me");

getByDisplayValue

getByDisplayValue is used to find elements (input, textarea, select, etc.) by their display value.

<input type="text" value="John Doe" />
View code
const input = screen.getByDisplayValue("John Doe");

getByAltText

getByAltText is used to find elements (image, area, input, etc.) by their alt text.

<img src="profile.jpg" alt="Profile picture" />
View code
const img = screen.getByAltText("Profile picture");

getByTitle

getByTitle is used to find elements by their title attribute.

<button title="Click me">Click me</button>
View code
const button = screen.getByTitle("Click me");

getByTestId

getByTestId is used to find elements by their data-testid attribute.

<button data-testid="submit">Submit</button>
View code
const button = screen.getByTestId("submit");

Priority Order for Queries

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue

If you still cannot find the element, you can use the following queries:

  1. getByAltText
  2. getByTitle

However, with these attributes, the user experience varies greatly depending on the browser. For example, the title attribute is not consistently read by screen readers, and alt text is not always displayed.

If none of these work, your last resort should be getByTestId.

  1. getByTestId

Use this when the text is dynamic.

Queries for Multiple Elements

Each getBy query has a corresponding getAllBy query.

View code
describe("Skills", () => {
  test("renders all skills", () => {
    render(<Skills skills={skills} />);
    const listItemElements = screen.getAllByRole("listitem");
    expect(listItemElements).toHaveLength(3);
  });
});

TextMatch

<div>Hello World</div>
View code
screen.getByText("Hello World"); // full string match
screen.getByText("llo Worl", { exact: false }); // substring match
con;
screen.getByText("hello workd", { exact: false }); // ignore case
screen.getByText(/hello world/i); // regex match
screen.getByText((content, element) => {
  return content.startsWith("Hello");
}); // custom function

queryBy

  • queryBy queries return the matching node for a query, and return null if the element is not found.
  • Useful for asserting an element that is not present
  • Throws an error if more than one match is found
  • queryAllBy returns an array of all matching nodes for a query, and returns an empty array if no elements are found
View code
const button = screen.queryByRole("button");
expect(button).not.toBeInTheDocument();

This is useful for finding elements that are not present on the page.

findBy

  • findBy queries return a promise that resolves when the element is found
  • The promise is rejected if no element is found or if more than one element is found after a default timeout of 1000ms
  • Useful for asserting elements that are not present initially but are rendered after an asynchronous operation
  • findAllBy returns a promise that resolves to an array of all matching elements for a query, and the promise is rejected if no elements are found after a default timeout of 1000ms
View code
const button = await screen.findByRole("button");
  • Pass in a third argument to findBy to change the timeout
View code
const button = await screen.findByRole("button", {}, { timeout: 2000 });

Manual Queries

You can use the regular querySelector DOM API to find elements.

View code
const { container } = render(<MyComponent />);
const foo = container.querySelector("[data-foo=bar]");

Debugging

View code
import { render, screen, logRoles } from "@testing-library/react";
 
test("renders learn react link", () => {
  const view = render(<App />);
  logRoles(view.container);
});

User Interactions

user-event

User interactions are tested using a companion library called @testing-library/user-event that simulates user interactions by dispatching the events that would happen if the interaction took place in a browser.

user-event simulates full interactions, which may fire multiple events and do additional checks along the way.

fireEvent

fireEvent is a low-level API that simulates events on a DOM node.

user-event vs fireEvent

For example, when a user types into a text box, the element has to be focused, and then keyboard and input events are fired and the selection and value on the element are manipulated as they type.

We can dispatch the change event on an input field using fireEvent.

user-event allows you to describe a user interaction instead of a concrete event. It adds visibility and intractibility checks along the way and manipulates the DOM just like a user interaction in the browser would. It factors in the fact that the user wouldn’t click a hidden element or type in a disabled text box.

Pointer Events

Convenience APIs internally calls the pointer API.

  • click
  • dblClick
  • tripleClick
  • hover
  • unhover

We can also use low level Pointer APIs.

  • pointer({keys: '[MouseLeft]'}) similates a left click
  • pointer({keys: '[MouseLeft][MouseRight]'}) simulates a left click followed by a right click
  • pointer('[MouseLeft][MouseRight]') pass in a string if keys is the only argument
  • pointer('[MouseLeft>]') hold without releasing it
  • pointer('[/MouseLeft]') release the click
View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
 
test("clicks the button", () => {
        userEvent.setup();
        render(<App />);
        const button = screen.getByRole("button");
        await userEvent.click(button);
});

Note: All pointer events are asynchronous.

Keyboard Events

Utility APIs

  • type
  • clear
  • selectOptions
  • deselctOptions
  • upload

Convenience APIs

  • tab

Clipboard APIs

  • paste
  • copy
  • cut

Low Level Keyboard APIs

  • keyboard('foo') // type 'foo'
  • keyboard('{Shift>}A{/Shift}') // Shift(down) + A + Shift(up)
View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
 
test("types in the input", () => {
        userEvent.setup();
        render(<App />);
        const input = screen.getByRole("textbox");
        await userEvent.type(input, "Hello World");
});

Providers

Providers are used to provide context to the component being tested.

View code
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "styled-components";
import App from "./App";
 
test("renders learn react link", () => {
  render(<App />, {
    wrapper: ThemeProvider,
  });
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

Custom Render Function

  • Create test-utils.js file in the src directory
  • Copy sample code from docs
  • Import { render, screen } from test-utils.js in the test file
View code
import React from "react";
import { render } from "@testing-library/react";
import { ThemeProvider } from "my-ui-lib";
import { TranslationProvider } from "my-i18n-lib";
import defaultStrings from "i18n/en-x-default";
 
const AllTheProviders = ({ children }) => {
  return (
    <ThemeProvider theme="light">
      <TranslationProvider messages={defaultStrings}>
        {children}
      </TranslationProvider>
    </ThemeProvider>
  );
};
 
const customRender = (ui, options) =>
  render(ui, { wrapper: AllTheProviders, ...options });
 
// re-export everything
export * from "@testing-library/react";
 
// override render method
export { customRender as render };

Custom React Hooks and act utility function

View code
// useCounter.tsx
import { useState } from "react";
import { UseCounterProps } from "./userCouner.types";
 
export const useCounter = ({ initialCount = 0 }: UseCounterProps = {}) => {
  const [count, setCount] = useState(initialCount);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  return { count, increment, decrement };
};
 
// useCounter.test.tsx
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
 
describe("useCounter", () => {
  test("should render the initial count", () => {
    const { result } = renderHook(useCounter);
    expect(result.current.count).toBe(0);
  });
 
  test("should accept and render the same initial count", () => {
    const { result } = renderHook(useCounter, {
      initialProps: { initialCount: 10 },
    });
    expect(result.current.count).toBe(10);
  });
 
  test("should increment the count", () => {
    const { result } = renderHook(useCounter);
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });
 
  test("should decrement the count", () => {
    const { result } = renderHook(useCounter);
    act(() => result.current.decrement());
    expect(result.current.count).toBe(-1);
  });
});

Mocking

Mocking Functions

View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
 
test("clicks the button", () => {
  const handleClick = jest.fn();
  render(<App onClick={handleClick} />);
  const button = screen.getByRole("button");
  userEvent.click(button);
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Mocking HTTP Requests

MSW Setup

Use MSW (Mock Service Worker) to mock HTTP requests.

MSW intercepts the requests made by the application and returns a mocked response.

npm install msw --save-dev
View code
// mocks/handlers.ts
import { rest } from "msw";
 
export const handlers = [
  rest.get("https://jsonplaceholder.typicode.com/users", (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        {
          name: "Bruce Wayne",
        },
        {
          name: "Clark Kent",
        },
        {
          name: "Princess Diana",
        },
      ])
    );
  }),
];
View code
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
 
// This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers);
View code
// setupTests.js
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
// src/setupTests.js
import { server } from "./mocks/server";
// Establish API mocking before all tests.
beforeAll(() => server.listen());
 
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
 
// Clean up after the tests are finished.
afterAll(() => server.close());
Testing and Error Handling With MSW
View code
// Users.tsx
import { useState, useEffect } from 'react'
 
export const Users = () => {
    const [users, setUsers] = useState<string[]>([])
    const [error, setError] = useState<string | null>(null)
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users')
            .then((res) => res.json())
            .then((data) => setUsers(data.map((user: { name: string }) => user.name)))
            .catch(() => setError('Error fetching users'))
    }, [])
    return (
        <div>
            <h1>Users</h1>
            {error && <p>{error}</p>}
            <ul>
                {users.map((user) => (
                    <li key={user}>{user}</li>
                ))}
            </ul>
        </div>
    )
}
 
// Users.test.tsx
import { render, screen } from '@testing-library/react'
import { Users } from './Users'
import { rest } from 'msw'
import { server } from '../../mocks/server'
 
describe('Users', () => {
    test('renders correctly', () => {
        render(<Users />)
        const textElement = screen.getByText('Users')
        expect(textElement).toBeInTheDocument()
    })
 
    test('renders a list of users', async () => {
        render(<Users />)
        const users = await screen.findAllByRole('listitem')
        expect(users).toHaveLength(3)
    })
 
    test('renders error', async () => {
        server.use(
            rest.get(
                'https://jsonplaceholder.typicode.com/users',
                (req, res, ctx) => {
                        // replace the handler with a new one that returns a 500 status code
                    return res(ctx.status(500))
                }
            )
        )
        render(<Users />)
        const error = await screen.findByText('Error fetching users')
        expect(error).toBeInTheDocument()
    })
})

Static Analysis Testing

Process of verifying the code without running it.

  • Ensure consistent style and formatting
  • Check for common mistakes and possible bugs
  • Limit the complexity of code and
  • Verify type consistency

All types of tests run the code and then compare the output to the expected value.

Static testing analyses aspects such as readability, consistency, error handling, type checking, and alignment with best practices.

Testing checks if your code works or not, whereas static analysis checks if it is written well or not.

Static analysis testing tools:

  • TypeScript
  • ESLint
  • Prettier
  • Husky
  • lint-staged

ESLint

ESLint is a static analysis tool that checks JavaScript code for common mistakes and errors. create-react-app comes with ESLint pre-configured.

"scripts": {
    "lint": "eslint --ignore-path .gitignore ."
}
npm run lint

Prettier

Prettier is a code formatter that ensures that all outputted code conforms to a consistent style.

"scripts": {
    "format": "prettier --ignore-path --write \"**/*.{ts,tsx,css,scss}\""
}
npm run format

Create a prettierrc.json file in the root directory to customise the Prettier rules.

{
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 80,
  "tabWidth": 2
}

When using Prettier with ESLint, it is important to install the eslint-config-prettier package.

npm install eslint-config-prettier --save-dev
eslintConfig: {
    "extends": ["react-app", "react-app/jest", "plugin:prettier/recommended", "eslint-config-prettier"],
}

This disables some ESLint rules that conflict with Prettier or are already covered by Prettier.

Husky

Husky prevents developers from committing code that does not pass the linting and formatting checks.

npm install husky --save-dev
#!/usr/bin/env sh

. "$(dirname "$0")/_/husky.sh"

npm run lint && npm run format

lint-staged

Lint-staged runs linters on files that are staged in Git.

npm install lint-staged --save-dev
"lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint"],
    "*.{ts,tsx,css,scss}": ["prettier --write"]
}
#!/usr/bin/env sh

. "$(dirname "$0")/_/husky.sh"

npm run lint-staged

To ensure no code is pushed without passing the tests, run this:

npx husky add .husky/pre-push "npm test -- --watchAll=false

If you try to push code that does not pass the tests, Husky will prevent the code from being pushed.