| name | react-testing |
| description | Write and review React/TypeScript tests for Sentry's frontend using Jest and React Testing Library. Use when adding or editing tests in static/ (*.spec.tsx), writing component/hook tests, mocking API responses with MockApiClient, testing routing or network requests, or when asked to "write a frontend test", "add a React test", "test this component", or "fix a flaky RTL test". |
React Testing Guidelines
Testing Philosophy
- User-centric testing: Write tests that resemble how users interact with the app.
- Avoid implementation details: Focus on behavior, not internal component structure.
- Do not share state between tests: Behavior should not be influenced by other tests in the test suite.
Imports
Always import from sentry-test/reactTestingLibrary, not directly from @testing-library/react:
import {
render,
screen,
userEvent,
waitFor,
within,
} from 'sentry-test/reactTestingLibrary';
Query Priority (in order of preference)
-
getByRole - Primary selector for most elements
screen.getByRole('button', {name: 'Save'});
screen.getByRole('textbox', {name: 'Search'});
-
getByLabelText/getByPlaceholderText - For form elements
screen.getByLabelText('Email Address');
screen.getByPlaceholderText('Enter Search Term');
-
getByText - For non-interactive elements
screen.getByText('Error Message');
-
getByTestId - Last resort only
screen.getByTestId('custom-component');
Best Practices
Avoid mocking hooks, functions, or components
Do not use jest.mocked().
jest.mocked(useDataFetchingHook)
MockApiClient.addMockResponse({
url: '/data/',
body: DataFixture(),
})
jest.mocked(useOrganization)
render(<Component />, {organization: OrganizationFixture({...})})
jest.mocked(useLocation)
render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: "/foo/",
},
},
});
jest.mocked(usePageFilters)
PageFiltersStore.onInitializeUrlState(
PageFiltersFixture({ projects: [1]}),
)
renderHook(useNavigate, {
wrapper: (children) => (<AllTheProviders>{children}</AllTheProviders>),
})
(useNavigate)
Use fixtures
Sentry fixtures are located in tests/js/fixtures/ while GetSentry fixtures are located in tests/js/getsentry-test/fixtures/.
import type {Project} from 'sentry/types/project';
const project: Project = {...}
import {ProjectFixture} from 'sentry-fixture/project';
const project = ProjectFixture(partialProject)
Use screen instead of destructuring
const {getByRole} = render(<Component />);
render(<Component />);
const button = screen.getByRole('button');
Query selection guidelines
- Use
getBy... for elements that should exist
- Use
queryBy... ONLY when checking for non-existence
- Use
await findBy... when waiting for elements to appear
expect(screen.queryByRole('alert')).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
Async testing
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.getByRole('alert'));
Avoid waiting for loading indicators
Do not use findBy with .not.toBeInTheDocument() for loading indicators. findBy will error if the element is not found, but we're asserting it should NOT exist. Loading indicators are also flakey since they appear on screen for only a few ticks.
expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole('button', {name: 'Submit'})).toBeInTheDocument();
});
expect(await screen.findByRole('button', {name: 'Submit'})).toBeInTheDocument();
User interactions
fireEvent.change(input, {target: {value: 'text'}});
await userEvent.click(input);
await userEvent.keyboard('text');
Testing routing
const {router} = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/foo/',
query: {page: '1'},
},
},
});
expect(router.location.pathname).toBe('/foo');
expect(router.location.query.page).toBe('1');
await userEvent.click(screen.getByRole('link', {name: 'Go to /bar/'}));
expect(router.location.pathname).toBe('/bar/');
router.navigate('/new/path/');
router.navigate(-1);
If the component uses useParams(), the route property can be used:
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();
Testing components that make network requests
MockApiClient.addMockResponse({
url: '/projects/',
body: [{id: 1, name: 'my project'}],
});
MockApiClient.addMockResponse({
url: '/projects/',
method: 'POST',
body: {id: 1, name: 'my project'},
});
MockApiClient.addMockResponse({
url: '/projects/',
method: 'POST',
body: {id: 2, name: 'other'},
match: [
MockApiClient.matchQuery({param: '1'}),
MockApiClient.matchData({name: 'other'}),
],
});
MockApiClient.addMockResponse({
url: '/projects/',
body: {
detail: 'Internal Error',
},
statusCode: 500,
});
Always Await Async Assertions
Network requests are asynchronous. Always use findBy queries or properly await assertions:
expect(screen.getByText('Loaded Data')).toBeInTheDocument();
expect(await screen.findByText('Loaded Data')).toBeInTheDocument();
Handle Refetches in Mutations
When testing mutations that trigger data refetches, update mocks before the refetch occurs:
it('adds item and updates list', async () => {
MockApiClient.addMockResponse({
url: '/items/',
body: [],
});
const createRequest = MockApiClient.addMockResponse({
url: '/items/',
method: 'POST',
body: {id: 1, name: 'New Item'},
});
render(<ItemList />);
await userEvent.click(screen.getByRole('button', {name: 'Add Item'}));
MockApiClient.addMockResponse({
url: '/items/',
body: [{id: 1, name: 'New Item'}],
});
await waitFor(() => expect(createRequest).toHaveBeenCalled());
expect(await screen.findByText('New Item')).toBeInTheDocument();
});