User-Centric Testing using React Testing Library

Jonathan ARNAULT
Jonathan ARNAULTMay 26, 2023
#react#testing#tutorial#integration

During my integration at Marmelab, I was challenged to build the Labyrinth Game in 5 weeks using various technologies, including React and Vite. Since I mostly did backend programming before joining Marmelab, the frontend was definitly the most challenging aspect of this intensive coding session.

One of the aspects of frontend development that I was not used to was User-Centric Testing. While I usually tested the implementation details of my applications to ensure code quality over time, User-Centric Testing focuses on how users will use the application to define tests. This testing practice has been highlighted by Kent C. Dodds in his article Testing Implementation Details: it focuses on component roles in a page (e.g. button, searchbox, listitem...) instead of their implementation (e.g. input[type="submit"], input[type="text"], li...). This helps reduce tests maintenance over time since we only need to update them when User Stories change and not when the implementation details of the User Interface change.

In this tutorial, we will test a simple search engine using User-Centric Testing, with two levels of testing: Unit Testing for the Search Bar and Integration Testing for the whole Search Experience.

Search Bar Screenshot

Setting Up the Project

We will use Vitest and @testing-library/react to setup the test suite.

The project skeleton can be downloaded from this public GitHub repository: marmelab/blog-user-centric-testing.

# Clone repository
git clone https://github.com/marmelab/blog-user-centric-testing && cd blog-user-centric-testing

# Setup node version if you use NVM
nvm use

npm install

To run this, project, we will need two additionals commands:

  • npm run dev that can be used to preview our application in the browser ;
  • npm test to run our tests in watch mode.

Note: This project uses happy-dom to emulate the DOM in tests as there are some incompatibilities between jsdom and react-router-dom.

Using User-Centric Tests APIs

If you are not familiar with testing with Vitest and @testing-library/react, a typical test file looks like the template below. The describe and it functions are used to define our test suite.

import { describe, it, expect, afterEach, vi } from "vitest";
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import matchers from "@testing-library/jest-dom/matchers";
import TestComponent from './TestComponent';

// Setup @testing-library/react environment.
expect.extend(matchers);
afterEach(cleanup);

describe("TestComponent", () => {
  it("Should do something", async () => {
    render(<TestComponent />);
  });
});

Now that we have setup our test suite and rendered our component, we can use the testing utilities provided by @testing-library/react to retrieve our elements in a page. For example, if we want to select an input whose role is searchbox, or a button whose inner text is Search, we will use the following helper:

// Get the searchbox
const searchBox = await screen.findByRole("searchbox");

// Get the button whose inner text is "Search"
const searchButton = await screen.findByRole("button", {
  name: "Search",
});

Once we have selected our HTML element, when can apply assertions on it, using the Jest matchers:

// Ensure that the searchbox is visible by the end user
expect(searchBox).toBeVisible();

We can also fire events on our elements using the fireEvent helper:

// Emulate user input
const query = "John";
fireEvent.change(searchBox, { target: { value: query } });
expect(searchBox).toHaveDisplayValue(query);

Unit Testing the Search Bar Component

Let's put in in practice: we will start by unit testing the <SearchBar> component. The Product Owner gave us the folowing user stories for the search bar component:

  • As a user, I want to see a search element that contains:
    • A search box where I can write my query ;
    • A button that contains the text "Search".
  • As a user, I want to see my current query in the search bar.
  • As a user, I want to execute a search when I click on the search button.

Since the search box component will to be used multiple times in our tests, it is interesting to create a Page Object for that component. A Page Object wraps a page or a fragment of a page with a high-level API to manipulate its elements. In the case of our search component, the API to manipulate the component is the following:

export interface SearchBox {
  // Type a string in the search box
  type(search: string): void;

  // Submit the search form
  submit(): void;

  // Get the search box value
  getValue(): string;
}

We will first write the getSearchBox Page Object in tests/src/Test/SearchBox.ts so that it can be reused in all our pages to test the search bar:

