Using React Testing Library
Our tests are written using React Testing Library. In this guide, you'll find tips to follow best practices and avoid common pitfalls.
We have two ESLint rules in place to help with this:
We strive to write tests in a way that closely resembles how our application is used.
Rather than dealing with instances of rendered components, we query the DOM in the same way the user would. We find form elements by their label text (just like a user would), we find links and buttons from their text (like a user would).
As a part of this goal, we avoid testing implementation details so refactors (changes to implementation but not functionality) don't break the tests.
We are generally in favor of Use Case Coverage over Code Coverage.
By default, render() has some default contexts setup for you. The organization context is set to OrganizationFixture() and the router context has some default params like orgId and projectId. These can be overridden by passing a property to the 2nd argument of the render() function.
Example of overriding the default organization features and access:
import { OrganizationFixture } from "sentry-fixture/organization";
import { render, screen } from "sentry-test/reactTestingLibrary";
const organization = OrganizationFixture({
access: ["org:admin"],
features: ["my-feature-flag"],
});
// useOrganization will now get the above organization
render(<Example />, { organization });
If your component needs more context, pass an additionalWrapper to render() to compose extra providers inside our default test providers.
import { render, screen } from "sentry-test/reactTestingLibrary";
function MyExtraProviders({ children }: { children: React.ReactNode }) {
return (
<SomeContext.Provider value={{}}>{children}</SomeContext.Provider>
);
}
render(<Example />, { additionalWrapper: MyExtraProviders });
expect(screen.getByText(/example/i)).toBeInTheDocument();
When using render(), an in-memory router is used, which will react to navigations with useNavigate() or interactions with Link components. If your component relies on the URL, you can define the initial state in initialRouterConfig. You can access the current router state by referencing the returned router class, as well as navigate programmatically with router.navigate().
const { router } = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: "/foo/",
query: { page: "1" },
},
},
});
// Uses passes in config to set initial location
expect(router.location.pathname).toBe("/foo");
expect(router.location.query.page).toBe("1");
// Clicking links goes to the correct location
await userEvent.click(screen.getByRole("link", { name: "Go to /bar/" }));
// Can check current route on the returned router
expect(router.location.pathname).toBe("/bar/");
// Can test manual route changes with router.navigate
router.navigate("/new/path/");
router.navigate(-1); // Simulates clicking the back button
If you need to test route param values (as in useParams()), the route will need to be provided in the config:
function TestComponent() {
const { id } = useParams();
return <div>{id}</div>;
}
const { router } = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: "/foo/123/",
},
route: "/foo/:id/",
},
});
expect(screen.getByText("123")).toBeInTheDocument();
If you need to test outlet param values (as in useOutletContext()), you can pass them inside the initialRouterConfig object.
function TestComponent() {
const { id } = useOutletContext();
return <div>{id}</div>;
}
const { router } = render(<TestComponent />, {
initialRouterConfig: {
outletContext: { id: "123" },
},
});
expect(screen.getByText("123")).toBeInTheDocument();
If directly setting outlet context isn't enough, instead you want to render some nested routes you can do that too!
Suppose routes.tsx defines some nested routes like:
{
path: 'settings/',
component: SettingsWrapper,
children: [
{index: true, component: SettingsIndex},
{path: ':projectId/', component: ProjectSettings},
]
}
We can configure the in-memory router to know about just the route tree that you need. Remember to render the wrapper component itself, and the router + route child components will render themselves.
// settingsWrapper.tsx
interface OutletContext {
name: string;
}
export function useCustomOutletContext() {
return useOutletContext<OutletContext>();
}
export default function SettingsWrapper() {
const context: OutletContext = {name: "Default"};
return <Outlet context={context} />;
}
// settingsIndex.tsx
function SettingsIndex() {
const {name} = useCustomOutletContext();
return <div>Settings > {name}</div>;
}
// projectSettings.tsx
function ProjectSettings() {
const {name} = useCustomOutletContext();
const {projectId} = useParams();
return <div>Settings > {name} > Project: {projectId}</div>;
}
// settingsIndex.spec.tsx
render(<SettingsWrapper />, {
initialRouterConfig: {
location: {
pathname: '/settings/',
},
route: '/settings/',
children: [
{
index: true,
element: <SettingsIndex />,
},
],
},
});
expect(screen.getByText('Settings > Default')).toBeInTheDocument();
// projectSettings.spec.tsx
render(<SettingsWrapper />, {
initialRouterConfig: {
location: {
pathname: '/settings/123/',
},
route: '/settings/:projectId',
children: [
{
path: ':projectId/',
element: <ProjectSettings />,
},
],
},
});
expect(screen.getByText('Settings > Default > Project: 123')).toBeInTheDocument();
- use
getBy...as much as possible - use
queryBy...only when checking for non-existence - use
await findBy...only when expecting an element to appear after a change to the DOM that might not happen immediately
To ensure that the tests resemble how users interact with our code we recommend the following priority for querying:
getByRole- This should be the go-to selector for almost everything. As a nice bonus with this selector, we make sure that our app is accessible. It will most likely be used together with the name optiongetByRole('button', {name: /save/i}). The name is usually the label of a form element or the text content of a button, or the value of the aria-label attribute. If unsure, use the logRoles feature or consult the list of available roles.getByLabelText/getByPlaceholderText- Users find form elements using label text, therefore this option is preferred when testing forms.getByText- Outside of forms, text content is the main way users find elements. This method can be used to find non-interactive elements (like divs, spans, and paragraphs).getByTestId- As this does not reflect how users interact with the app, it is only recommended for cases where you can't use any other selector
If you still have trouble deciding which query to use, check out testing-playground.com together with the screen.logTestingPlaygroundURL() and their browser extension.
Do not forget, that you can drop screen.debug() anywhere in your test to see the current DOM.
Read more about queries in the official docs.
Avoid destructuring query functions from render method, use screen instead (examples). You won't have to keep the render call destructure up-to-date as you add/remove the queries you need. You only need to type screen and let your editor's autocomplete take care of the rest.
import { render, screen } from "sentry-test/reactTestingLibrary";
// ❌
const { getByRole } = render(<Example />);
const errorMessageNode = getByRole("alert");
// ✅
render(<Example />);
const errorMessageNode = screen.getByRole("alert");
Avoid using queryBy... for anything except checking for non-existence (examples). The getBy... and findBy... variants will throw a more helpful error message if no element is found.
import { render, screen } from "sentry-test/reactTestingLibrary";
// ❌
render(<Example />);
expect(screen.queryByRole("alert")).toBeInTheDocument();
// ✅
render(<Example />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
Avoid using waitFor for waiting on appearance, use findBy... instead (examples). These two are basically equivalent (findBy... even uses waitFor under the hood), but the findBy... is simpler and the error message we get will be better.
import { render, screen, waitFor } from "sentry-test/reactTestingLibrary";
// ❌
render(<Example />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// ✅
render(<Example />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
Avoid using waitFor for waiting on disappearance, use waitForElementToBeRemoved instead (examples). The latter uses MutationObserver which is more efficient than polling the DOM at regular intervals with waitFor.
import {
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from "sentry-test/reactTestingLibrary";
// ❌
render(<Example />);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
// ✅
render(<Example />);
await waitForElementToBeRemoved(() => screen.getByRole("alert"));
Prefer using jest-dom assertions (examples). The advantages of using these recommended assertions are better error messages, overall semantics, consistency, and uniformity.
import { render, screen } from "sentry-test/reactTestingLibrary";
// ❌
render(<Example />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toEqual("abc");
expect(screen.queryByRole("button")).toBeFalsy();
expect(screen.queryByRole("button")).toBeNull();
// ✅
render(<Example />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("abc");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
Prefer using case insensitive regex when searching by text. It will make the tests slightly more resilient to change.
import { render, screen } from "sentry-test/reactTestingLibrary";
// ❌
render(<Example />);
expect(screen.getByText("Hello World")).toBeInTheDocument();
// ✅
render(<Example />);
expect(screen.getByText(/hello world/i)).toBeInTheDocument();
Use userEvent over fireEvent where possible. userEvent comes from the package @testing-library/user-event which is built on top of fireEvent, but it provides several methods that resemble the user interactions more closely.
// ❌
import {
render,
screen,
fireEvent,
} from "sentry-test/reactTestingLibrary";
render(<Example />);
fireEvent.change(screen.getByLabelText("Search by name"), {
target: { value: "sentry" },
});
// ✅
import {
render,
screen,
userEvent,
} from "sentry-test/reactTestingLibrary";
render(<Example />);
userEvent.type(screen.getByLabelText("Search by name"), "sentry");
Use renderHookWithProviders() to test hooks with the same built-in providers as render() (organization, theme, query client, and an in-memory router). It returns the regular renderHook result plus a router helper you can use to inspect location and navigate.
Set the initial URL and define route patterns via initialRouterConfig (use route for a single route or routes for multiple):
import { useParams } from "react-router-dom";
import {
renderHookWithProviders,
waitFor,
} from "sentry-test/reactTestingLibrary";
function useRouteId() {
const { id } = useParams();
return id;
}
const { result, router } = renderHookWithProviders(useRouteId, {
initialRouterConfig: {
location: { pathname: "/foo/123/" },
route: "/foo/:id/",
},
});
expect(result.current).toBe("123");
// Navigate programmatically
router.navigate("/foo/456/");
await waitFor(() => expect(result.current).toBe("456"));
You can also provide additional providers or override the default organization via options:
import { OrganizationFixture } from "sentry-fixture/organization";
import { renderHookWithProviders } from "sentry-test/reactTestingLibrary";
function useFeatureFlag() {
// implementation under test
}
function MyExtraProviders({ children }: { children: React.ReactNode }) {
return (
<SomeContext.Provider value={{}}>{children}</SomeContext.Provider>
);
}
const organization = OrganizationFixture({
features: ["my-feature-flag"],
});
const { result } = renderHookWithProviders(useFeatureFlag, {
organization,
additionalWrapper: MyExtraProviders,
});
Our documentation is open source and available on GitHub. Your contributions are welcome, whether fixing a typo (drat!) or suggesting an update ("yeah, this would be better").