< Articles


React Testing Library - A Starters Guide

In my last post, we covered the primary motivations for switching from Enzyme to the React Testing Library (RTL for the rest of the post). Here we’ll cover the basics of testing with RTL.

Component Setup

To begin, we’ll create a starter component for which we can write tests.

import React from "react";

export interface ICustomDropdownOption {
    value: any;
    displayName: string;
    testId?: string;
}

export interface ICustomDropdownProps {
    value: any;
    label: string;
    testId?: string;
    options: ICustomDropdownOption[];
    onChange: (event: React.ChangeEvent<any>) => void;
}

export const CustomDropdown = (props: ICustomDropdownProps) => {
    return (
        <FormControl>
            <InputLabel>{props.label}</InputLabel>
            <Select
                value={props.value}
                onChange={handleChange}
                data-testid={props.testId}
            >
                {props.options.map((option, index) => {
                    return (
                        <MenuItem
                            value={option.value}
                            key={index}
                            data-testid={option.testId}
                        >
                            {option.displayName}
                        </MenuItem>
                    );
                })}
            </Select>
        </FormControl>
    );
};

The first thing to do when writing any test is to render that component. RTL provides a handy render function to accomplish that purpose:

// Dropdown.test.tsx
import { Dropdown } from "./index";
import { render, fireEvent } from "@testing-library/react";

describe("Dropdown", () => {
    it("should invoke the onChange prop", () => {
        const onChange = () => null;
        const renderResult = render(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                testId={"Dropdown Select"}
                label="Test Dropdown"
            />
        );
    });
});

The renderResult object gives us utility functions for accessing the DOM. These utilities are spread into six types: getBy*, getAllBy*, queryBy*, queryAllBy*, findBy*, and findAllBy*.

Get By & Get All By

The getBy* and getAllBy* utilities are by far the most used. These functions are synchronous and will fail the test if the given text, test id, etc. is not found.

Among the many options RTL provides, I employ getByText, getAllByText, getByTestId, and getAllByTestId 99% of the time. Here’s an example for how we might use getByText.

// Dropdown.test.tsx
import { Dropdown } from "./index";
import { render, fireEvent } from "@testing-library/react";

describe("Dropdown", () => {
    it("Should render the label", () => {
        const options = [
            {
                value: 1,
                displayName: "One",
                testId: "One Option",
            },
        ];
        const onChange = () => null;
        const renderResult = render(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                testId={"Dropdown Select"}
                label="Test Dropdown"
            />
        );

        renderResult.getByText("Test Dropdown");
    });
});

Notice that I never had to run an assertion on the element being found. All getBy* methods will fail the test automatically.

Now let’s take a look at getByTestId:

// Dropdown.test.tsx
import { Dropdown } from "./index";
import { render, fireEvent } from "@testing-library/react";
describe("Dropdown", () => {
    it("should render all of the list items", () => {
        const options = [
            {
                value: 1,
                displayName: "One",
                testId: "Option One",
            },
        ];
        const onChange = () => null;
        const renderResult = render(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                testId={"Dropdown Select"}
                label="Test Dropdown"
            />
        );

        renderResult.getByTestId("Option One");
    });
});

It’s worth noting that getAllBy* have similar behavior. Not finding any elements with the corresponding test id will result in a failed test. The only difference is getAllBy* will allow multiple elements while getBy* will fail the test if multiple elements are found.

Now let’s take a look at queryBy*.

Query By & Query All By

Similar to getBy*, queryBy* is a synchronous method. Unlike getBy*, it will not fail the test if the item is not found. The main use case for this method is in testing the absence of an element. To illustrate this, we’ll implement an async button which show’s Loading... when a loading prop is passed in.

import React from "react";

export interface IButtonProps {
    showLoadingText?: boolean;
    children: string;
    onClick: () => void;
}

export const Button = (props: IButtonProps) => {
    const renderedText = props.showLoadingText ? "Loading..." : children;
    const disabled = !!props.showLoadingText;
    return (
        <button disabled={disabled} onClick={props.onClick}>
            {renderedText}
        </button>
    );
};

Now let’s add the test for our component.

// Button.test.tsx
import { Button } from "./index";
import { render } from "@testing-library/react";

describe("Button", () => {
    it("should hide the loading text if the associated prop is false", () => {
        const renderResult = render(
            <Button showLoadingText={false} onClick={() => null}>
                Button Text
            </Button>
        );

        const isShowingLoadingSpinner = !!renderResult.getByText("Loading...");
        expect(isShowingLoaingSpinner).toBe(false);
    });
});

To be honest, that’s the only use case I see for queryBy* and queryAllBy* methods. Still, it’s incredibly handy to know.

Now let’s take a look at findBy* and findAllBy*.

Find By & Find All By