import { fireEvent, screen } from "@testing-library/react";
import { expect } from "vitest";

export interface SearchBox {
  // Type a string in the searchbox
  type(search: string): void;

  // Submit the search form with the given query
  doSearch(search: string): void;

  // Get the search box value
  getValue(): string;
}

export const getSearchBox = async (): Promise<SearchBox> => {
  // We ensure that the search box is present
  const searchBox: HTMLInputElement = await screen.findByRole("searchbox");
  expect(searchBox).toBeVisible();

  // We ensure that the search submit button is present
  const searchButton: HTMLButtonElement = await screen.findByRole("button", {
    name: "Search",
  });
  expect(searchButton).toBeVisible();

  return {
    type(search: string) {
      fireEvent.change(searchBox, { target: { value: search } });
    },
    doSearch(search: string) {
      fireEvent.change(searchBox, { target: { value: search } });
      fireEvent.click(searchButton);
    },
    getValue() {
      return searchBox.value;
    },
  };
};

Then we will use the Page Object that we have written to unit test the <SearchBar> behavior in src/Search/SearchComponents/SearchBar.spec.tsx. For the last user story, we will use vi.fn() to mock the search bar submit callback:

import matchers from "@testing-library/jest-dom/matchers";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getSearchBox } from "../../Test";
import { SearchBar } from "./SearchBar";

// Setup @testing-library/react environment.
expect.extend(matchers);
afterEach(cleanup);

describe("SearchBar", () => {
  it("Should display query in search box if provided", async () => {
    const query = "John";

    render(<SearchBar query={query} />);

    const searchBox = await getSearchBox();
    expect(searchBox.getValue()).toBe(query);
  });

  it("Should support user search query", async () => {
    const query = "John";
    const handleSearch = vi.fn();

    render(<SearchBar query={query} handleSearch={handleSearch} />);

    const searchBox = await getSearchBox();
    searchBox.doSearch(query);

    expect(searchBox.getValue()).toBe(query);
    // We test that the search hadler has been called.
    expect(handleSearch).toHaveBeenCalledWith(query);
  });
});

Finally, now that our test suite reflects what users want, all we have to do is to implement the <SearchBar> component inside src/Search/SearchComponents/SearchBar.tsx to match the defined behavior:

import { Button, Grid, TextField } from "@mui/material";
import { useState } from "react";

type SearchBarQuery = (query: string) => void;

interface SearchBarProps {
  query?: string | null;
  handleSearch?: SearchBarQuery;
}

export const SearchBar = ({
  query: initialQuery,
  handleSearch,
}: SearchBarProps) => {
  const [query, setQuery] = useState(initialQuery ?? "");

  return (
    <Grid container role="search" spacing={2}>
      <Grid item xs={12} md={8}>
        <TextField
          variant="outlined"
          inputProps={{ role: "searchbox" }}
          value={query}
          sx={{ width: "100%" }}
          onChange={(event) => setQuery(event.target.value)}
        />
      </Grid>
      <Grid item xs={12} md={4}>
        <Button
          variant="contained"
          role="button"
          size="large"
          sx={{ height: "100%", width: "100%" }}
          onClick={() => handleSearch && handleSearch(query)}
        >
          Search
        </Button>
      </Grid>
    </Grid>
  );
};

Integration Testing of the Whole Search Experience

Integration Testing differs from Unit Testing as it tests that our components will work together. In the case of the search engine we are implmenting, this means that we will test that when a user inputs a search in the home page, they will see the results in the search results page.

The Product Owner now wants us to implement the last user story for our use case:

  • As a user, I want to see the results when I input a query in the search box and click on the search button.

First, we will implement the home page tests in src/Home/HomePages.spec.tsx and the home page of our application in src/Home/HomePages.tsx:

import matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

// Setup test environment.
expect.extend(matchers);
afterEach(cleanup);

import { getSearchBox, renderApp } from "../Test";

// Setup @testing-library/react environment.
expect.extend(matchers);
afterEach(cleanup);

