Unit Testing a React Application

Testing is a fundamental practice of creating a robust application. In this article we dive into unit testing a React application using testing library, jest and typescript. Should work with vitest as well. We will be using mocks to verify the interactions between a unit, and stubs for making sure we pass in the right data to our components. Testing hooks, context providers and data fetching will also be covered.

Testing library is a great tool for testing user interfaces. The philosophy behind testing library resembles how an end-user would use the application. Meaning that the way we test the UI, is very similar to how a user using a mouse, keyboard or assistive technoligies when interacts with the application. Its selectors are based of "accessible first" queries. With testing library, you can fire user interaction events such as writing text to an input element, or clicking on buttons for example.

When writing unit tests one should avoid testing the child components, internal state, or internal methods of a component. Instead the focus is on the user would interact with it.

Lets imagine we want to create a movie review site, were users can find and search for popular movies, and users can add their own reviews and ratings to them. We will implement a basic React application and focus on testing it. By the end, you should have the foundation to start testing your React application. Source code is provided in this github repo. Here is it how it looks:

React Testing

It includes the following:

  • It renders a list of trending movies.
  • If the user starts searching, movies matching the query will be displayed instead.
  • Clicking on a movie takes you to the movie page.
  • Users can leave their review of the movie.

Testing a React Component

Lets start with rending a list of trending movies.

export default function TrendingMovies() {
  const { movies } = useMovies();
  const navigate = useNavigate();

  const onSelectMovie = (id: number) => {
    navigate(`/movies/${id}`);
  };

  if (movies.length === 0) {
    return (
      <div className="App">
        <p>No movies available</p>
      </div>
    );
  }

  return (
    <div className="App">
      <h1>Trending Movies</h1>
      {movies.map((movie) => (
        <MovieItem
          key={movie.id}
          movie={movie}
          onSelectMovie={() => onSelectMovie(movie.id)}
        />
      ))}
    </div>
  );
}

Its a rather trivial component. It fetches the movies from a hook called useMovies, and renders a list of MovieItem components. If no movies exists an empty state message will be shown instead. Clicking on a movie calls the navigate callback, which will redirect the user to another route. Pretty simple.

What we want to cover in a test, is all of the expected scenarios that can occur in this component:

  • Render the empty state message if no movies exists
  • Render a list of MovieItem components if 1 or more movies exists.
  • Clicking a button should call the navigate function.

First thing we should do is to make it easy for us to control the dependencies here (useMovies and useNavigate). Let's change how we inject these two hooks, such that we can easily mock these two in our tests.

import { useNavigate as useNavigateImport } from "react-router-dom";
import { useMovies as useMoviesImport } from "./useMovies";

export type TrendingMoviesProps = {
  useMovies?: typeof useMoviesImport;
  useNavigate?: typeof useNavigateImport;
};

export default function TrendingMovies({
  useMovies = useMoviesImport,
  useNavigate = useNavigateImport,
}: TrendingMoviesProps) {
  const { movies } = useMovies();
  const navigate = useNavigate();
  ...
}

Now we made use of dependency injection.

Dependency injection is a means of passing in a dependency via the parameters, instead of letting the function or class create them internally. Several benefits comes from separating the creation of the dependency from the internal function. You can easily pass in a different configuration (given that it adheres to the type), which makes it more flexible. Another benefit is that it makes it easier to create unit tests for the component. As you can simply pass in a mock into a function, and assert that the component itself interacts as expected with the mock.

In our test, we can now easily mock these two hooks, in order to pass in whatever we like. The prop TrendingMoviesProps defines the two hooks, but both are optional, and we simply set the default values in the props.

Let's look at a test.

describe('TrendingMovies component', () => {
  it('renders an empty state', () => {
    const useNavigateMock = () => jest.fn();
    const useMoviesMock = jest.fn().mockReturnValue({ movies: [] });

    const screen = render(
      <TrendingMovies
        useNavigate={useNavigateMock}
        useMovies={useMoviesMock}
      />,
    );

    expect(screen.getByText('No movies available')).toBeDefined();
  });
});

Here the describe block tells us what we are testing, and it statements describes a specific test case. We define the hooks as mocks using jest.fn and pass in as props. Notice how the useMoviesMock contains an empty list of movies. And now we can make an assertion that the No movies available string is rendered. Simple as that.

Let's take another example. What about rendering a list of movies.