These methods are different from the first two in the sense that they are not synchronous. Instead, they wait up to 1000 milliseconds for an element (or elements) to become visible.

If an elements, or elements in the case of findAllBy*, become visible before the 1000 milliseconds is up, the test will continue on. If an element is not found within that time frame, an error is thrown which fails the test.

To illustrate this example, let’s set up another component with an API request that resolves after a certain time period.

import React, { useEffect } from "react";

export const TestComponent = () => {
    const [numberOfRequests, setNumberOfRequests] = useState(0);

    const [requestInProgress, setRequestInProgress] = useState(false);

    let pastInitialRender = false;
    useEffect(() => {
        let didCancel = false;
        if (pastInitialRender && !requestInProgress) {
            setTimeout(() => {
                if (didCancel) return;
                setNumberOfRequest((previousNumberOfRequests) => {
                    return previousNumberOfRequests + 1;
                });
                setRequestInProgress(false);
            }, 500);
        }
        pastInitialRender = true;
        return () => (didCancel = true);
    }, [requestInProgress]);

    const onClick = () => {
        setRequestInProgress(true);
    };

    return (
        <div>
            <p>Number of requests: {numberOfRequests}</p>
            <button onClick={onClick}>Send Off Request</button>
        </div>
    );
};

Now let’s look at how we might test that a request is sent off using findByText.

// TestComponent.test.tsx
import { TestComponent } from "./index";
import { render, fireEvent } from "@testing-library/react";

describe("Test Component", () => {
    it("should hide the loading text if the associated prop is false", () => {
        const renderResult = render(<TestComponent />);

        const button = renderResult.getByText("Send Off Request");
        fireEvent.click(button);

        renderResult.getByText("Number of requests: 0");

        // will be successful because the "API" only takes
        // 500 milliseconds to resolve
        renderResult.findByText("Number of requests: 1");
    });
});

Forgive the contrived example here but the principle is still the same. findBy* allows us to wait for DOM elements to show up.

It’s worth noting that at the time of this writing, find by is not working correctly without implementing one of the fixes mentioned here.

Firing Event

Now that we’ve gone over accessing elements in the DOM, we need to touch on firing event. It’s worth mentioning that fireEvent wraps act under the hood.

Firing events is incredibly simple with RTL. After accessing a DOM element, do the following:

const someElement = renderResult.getByText("Some Text");

fireEvent.click(someElement);

Some events, such as the change event, will require an event to be passed through. Here’s how that would look:

const someElement = renderResult.getByTestId("My Text Input");

fireEvent.change(someElement, {
    target: {
        value: "New Value",
    },
});

Notice that we only need to mock out the pieces of the event we need for our onChange method to run.

React Testing Library – Nuances

Understanding how to select elements and trigger events is all you need to get going with RTL. Still, there’s a couple of special nuances I’ve come across that are worth mentioning.

The first is that RTL doesn’t completely eliminate the need for the act utility. This is particularly true of triggering programmatic updates (discouraged but I still see a use for it) to a component which you could do if the component has a dependency on Redux as well as long standing async actions. As a general rule, only pull in act when you start getting warnings in the console.

From an async perspective, a simple way to resolve most Promises before finishing a test is to add this line of code to the end of the test:

await act(() => Promise.resolve());

Assuming your mocked out API requests have immediately resolved or rejected Promises, that will work wonders. After Create React App upgrades to the lastest Jest DOM, you can do this:

import { wait } from "@testing-library/react";

// ... in your test
await wait();

Another nuance I’ve noticed seems specific to our component implementation but may be applicable to your situation as well. With our dropdown component, the click method won’t open the dropdown. Instead, we use the mouseDown method like so:

const selectItem = renderResult.getByTestId("Select Item");
fireEvent.mouseDown(selectItem);

Use With Cypress

Before wrapping up this post, it’s worth mentioning that these test ids can be used by Cypress as well. To eliminate discrepancies between our E2E tests and our unit tests, we separate the test ids into their own file that lives as a sibling to the component. In the case of a Dropdown component, we’d have this code:

// Dropdown.testIds.ts

export const dropdownTestIds = {
    select: "Dropdown - Select",
    list: "Dropdown - List",
};

Then our component file, as well as our Cypress tests, would pull in those ids.

It’s slightly more nuanced than what I’ve described here because our base level components like Dropdown actually have test id props instead of hard coding the ids. We do that to ensure each test id is somewhat unique across the application.

If you plan on going this route and using the test ids in Cypress, I would recommend adding the command to Cypress as well. That would look something like this:

// commands.ts

Cypress.Commands.add("getByTestId", (testId: string) => {
    return cy.get(`[data-testid="${testId}"]`);
});

That pretty much wraps it up for most cases! To dive a little deeper, take a look at some advanced cases here.