describe("Home", () => {
  it("Should display search bar", async () => {
    // We render the app, which opens the home page by default.
    renderApp();

    // We ensure that the user sees the search form
    await getSearchBox();
  });
});
import { useNavigate } from "react-router-dom";

import { SearchBar } from "../Search";

export const Home = () => {
  const navigate = useNavigate();

  const handleSearch = (query: string) =>
    navigate(`/search?query=${encodeURI(query)}`);

  return <SearchBar handleSearch={handleSearch} />;
};

Then, as we did for the SearchBox Page Object, we will create a PageResult Page Object to provide an high-level API for search results:

import { screen } from "@testing-library/react";
import { expect } from "vitest";

export interface SearchResult {
  // Get the search results
  getResults(): Promise<string[]>;

  // Get the search error
  getError(): Promise<string>;
}

export const getSearchResults = async (): Promise<SearchResult> => {
  return {
    async getResults() {
      const searchResultItems = await screen.findAllByRole("listitem");
      for (const searchResultItem of searchResultItems) {
        expect(searchResultItem).toBeVisible();
      }
      return searchResultItems.map((listitem) => listitem.textContent!.trim());
    },
    async getError() {
      const searchResultAlert = await screen.findByRole("alert");
      expect(searchResultAlert).toBeVisible();
      return searchResultAlert.textContent!.trim();
    },
  };
};

Then, we can create the test suite for the <SearchResult> page in src/Search/SearchPages.spec.tsx:

import matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { getSearchBox, getSearchResults, renderApp } from "../Test";

// Setup test environment.
expect.extend(matchers);
afterEach(cleanup);

describe("SearchResult", () => {
  it("Should display search results", async () => {
    renderApp();

    const searchBox = await getSearchBox();
    searchBox.doSearch("John");

    const searchResults = await getSearchResults();

    expect(searchResults.getResults()).resolves.toStrictEqual([
      "Nancy Johnson",
      "John Smith",
    ]);
  });

  it("Should display an error if the search query is not valid", async () => {
    renderApp();

    const searchBox = await getSearchBox();
    searchBox.doSearch("a");

    const searchResults = await getSearchResults();
    expect(searchResults.getError()).resolves.toBe(
      "Query must be at least 2 characters long."
    );
  });
});

Finally, we can update the <SearchResult> page in src/Search/SearchPages.tsx to match the behavior defined in the test suite.

import { Alert, List, ListItem } from "@mui/material";
import { Stack } from "@mui/system";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";

import { SearchBar } from "./SearchComponents/SearchBar";
import { useSearchMutation } from "./SearchHooks";

const queryParamKey = "query";

export const SearchResult = () => {
  const searchMutation = useSearchMutation();

  const [searchParams, setSearchParams] = useSearchParams();
  const [query, _] = useState(searchParams.get(queryParamKey));

  const handleSearch = (query: string) => {
    setSearchParams(() => {
      return [[queryParamKey, query]];
    });
  };

  useEffect(() => {
    searchMutation.mutate(searchParams.get(queryParamKey));
  }, [searchParams]);

  return (
    <Stack spacing={2}>
      <SearchBar query={query} handleSearch={handleSearch} />

      {searchMutation.isLoading && (
        <Alert role="status" severity="info">
          Loading
        </Alert>
      )}

      {searchMutation.isError && (
        <Alert role="alert" severity="error">
          {searchMutation.error?.message}
        </Alert>
      )}

      {searchMutation.isSuccess && (
        <List>
          {searchMutation.data.map((person) => (
            <ListItem key={person.id} role="listitem">
              {person.name}
            </ListItem>
          ))}
        </List>
      )}
    </Stack>
  );
};

Conclusion

Following User-Centric Testing eases test maintenance over time since we focus tests on how users will use the application instead of application details. This allows developers to improve or refactor the application without updating to the tests that match the business requirements/user stories. I definitly recommend User-Centric Testing with Vitest and @testing-library/react, since it allows developers to write concise and clear tests and supports TypeScript out of the box.

Did you like this article? Share it!