BLOG

From a simple test

A case for react-testing-library

9/3/2018Time to read: 7 min

The target audience of this post are developers working with React and have some experience on unit testing React components.

Let's talk about unit testing for React. Say we have a simple React component. It does an ajax call in componentDidMount and then renders the content according to the response. The code for the component is:

import React, { Component } from "react";

class App extends Component {
  constructor() {
    super();
    this.state = {
      username: ""
    };
  }

  fetchUser() {
    // return a promise that resolve to
    // {
    //   username: <Some username>
    // }
  }

  componentDidMount() {
    this._isMounted = true;
    this.fetchUser().then(data => {
      if (this._isMounted) {
        this.setState({ username: data.username });
      }
    });
  }

  componentWillUnmount() {
    this._isMounted = false;
  }

  render() {
    return (
      <div>
        <h2>{this.state.username}</h2>
      </div>
    );
  }
}

export default App;

What should be tested about this component? We obviously need to test that when the server returns some data, it should render it correctly. This component will trigger an ajax call, so we need to mock it in the test. I can mock the network request API, but for simplicity, I will just mock the fetchUser function.

// the following test is using jest
import App from './index';

test('should render server result', () => {
  const mockUsername = 'test-user';
  jest.spyOn(App.prototype, 'fetchUser').mockResolvedValue({
    username: mockUsername
  });
  // ... other test code
});

But how exactly should we test the render output? When the component initially renders, it outputs empty string, because the initial state of username is empty. Only after the async call to fetch user resolves, that we are able to render the username.

Can we mock fetchUser to be synchronous? It's not a good idea, because we want to align with the original behavior of the component as much as possible in a test.

We mocked the async call to resolve immediately. So when componentDidMount is triggered, it will push an async task .then(data => { if (this._isMounted) { this.setState({ username: data.username }); } }) into JavaScript event queue to be executed in the next cycle. We just need to push a new task after it, and write out assertions there.
So we can do the following using enzyme:

import React from "react";
import { configure, shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import App from "./index";

configure({ adapter: new Adapter() });

test("should display username", () => {
  const mockUsername = "test-user";
  jest.spyOn(App.prototype, "fetchUser").mockImplementation(() =>
    Promise.resolve({
      username: mockUsername
    })
  );
  const wrapper = shallow(<App />);
  return Promise.resolve().then(() => {
    // assertion to check the rendering of server result.
    expect(wrapper.find("h2").text()).toBe(mockUsername);
  });
});

In this test, the following tasks get executed in order:

  1. enzyme shallow-renders App component and calls fetchUser in componentDidMount, which Pushes task 2 into event queue. Our test returns and pushes task 3 into event queue
  2. mocked fetchUser resolves and triggers setState with mockUsername.
  3. our test assertion runs to check the h2 element with the expected text.

What if we change our componentDidMount to the following:

componentDidMount() {
  this._isMounted = true;
  this.fetchUser()
    .then(
      // validate server result
      data =>
        data.status === "SUCCESS"
          ? data
          : Promise.reject(new Error("server error"))
    )
    .then(data => {
      if (this._isMounted) {
        this.setState({ username: data.username });
      }
    }).catch((error) => {
      // handle error
    });
}

Here we added an extra step to validate the server result. It is another async task. Guess what, our test broke. Because the event queue looks like this now:

  1. enzyme shallow-renders App component and calls fetchUser in componentDidMount, which Pushes task 2 into event queue. Our test returns and pushes task 3 into event queue
  2. mocked fetchUser resolves and triggers validation for server result, pushing task 4 into event queue
  3. our test assertion runs to check the h2 element with the expected text but it fails
  4. validation for server result resolves and triggers setState with mockUsername.

So what happened? The task to update the state is only pushed into event queue when fetchUser resolves, and by the time, our assertion is already waiting to be run next.

We can fix our test to postpone the assertion:

test("should display username", () => {
  const mockUsername = "test-user";
  jest.spyOn(App.prototype, "fetchUser").mockImplementation(() =>
    Promise.resolve({
      status: 'SUCCESS',
      username: mockUsername
    })
  );
  const wrapper = shallow(<App />);
  return Promise.resolve()
  .then(() => {}) // does nothing, simply to wait a cycle
  .then(() => {
    // assertion to check the rendering of server result.
    expect(wrapper.find("h2").text()).toBe(mockUsername);
  });
});

We added a no-op .then in our test to make sure our assertion runs after the state is updated. But this looks ugly. Our test is knowing too much about the source. Do we really care about how many event loop cycle it takes to update the render?

If we know that promise is a microtask and setTimeout is a macrotask (wait, micro, macro-what? Learn more about different types of JavaScript event queue here). When our test is run, all the microtasks are executed before the next macrotask is executed, so if we use a setTimeout in our test, it will work.

test("should display username", (done) => {
  const mockUsername = "test-user";
  jest.spyOn(App.prototype, "fetchUser").mockImplementation(() =>
    Promise.resolve({
      status: "SUCCESS",
      username: mockUsername
    })
  );
  const wrapper = shallow(<App />);
  setTimeout(() => {
    expect(wrapper.find("h2").text()).toBe(mockUsername);
    done();
  }, 0);
});

But still, this looks like a hack.

Let's come back to the requirement of our test. What exactly do we want to test? We want to test that when the component renders, it renders out the username from the server. We don't care about how our component is implemented. We want our test to be resilient to changes to the implementation detail. We want our test to be an easy-to-understand documentation for future developers. Apparently, we are not doing a good job above.

How would a real user test our component? test-in-production

A user would open up our page, and wait for a while because they understand the page might take some time to load. When the username finally shows up, the user is happy "Hey, it works". But if the username doesn't show up for a while, they get angry "Hey! your site is a garbage!".

What if we change our test to be more like a user? It should wait for a while, until the username shows up, if it doesn't show up after a certain duration, we report test broken.

This is exactly the idea of react-testing-library, a trending replacement for enzyme. As the name suggests, it is a testing library for React (duh). 'react-testing-library`'s guiding principle is

The more your tests resemble the way your software is used, the more confidence they can give you.

React testing library renders React component directly to dom, and in our test, we are testing against the actual dom elements instead of React instances.

How would our test look like using react-testing-library?

import React from "react";
import { render, cleanup, waitForElement } from "react-testing-library";
import App from "./src/index";

test("should display username", () => {
  const mockUsername = "test-user";
  jest.spyOn(App.prototype, "fetchUser").mockImplementation(() =>
    Promise.resolve({
      status: "SUCCESS",
      username: mockUsername
    })
  );
  const { getByText } = render(<App />);
  return waitForElement(() => getByText(mockUsername));
});

afterEach(cleanup);

waitForElement is a utility provided by react-testing-library. It returns a promise that resolves when the callback returns truthy value and rejects when time out. This test is pretty self-explanatory. We import the render function from react-testing-library; we render our component, and we wait until we are able to get a dom element with the text "test-user". If we waited too long (default timeout is 4500ms), we know the test is broken.

Simple, clean, right on the point. By the way, do we really care about if the username is inside an h2 or not?

You might wonder how waitForElement is implemented. It returns a promise that resolves when the callback returns a truthy value and rejects when time out. It is not using polling. It uses dom mutation api and only checks the dom when dom changes.

So what about enzyme, before react-testing-library came out, enzyme was pretty much the de facto tool to use for React unit testing (it might still be). Should we now dump enzyme and just use react-testing-library instead?

I don't think so, at least I feel there are still something easier to do in enzyme than in react-testing-library. For example, if we have a container component that each of the children does ajax call (although it might be better to have the container taking care of all the ajax call and children be just dumb components, it's not hard to find an example in production code that is not following this guideline). The test for the container component doesn't really care about the detail of children. shallow rendering is great in that it automatically mocks the children so we don't have to mock each child. We can do that with react-testing-library as described in the faq. I still think enzyme's approach is simpler.

I think react-testing-library is a great library packed with some very cool ideas. At the time of writing, it is only 5 months old (the first 0.1.0 version was published on May 19, 2018), but it is already getting 123k downloads per month. So what do you think, react-testing-library or enzyme? maybe both?

On blogging
Can CSS Modules solve all the problems?