export const moviesStub: Movie[] = [
  {
    adult: false,
    backdrop_path: "/uhUO7vQQKvCTfQWubOt5MAKokbL.jpg",
    id: 693134,
    title: "Dune: Part Two",
    original_language: "en",
    original_title: "Dune: Part Two",
    overview:
      "Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the known universe, Paul endeavors to prevent a terrible future only he can foresee.",
    poster_path: "/8b8R8l88Qje9dn9OE8PY05Nxl1X.jpg",
    media_type: "movie",
    genre_ids: [878, 12],
    popularity: 427.28,
    release_date: "2024-02-27",
    video: false,
    vote_average: 8.6,
    vote_count: 13,
  },
  ...
];

describe("TrendingMovies component", () => {
  ...
  it("renders a list of movies", () => {
    const useNavigateMock = () => jest.fn();
    const useMoviesMock = jest.fn().mockReturnValue({ movies: moviesStub });

    const screen = render(
      <TrendingMovies useNavigate={useNavigateMock} useMovies={useMoviesMock} />
    );

    moviesStub.forEach(({ title }) => {
      expect(screen.getByRole("heading", { name: title })).toBeDefined();
      expect(
        screen.getByRole("img", { name: `Poster from movie: ${title}` })
      ).toBeDefined();
    });
  });
}

Here we pass in a list of movies using the moviesStub, its an array of stubbed movies. These types of stubs should typically go into a separate file, which makes them easy to re-use throughout an application. In the test case we can assert that each movie is rendered by checking that the heading element containing the movie title exists, as well as the movie poster is rendered. Notice that we use the getByRole here. Testing library has many different queries, here we use getByRole to get the heading and image element.

Queries allow us to find elements being rendering in our application. There exists queries such as getByRole which finds an element based on the aria role the element has, getByText which will get an element based on the text content, getByLabelText is good for finding form fields which has a corresponding label to them. Some quirks exists for all of these, for instance if the getBy method fails to find an element, it will throw an error and your test will fail. But for example queryByRole will return a null value when it couldn't find an element. This can be useful when you want to assert that an element shouldn't be rendered in a test case. Read the documentation for more information

Remember how clicking on a movie should navigate the user to the movie route. Let's add a case for that as well.

describe("TrendingMovies component", () => {
  ...
  it("should navigate when selecting a movie", () => {
    const navigateMock = jest.fn();
    const useNavigateMock = () => navigateMock;
    const useMoviesMock = jest.fn().mockReturnValue({ movies });
    const screen = render(
      <TrendingMovies useNavigate={useNavigateMock} useMovies={useMoviesMock} />
    );

    fireEvent.click(screen.getAllByRole("button", { name: "Read more" })[0]);
    expect(navigateMock).toHaveBeenCalledWith("/movies/1");
  });
}

Here we setup the navigateMock to represent the callback that gets called when selecting a movie. After rendering the component we run the command fireEvent.click which will fire a click event, mimicking how a user would click on an element.

User actions let's us fire user interaction events, such a clicking on an element, or changing the input field like this:

fireEvent.change(input, { target: { value: 'Hello world' } });

Think of all the ways a user could interact with their mouse, keyboard, screen reader etc. These are all covered with the different fireEvent methods.

The element being clicked on is a button containing the Read more text. Notice we use the getAllByRole here, we do this because there are now multiple buttons with this text, since we have several movies in our list. We get the first button in the list and click on it, and assert the navigateMock has been called with the expected string, ie the route we expect to be redirected to.

ğŸŽ‰ Now we have tested our component.

✅ We have mocked dependencies using dependency injection. More on this topic further in the article. ✅ We have asserted that different content gets rendered depending on the size of the movies array. ✅ We have asserted that the navigate function is called when clicking on the read more button.

Let's take another component. In this component we will let the user leave a review by selecting a 1-5 star rating, and write a plain text review as you saw in the demo earlier.

import { useState } from 'react';
import { Link, useParams as useParamsImport } from 'react-router-dom';
import { useMovies as useMoviesImport } from '../hooks/useMovies';

export const getRatingMessage = (rating: number) => {
  switch (rating) {
    case 1:
      return 'bad';
    case 2:
      return 'mediocre';
    case 3:
      return 'ok';
    case 4:
      return 'good';
    case 5:
      return 'amazing';
    default:
      return '';
  }
};

export type MovieProps = {
  useMovies?: typeof useMoviesImport;
  useParams?: typeof useParamsImport;
};

function Movie({
  useMovies = useMoviesImport,
  useParams = useParamsImport,
}: MovieProps) {
  const { movies } = useMovies();
  let { movieId } = useParams();
  const movie = movies.find((m) => movieId && m.id === Number(movieId));

  const stars = Array.from(Array(5).keys());

  const [hoverRating, setHoverRating] = useState(-1);
  const [rating, setRating] = useState<number>();
  const [hasSubmitted, setHasSubmitted] = useState(false);

  const [reviewText, setReviewText] = useState('');
  const onChangeReviewText: React.ChangeEventHandler<HTMLTextAreaElement> = (
    e,
  ) => {
    setReviewText(e.currentTarget.value);
  };

  const onSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    setHasSubmitted(true);
  };

  return (
    <div className="App">
      <Link to={'/'}>Back</Link>
      <div>
        <h1>{movie?.title}</h1>
        <div className="movieDetails">
          <img
            className="moviePoster"
            src={`https://image.tmdb.org/t/p/w500/${movie?.poster_path}`}
          />
          <div className="movieContent">
            <p>{movie?.overview}</p>

            <form onSubmit={onSubmit}>
              <div className="movieRating">
                {stars.map((s, i) => (
                  <button
                    onClick={() => setRating(i + 1)}
                    key={'rating-' + i}
                    onMouseOver={() => setHoverRating(i + 1)}
                    onMouseLeave={() => setHoverRating(-1)}
                    className={i < (rating || hoverRating) ? 'active' : ''}
                    type="button"
                  >
                    ⭐
                  </button>
                ))}
              </div>
              <p>{getRatingMessage(rating || hoverRating)}</p>
              <textarea
                className="textArea"
                onChange={onChangeReviewText}
                placeholder="Write a review of the movie..."
                value={reviewText}
              ></textarea>
              <br />
              <button className={'submitReview'} type="submit">
                Submit review
              </button>
              <p>{hasSubmitted && 'Thanks for your review ğŸŽ‰'}</p>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
}

This component is a bit longer. But it essentially lets the user see some movie details, and the user can leave a review. We created a function that takes a numerical rating and returns a corresponding message. In our test we want to cover that we render the content properly. And another test for submitting a review. But lets start with testing that the function that returns a text message.

describe('getRatingMessage', () => {
  const testCases: [number, string][] = [
    [1, 'bad'],
    [2, 'mediocre'],
    [3, 'ok'],
    [4, 'good'],
    [5, 'amazing'],
    [6, ''],
    [0, ''],
  ];

  testCases.map(([value, expectedText]) => {
    it(`should render ${expectedText} when given ${value}`, () => {
      const response = getRatingMessage(value);
      expect(response).toEqual(expectedText);
    });
  });
});

Here we cover all the scenarios that can happen in that function. We map over a list of input values, and the expected messages that will be returned from the function. Then we simply test that all scenarios work as expected.

Next up, lets look at testing the movie content.

describe("Movie", () => {
  it("should render the movie and its content", () => {
    const movie = moviesStub[0];
    const useMoviesMock = jest.fn().mockReturnValue({ movies: moviesStub });
    const useParamsMock = jest.fn().mockReturnValue({ movieId: movie.id });

    const screen = renderWrapper(
      <Movie useMovies={useMoviesMock} useParams={useParamsMock} />
    );
    expect(screen.getByRole("heading", { name: movie.title }));
    expect(screen.getByText(movie.overview)).toBeDefined();
    expect(screen.getByRole("img").getAttribute("src")).toEqual(
      `https://image.tmdb.org/t/p/w500/${movie.poster_path}`
    );
  });
...
})

Here we make sure that the first item from the moviesStub array is the one being rendered. And we simply verify that the heading, overview and poster image is being rendered using the getByRole query.

Lets tackle the form now.

describe("Movie", () => {
  ...

  it("should fill in a complete review", () => {
    const movie = moviesStub[0];
    const useMoviesMock = jest.fn().mockReturnValue({ movies: moviesStub });
    const useParamsMock = jest.fn().mockReturnValue({ movieId: movie.id });

    const screen = renderWrapper(
      <Movie useMovies={useMoviesMock} useParams={useParamsMock} />
    );
    fireEvent.click(screen.getAllByRole("button", { name: "⭐" })[4]);
    fireEvent.change(screen.getByRole("textbox"), {
      target: { value: "My review" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Submit review" }));
    expect(screen.getByText("Thanks for your review ğŸŽ‰")).toBeDefined();
  })
});

Here we click on the star button, to select the "five star" review, and we fill in the text area with some text and finally submit the review. We can then confirm that the success message has been shown.

Now we have gone trough some examples of testing this application. There are more tests covered in this github repo that you can take a look at.

Testing library queries

The testing guide describes the order of priority one should take into consideration when choosing a query. Lets cover them briefly:

Queries accessible by all users

  1. getByRole is the prefered query, as it queries elements based on the accessability tree.
  2. getByLabelText is next prefered query. Its useful when testing form fields, as it can find the field based on its label.
  3. getByPlaceholderText works when you don't have a label associated with your field.
  4. getByText can be used for elements like divs, spans and paragraphs as these ones have no role associated with them.

Semantic queries

  1. getByAltText finds an element by their alt-text.
  2. getByTitle finds an element by their title. (I have never seen this one being used)

Test IDs

  1. getByTestId can be used when attaching a data-testid="my-custom-id" to an element. These are not recommended to use since neither a screen reader user, or a screen user can see these. Use these only when its too difficult to make use of text or roles to find an element.

You can play around with some these at testing-playground.com.

Let's look at some examples

<h1>My cool website</h1>
screen.getByRole('heading', { level: 1 });

Here we can get the heading by using the getByRole. This works since we only have 1 heading with level 1. If we instead want to get a specific heading, we might use a different option. For example:

<h2>React</h2>
<h2>Angular</h2>
screen.getByRole('heading', { name: 'React' });

Mocking interactions

Mocking the interactions between component is important. You generally don't want your unit tests to test their child components. As this will lead to painful situations where you end up breaking a test several layers above the component you are currently changing. Instead we aim to test the contract between two units. As long as the contract is upheld, it shouldn't matter what the child component renders, as the concern lies in that component in a unit test.

Let's look at the following image.

React tree

This should resemble how a react application, containing a tree of elements is being renderd. I want you to imagine a scenario where the top-most component test dependends on the component at the bottom. Now if you want to update the child component, you might have to update tests that are completely unrelated to it, since you coupled another unit test to it. Having this issue in your codebase causes friction, as you have to spend time solving these quirks. It can be a pain, and leave your team frustrated with your codebase. This should be avoided as much as possible. This is why is super important to keep your unit tests small, and only cover what its inside that unit, anything the unit is coupled to should be mocked. With that said, sometimes it might be fine that your component renders a child component in your test, but only if it seems unnecessary to mock it. But it should be done with caution.

With that said, we use integration or e2e tests for making sure the interaction between units work as intended.

Mocking react components

Using something like react-mock-component can be a powerful way of making sure the right child component is being rendered without actually rendering it. This might feel like a strange thing to do if you are just getting started with unit testing. But as described above, a unit test shouldn't be focusing on testing the child components, as these should have tests of their own.

In the following example we render either <MoviesByQuery ... /> or <TrendingMovies />. Both of these are imported using dependency injection. Now in our test case we can easily test that right one is being rendered.

interface MoviesProps {
  MoviesByQuery?: React.ComponentType<MoviesByQueryProps>;
  TrendingMovies?: React.ComponentType<TrendingMoviesProps>;
}

const Movies: FunctionComponent<MoviesProps> = ({
  MoviesByQuery = MoviesByQueryImport,
  TrendingMovies = TrendingMoviesImport,
}: MoviesProps) => {
  const [search, setSearch] = useState('');
  const isSearching = search.length > 0;

  return (
    <div className="App">
      <h1>Find movies</h1>
      <div className="search">
        <InputField
          name="Search movie"
          type={'search'}
          placeholder="Star wars"
          value={search}
          setValue={setSearch}
        />
      </div>
      {isSearching ? <MoviesByQuery search={search} /> : <TrendingMovies />}
    </div>
  );
};

Here we can see that the MoviesByQuery is only rendered when we are searching for a movie, otherwise the TrendeingMovies is shown.

describe('Movies', () => {
  it('should render movie content', () => {
    const MoviesByQueryMock = createReactMock<MoviesByQueryProps>();
    const TrendingMoviesMock = createReactMock<TrendingMoviesProps>();
    const screen = render(
      <Movies
        MoviesByQuery={MoviesByQueryMock}
        TrendingMovies={TrendingMoviesMock}
      />,
    );
    expect(screen.getByRole('heading', { name: 'Find movies' })).toBeDefined();
    expect(TrendingMoviesMock.rendered).toBeTruthy();
    expect(MoviesByQueryMock.rendered).toBeFalsy();
  });

  it('should set review for a movie', async () => {
    const MoviesByQueryMock = createReactMock<MoviesByQueryProps>();
    const TrendingMoviesMock = createReactMock<TrendingMoviesProps>();
    const screen = render(
      <Movies
        MoviesByQuery={MoviesByQueryMock}
        TrendingMovies={TrendingMoviesMock}
      />,
    );

    act(() =>
      fireEvent.change(screen.getByLabelText('Search movie'), {
        target: { value: 'Dune' },
      }),
    );
    expect(MoviesByQueryMock.rendered).toBeTruthy();
    expect(MoviesByQueryMock.renderedWith({ search: 'Dune' })).toBeTruthy();
  });
});

Testing context provider

A context provider provides state for child components. We can test these by rendering the context provider, and asserting the state it passes as props works as intended.

export interface MoviesContext {
  movies: Movie[];
}
const defaultValue: MoviesContext = {
  movies: [],
};

const API_TOKEN = 'secret';

export const MoviesContext = createContext<MoviesContext>(defaultValue);

export const MoviesProvider = ({
  children,
  apiService = createApiService(API_TOKEN),
}: {
  children: React.ReactNode;
  apiService?: ApiService;
}) => {
  const [movies, setMovies] = React.useState<Movie[]>([]);

  React.useEffect(() => {
    apiService.getTrendingMovies().then((res) => setMovies(res.results));
  }, []);

  return (
    <MoviesContext.Provider
      value={{
        movies,
      }}
    >
      {children}
    </MoviesContext.Provider>
  );
};

Here is how we could test it:

describe('MoviesProvider', () => {
  it('should fetch movies from apiService', () => {
    const getTrendingMovies = jest.fn().mockResolvedValue(moviesStub);
    const apiServiceFnMock = {
      getTrendingMovies,
    };

    render(
      <MoviesProvider apiService={apiServiceFnMock}>
        <>children</>
      </MoviesProvider>,
    );

    waitFor(() => {
      expect(getTrendingMovies).toHaveBeenCalledTimes(1);
    });
  });
});

Testing react hooks

To test hooks we can use the renderHook function from testing library.

import { useContext } from 'react';
import { MoviesContext } from '../providers/MoviesProvider';

export const useMovies = () => useContext(MoviesContext);
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <MoviesContext.Provider
    value={{
      movies: moviesStub,
    }}
  >
    {children}
  </MoviesContext.Provider>
);

describe('useMovies', () => {
  it('should return a list of movies', () => {
    const hook = renderHook(() => useMovies(), { wrapper });
    expect(hook.result.current.movies).toEqual(moviesStub);
  });
});

Here we are testing that the hook returns the movies set by the context. We had to create a wrapper that sets up the initial context, and set some mock data in the context state. This is a trivial hook, but you can imagine a hook that has more functionality. Let say the hook would have some callbacks, internal state and so on. Then we could cover the functionality with several unit tests.

Testing an api service

When creating unit tests we don't want them to rely on any api service. No network requests should ever be made. But we still wan't to have test coverage for the layer that is responsible for talking to an api. In the following example we have an service which calls the movie api being using in this example.

import { MovieResults } from '../types';

export const ApiEndpoints = {
  popularMovies:
    'https://api.themoviedb.org/3/trending/movie/day?language=en-US',
};

export type ApiService = {
  getTrendingMovies: () => Promise<MovieResults>;
};

export const createApiService = (
  token: string,
  fetchFn = fetch,
): ApiService => {
  const get = async (url: string) => {
    const response = await fetchFn(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        accept: 'application/json',
      },
    });
    return response.json();
  };

  return {
    getTrendingMovies: async () => {
      const data = await get(ApiEndpoints.popularMovies);
      return data;
    },
  };
};

In order to test this implementation. I will mock the fetch function, and make sure the fetch function is in fact called upon when we run the getTrendingMovies function.

import { waitFor } from '@testing-library/react';
import { ApiEndpoints, createApiService } from './ApiService';

describe('ApiService', () => {
  const authToken = 'mock-token';
  it('should call getTrendingMovies', () => {
    const jsonResolve = jest.fn();
    const fetchMock = jest.fn().mockResolvedValue({
      json: jsonResolve,
    });
    const service = createApiService(authToken, fetchMock);

    service.getTrendingMovies();
    expect(fetchMock).toHaveBeenCalledWith(ApiEndpoints.popularMovies, {
      headers: {
        Authorization: `Bearer ${authToken}`,
        accept: 'application/json',
      },
    });
    waitFor(() => {
      expect(jsonResolve).toHaveBeenCalledTimes(1);
    });
  });
});

Great. Now we have coverage for the service interacting with the api.

Wrap up

We covered a lot. Testing react componens, hooks, context providers, stubs, mocking, queries and api services

Unit testing is important for any serious production ready system. Its the foundation of the testing pyramid for a reason. Now you should be equiped with enough tools to get started with unit testing your application.

Happy coding.

← Back to home