User-Centric Testing using React Testing Library
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.
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 betweenjsdom
andreact-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.