< Articles


Switching From Enzyme to React Testing Library

Over the last two weeks, I’ve rewritten hundreds of unit tests spread across three repositories in an effort to move away from using Enzyme. From the outside looking in, it would seem like a wasted effort as we have roughly the same number of unit tests we did before. Dive a little deeper however and you’ll find the switch is a move toward greater velocity.

In this article, I’ll outline the motivation for the switch.

Dialog Simplicity

When weighing two technologies, I first consider the feature differences between the two. When features are nearly equivalent, or least the features I plan on utilizing are nearly equivalent, I always choose the technology that is simpler. That’s why I prefer React to Angular and also why I prefer React Testing Library to Enzyme.

One of the first causes for concern with Enzyme was its difficulty in testing popups and dialogs. While there are workarounds for the issue, it never felt like a first class citizen of the testing utility. This may not be a problem for some applications but those using React Material should pay close attention.

You see, React Material uses these types of components all over the place, the most common being the Dropdown and the Date Picker. In B2B applications, like those we typically work on at Xactware, these form-based components pop up all over the place.

With the React Testing Library (RTL from here on), these elements can be accessed from the original wrapper. RTL behaves like a mini application, allowing to access elements rendered outside of your original component’s tree.

Let’s compare the two so that you can get a visual of the difference. To do so, let’s imagine we have the following component from

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>
    );
};

Now let’s assume we want to test if the onChange prop is invoked when we click on an item in the list. Here’s how we might approach that with Enzyme.

// Dropdown.test.tsx
import { Dropdown } from "./index";
import { mount, ReactWrapper } from "enzyme";

describe("Dropdown", () => {
    it("should invoke the onChange prop", () => {
        let onChangeInvoked = false;
        const options = [
            {
                value: 1,
                displayName: "One",
            },
        ];
        const onChange = () => (onChangeInvoked = true);
        const wrapper = mount(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                label="Test Dropdown"
            />
        );
        const select = wrapper.find(".MuiSelect-root");
        select.simulate("click");

        const list = document.getElementsByClassName(".MuiList-root")[0];
        const listWrapper = new ReactWrapper(list, true);

        expect(onChangeInvoked).toBe(false);
        listWrapper.find("li").click();
        expect(onChangeInvoked).toBe(true);
    });
});

Notice how we need to directly access the document in order to find the launched dropdown list. Now compare the equivalent scenario with RTL.

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

describe("Dropdown", () => {
    it("should invoke the onChange prop", () => {
        let onChangeInvoked = false;
        const options = [
            {
                value: 1,
                displayName: "One",
                testId: "One Option",
            },
        ];
        const onChange = () => (onChangeInvoked = true);
        const renderResult = render(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                testId={"Dropdown Select"}
                label="Test Dropdown"
            />
        );

        const select = renderResult.getByTestId("Dropdown Select");
        fireEvent.click(select);

        const listItem = renderResult.getByTestId("One Option");
        expect(onChangeInvoked).toBe(false);
        fireEvent.click(listItem);
        expect(onChangeInvoked).toBe(true);
    });
});

Notice how we never need to create an additional wrapper or access the DOM directly. Instead, the renderResult natively wraps the entire DOM.

Wrapping Act

If you’ve done any testing with Enzyme, you know it’s common to see warnings related to the act function. This is a helpful utility that ensures all lifecycle methods have completed before running a given assertion. It even supports waiting on async actions that call setState.

While super useful, it’s a little annoying that all interactions with the DOM aren’t automatically wrapped in act. I’ve always wanted this behavior by default and RTL has it out of the box.

If we really wanted to do our first example correctly, the code for Enzyme would look like this:

// Dropdown.test.tsx
import { Dropdown } from "./index";
import { mount, ReactWrapper } from "enzyme";
import { act } from "react-dom/test-utils";

describe("Dropdown", () => {
    it("should invoke the onChange prop", () => {
        let onChangeInvoked = false;
        const options = [
            {
                value: 1,
                displayName: "One",
            },
        ];
        const onChange = () => (onChangeInvoked = true);
        const wrapper = mount(
            <Dropdown
                value={1}
                options={options}
                onChange={onChange}
                label="Test Dropdown"
            />
        );
        const select = wrapper.find(".MuiSelect-root");
        act(() => select.simulate("click"));

        const list = document.getElementsByClassName(".MuiList-root")[0];
        const listWrapper = new ReactWrapper(list, true);

        expect(onChangeInvoked).toBe(false);
        act(() => listWrapper.find("li").click());
        expect(onChangeInvoked).toBe(true);
    });
});

While forgetting to add act will not always cause test failures, the warnings in the console can be quite annoying. With RTL, we don’t have this problem because fireEvent internally wraps it’s invocations in act.

Testing Stability

Aside from simplicity, the other major upside of RTL is its testing stability. Below, I’m displaying some code that we used for our initial Enzyme test:

const select = wrapper.find(".MuiSelect-root");
select.simulate("click");

const list = document.getElementsByClassName(".MuiList-root")[0];
const listWrapper = new ReactWrapper(list, true);

expect(onChangeInvoked).toBe(false);
listWrapper.find("li").click();
expect(onChangeInvoked).toBe(true);

Consider how brittle those selectors are. What if React Material decides that MuiSelect-root and MuiList-root should instead become MuiSelect-base and MuiList-base? Or what if they change the implementation from using an li to being a less semantic div?

Our tests break. And guess what, nothing in our application truly changed.

Now instead, consider the getByTestId method that RTL provides us.

const select = renderResult.getByTestId("Dropdown Select");
fireEvent.click(select);
const listItem = renderResult.getByTestId("One Option");

Relying on a test id or even text is a better solution because it’s something we have control over. Additionally, we can use these same test ids in our E2E tests which we’ll talk more about when we go over implementation.

Summary

In conclusion, RTL seems like the better option. It’s not only simpler but it’s also more stable. From a productivity perspective, my own testing velocity seems to have doubled or tripled. If you want to learn more about RTL implementation, take a look at this post.