DEV Community

Brett Fisher
Brett Fisher

Posted on • Originally published at brettfisher.dev

2 Ways to Write Easily Testable React Components

Unit testing my React components is a skill that did not come very easily to me. When working on personal projects, it was so easy to justify not writing unit tests for reasons like the project wasn't very big or I wasn't anticipating having very many users. However, I've recently learned some useful patterns that have made unit testing my components much easier, and now I'm at the point where I even enjoy writing unit tests! These days, I don't write unit tests because I "have to" - I write them because I want to and sleep a lot better at night knowing that my code is protected. I'm going to describe a couple of common patterns that make writing testable components easier.

How I used to write unit tests

Before I understood too much about test-driven development (TDD), I would spend a few days writing a component and then come back to test it. The problem with this approach was that I would write components that were very difficult to test. Let's take a simple example. Here's a component that fetches a list of users from some API and displays them in a table.

import React, { useState, useEffect } from 'react';
import axios from 'axios';

const UserTable = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    axios
      .get('https://jsonplaceholder.typicode.com/users')
      .then(({ data }) => setUsers(data));
  }, []);

  return (
    <div>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Username</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {users.map(({ name, username, email }) => (
            <tr key={username}>
              <td>{name}</td>
              <td>{username}</td>
              <td>{email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default UserTable;

Now let's write a unit test for this component. When writing unit tests, we want to think about what the business logic is. So, what does this component do? We see that it fetches a list of users using axios in the useEffect at the beginning of the functional component, then displays that list of users. Let's write a test that makes sure the component successfully fetches and displays a list of users. Here's what a test might look like:

import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import UserTable from './UserTable';
import axios from 'axios';

describe('UserTable test', () => {
  const mockUsers = [
    {
      name: 'Harry Potter',
      username: 'boywholived',
      email: 'harry@hogwarts.com',
    },
    {
      name: 'Tom Riddle',
      username: 'darklord',
      email: 'voldemort@deatheaters.com',
    },
  ];

  beforeEach(async () => {
    axios.get = jest.fn().mockResolvedValue({ data: mockUsers });
    render(<UserTable />);
    await waitFor(() => expect(axios.get).toHaveBeenCalled());
  });

  test('renders user list', async () => {
    const { getByText } = screen;

    mockUsers.forEach(({ name, username, email }) => {
      expect(getByText(name)).toBeDefined();
      expect(getByText(username)).toBeDefined();
      expect(getByText(email)).toBeDefined();
    });
  });
});

Since we don't want to actually make a network request in our unit test, we first mock out the get function on axios in the beforeEach function. That way, when the component is rendered in our test, axios.get will return our array of mockUsers instead of making a real request. Then in our test, we check that the name, username, and email of each of our mock users is indeed rendered.

This component is pretty straightforward to test, but I find a few problems with it. First of all, there's a decent amount of setup. We have to mock axios and then wait for its get method to be called (even though our mocked get function immediately resolves, it's still asynchronous. We have to wait for the promise to resolve before we can reliably test that the data is rendered). I don't really want to have to worry about the implementation details of the components I'm testing. I could have axios calls in many different components, and then I would have to mock it out in every single one of my test files. What if I decided to start using some other fetching library instead of axios? Then I would have to change all my tests to mock that new library instead. This is all distracting from the purpose of unit tests - to test the business logic of your components.

I've used a couple of different approaches to solving these problems. The first is dependency injection, and the second is using presentational components.

Dependency Injection

One of the problems we mentioned with this component is its hard dependency on axios. That means we have to worry about mocking axios specifically in every component we test that uses it. What if instead of mocking it, we created another component and passed in a fetchUsers function as a prop? Then we wouldn't have to mock anything, we could just pass in our own function in the test file. Here's a new version of the component:

import React, { useState, useEffect } from 'react';
import axios from 'axios';

export const UserTable = ({ fetchUsers }) => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, [fetchUsers]);

  return (
    <div>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Username</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {users.map(({ name, username, email }) => (
            <tr key={username}>
              <td>{name}</td>
              <td>{username}</td>
              <td>{email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

const UserTableContainer = () => {
  const fetchUsers = async () => {
    const { data } = await axios.get(
      'https://jsonplaceholder.typicode.com/users'
    );
    return data;
  };

  return <UserTable fetchUsers={fetchUsers} />;
};

export default UserTableContainer;

Notice that now we have two components. UserTable is a lot like the old component, except it accepts a fetchUsers function as a prop. Notice that in the useEffect, fetchUsers directly assigns users to its resolved value. Compare that with how we previously had to extract data from the axios call. fetchUsers is completely generic - it just has to be a function that directly resolves to the array of users to be displayed.

We also have a UserTableContainer, which does the heavy lifting of passing in the fetchUsers function, which basically just wraps an axios call. Now take a look at our revised unit test:

import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import { UserTable } from './App';

describe('UserTable test', () => {
  const mockUsers = [
    {
      name: 'Harry Potter',
      username: 'boywholived',
      email: 'harry@hogwarts.com',
    },
    {
      name: 'Tom Riddle',
      username: 'darklord',
      email: 'voldemort@deatheaters.com',
    },
  ];

  beforeEach(async () => {
    const fetchUsers = jest.fn().mockResolvedValue(mockUsers);
    render(<UserTable fetchUsers={fetchUsers} />);
    await waitFor(() => expect(fetchUsers).toHaveBeenCalled());
  });

  test('renders user list', async () => {
    const { getByText } = screen;

    mockUsers.forEach(({ name, username, email }) => {
      expect(getByText(name)).toBeDefined();
      expect(getByText(username)).toBeDefined();
      expect(getByText(email)).toBeDefined();
    });
  });
});

Notice that we are testing UserTable instead of UserTableContainer. That's because UserTable actually contains the logic we want to test - displaying the list of users. This is better because we don't have to mock axios. In fact, we no longer care what fetching library our app uses. Notice that in the previous unit test, we had to mock axios's behavior of resolving to an object containing a data attribute with the fetched data. We had to know about this in our unit test, but now it really doesn't matter how our fetching library behaves. It's UserTableContainer's job to pass in the fetchUsers function, but we don't have to test that because its only job is to provide that function.

This is called dependency injection. UserTable asks for the function it will use to fetch the list of users instead of having a hard dependency on axios, and consequently it's much easier to test.

This is just one solution to the problems we were having earlier. The other solution I'll discuss is called presentational components.

Presentational Components

Presentational components are components that only present data, they have no state. For this example, instead of passing in a fetchUsers function, we could just make a component that accepts users as a prop and displays them. Then we wouldn't have to pass in a fetchUsers function at all in our test, all we would have to do is pass in a mock array of users and make sure that the component renders them. Here's the component rewritten to use this approach:

import React, { useState, useEffect } from 'react';
import axios from 'axios';

export const UserTable = ({ users }) => (
  <div>
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Username</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody>
        {users.map(({ name, username, email }) => (
          <tr key={username}>
            <td>{name}</td>
            <td>{username}</td>
            <td>{email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);

const UserTableContainer = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    axios
      .get('https://jsonplaceholder.typicode.com/users')
      .then(({ data }) => setUsers(data));
  }, []);

  return <UserTable users={users} />;
};

export default UserTableContainer;

Now in our test, we don't even have to wait for anything in our beforeEach function. We can just render UserTable with the mock users and test that everything is displayed properly.

import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import { UserTable } from './App';

describe('UserTable test', () => {
  const mockUsers = [
    {
      name: 'Harry Potter',
      username: 'boywholived',
      email: 'harry@hogwarts.com',
    },
    {
      name: 'Tom Riddle',
      username: 'darklord',
      email: 'voldemort@deatheaters.com',
    },
  ];

  beforeEach(async () => {
    render(<UserTable users={mockUsers} />);
  });

  test('renders user list', async () => {
    const { getByText } = screen;

    mockUsers.forEach(({ name, username, email }) => {
      expect(getByText(name)).toBeDefined();
      expect(getByText(username)).toBeDefined();
      expect(getByText(email)).toBeDefined();
    });
  });
});

Now if that isn't easy to test, I don't know what is!

Conclusion

Jest's mocking feature is incredibly powerful and useful, but personally I try to avoid it whenever possible. I usually find that I can refactor my components to use either dependency injection or presentation components, and then I don't have to worry about the implementation of my dependencies.

So which of these two approaches to improving your tests is better? It depends. For this simple example, I would probably go with a presentational component because I just want to test that it presents the data correctly. But sometimes I want to test a little more than just presentation, and that's when dependency injection comes in handy. For example, I recently wrote a component that uses localStorage. I originally tried mocking it with Jest and it was a huge pain. But after I refactored my component to accept a storageService, I was able to test the component easily. In my app, I passed in localStorage as the storageService, but in my test I passed in an object that looked like localStorage but wouldn't actually try to store anything in the browser.

I've found that it's hard to write a hard list of rules for what approach to use in every instance. As I've spent more time writing components and unit tests, I've developed a feel for what makes the most sense. Unit testing isn't just something you do - it's a way of thinking. Being a good unit tester doesn't mean you figure out clever ways to test every component - a huge part is knowing how to write and refactor your code such that it's easy to test in the first place.

Hopefully this has helped you write easier-to-test components! Good luck!

Discussion (0)