Wednesday, June 2, 2021

React Testing Library

This is quick post that summarizes React Testing Library (RTL) and how we use it at 99d.

What is RTL?

We use RTL for testing React components. The specific kind of tests it can perform are unit tests.

RTL is known for being pretty easy to get started with. It works seamlessly with the test runner Jest and is bundled with create-react-app which is great as most React beginners will have a test environment ready to use without having to install new dependencies.

Why do we write tests?

We write tests to increase the confidence we have in our code implementation. The more confidence we have the more robust and maintainable our code is.

Think about a situation where you create a new feature. Let's say that this feature is a popup box that appears when you click on a button. You write some RTL tests to ensure that this popup appears when this button is clicked and can be interacted with when it's visible. You ship the feature.

6 months pass and some requirements change. A different developer is tasked with adding another form input to the popup box. The unit tests written months ago ensure that the changes the new developer makes will not effect the code implementation that already exists. The team can therefore work much more collaboratively to quickly iterate on features.

When do we write tests?

A new component that adds functionality to the 99d site usually means it's time to write a RTL unit test.

As it's a unit test we're just focussed on testing small pieces of code in an isolated environment. Fetched data will be mocked. We don't care about what other components are doing.

In RTL's case we usually just test one component and mock any data that's passed to the component as a prop or through context.

What are some guiding principals of RTL?

Like many testing libraries and frameworks RTL has some strong opinions about the best way to test. Probably the most important mantra that you'll see all the time in examples and documentation is "write tests that closely resemble how your web pages are used".

So what does this mean? Basically as a tester you should be thinking about how a user would interact with the code rather than how the code is written. The user doesn't care about props that pass through page data. They care if the correct text is on the page that provides them with some expected information. The user don't know about setting state. They care that they can fill out form inputs correctly.

Thinking in terms of the user will help you write great RTL tests.

Can you show me some code examples?

This PR contained a bunch of RTL testing that I'm pretty happy with.

We're again using the example from before where a popup box appears when a button is clicked.

describe("DismissJobButton", () => {
  it("should show popup when 'Not interested' button is clicked and hide popup when 'Not interested' button is clicked again", () => {
    render(
      <SnackbarProvider>
        <MockedProvider>
          <DismissJobButton jobId="35" disabled={false} />
        </MockedProvider>
      </SnackbarProvider>
    );
    userEvent.click(screen.getByRole("button", { name: /not interested/i }));
    const textContent = "Let us know why you're not interested in this project";
    expect(screen.queryByText(textContent)).toBeInTheDocument();
    userEvent.click(screen.getByRole("button", { name: /not interested/i }));
    expect(screen.queryByText(textContent)).not.toBeInTheDocument();
  });
});

A couple of points:

  • We wrap all our unit tests in a describe block that usually contains the name of the component we're testing
  • The it message should make sense in a sentence with what's passed to the describe
  • We render a component mocking some props and context, this returns a DOM representation of the component
  • Instead of using the fireEvent API we're instead using the userEvent which better simulates real user events
  • I'm using screen to avoid having to use the container returned from render
  • I'm using getByRole which is what the library recommends using to query for elements, the reason for this recommendation is that it reflects the experience of all your users, including those that use assistive technologies
  • I'm then asserting that a text element that's within the popup is in the document, toBeInTheDocument is a jest matcher, you can see all the available matchers in VSCode with autocompletion
  • I'm then testing for the reverse as well, where a visible button is removed and we assert that the text is no longer in the document

As there's some repetition here we can also abstract some on this into their own utility functions.

const queryHeadingInPopup = () => {
  const textContent = "Let us know why you're not interested in this project";
  return screen.queryByText(textContent);
};

const clickOnNotInterestedButton = () =>
  userEvent.click(screen.getByRole('button', { name: /not interested/i }));

This makes the querying and assertion code read a little better.

clickOnNotInterestedButton();
expect(queryHeadingInPopup()).toBeInTheDocument();
clickOnNotInterestedButton();
expect(queryHeadingInPopup()).not.toBeInTheDocument();

Something else you can do with RTL is fill out form inputs.

const fillOutFormInputs = () => {
  const { getByRole } = screen;
  userEvent.click(getByRole('checkbox', { name: /i'm too busy/i }));
  userEvent.click(getByRole('checkbox', { name: /too cheap/i }));
  const input = getByRole('textbox', { name: /other/i });
  const value = 'Need jobs that are more than $200';
  userEvent.type(input, value);
};

This function was used in this bigger test which is arguably more of an integration test 🤪.

it("can click not interested button, check 2 checkboxes, click confirm button and then the popup is closed", async () => {
  const mocks = [
    {
      request: {
        query: Operations.DismissJob,
        variables: {
          id: "36",
          reason: ["Too cheap,Looking for work over $200"],
        },
      },
      result: {
        data: {
          job: {
            id: "36",
          },
          interest: {
            type: "DISMISS",
          },
        },
      },
    },
  ];
  render(
    <SnackbarProvider>
      <MockedProvider mocks={mocks}>
        <DismissJobButton jobId="36" disabled={false} />
      </MockedProvider>
    </SnackbarProvider>
  );
  clickOnNotInterestedButton();
  const heading = queryHeadingInPopup();
  expect(heading).toBeInTheDocument();
  fillOutFormInputs();
  clickOnConfirmButton();
  await waitForElementToBeRemoved(heading);
  expect(heading).not.toBeInTheDocument();
});

It mocks the GraphQL mutation, renders the component, triggers a click on the button, checks if the header in the popup renders, fills out the form inputs, clicks on the submit button for the form, waits for the heading to be removed from the screen and then asserts that it's removed. The waitForElementToBeRemoved is a good one to remember for when dealing with any asynchronous behavior as this test does.