Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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".
.claude/skills/getsentry-react-testing/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flashlowest | 94% | 54 |
| gemini-3.1-pro-preview | 100% | 2 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 36% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 47% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 27% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 84% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 76% | 0% |
Always import from sentry-test/reactTestingLibrary, not directly from @testing-library/react:
tsximport { render, screen, userEvent, waitFor, within, } from 'sentry-test/reactTestingLibrary';
getByRole - Primary selector for most elementstsx screen.getByRole('button', {name: 'Save'}); screen.getByRole('textbox', {name: 'Search'});
getByLabelText/getByPlaceholderText - For form elementstsx screen.getByLabelText('Email Address'); screen.getByPlaceholderText('Enter Search Term');
getByText - For non-interactive elementstsx screen.getByText('Error Message');
getByTestId - Last resort onlytsx screen.getByTestId('custom-component');
Do not use jest.mocked().
tsx// ❌ Don't mock hooks jest.mocked(useDataFetchingHook) // ✅ Set the response data MockApiClient.addMockResponse({ url: '/data/', body: DataFixture(), }) // ❌ Don't mock contexts jest.mocked(useOrganization) // ✅ Use the provided organization config on render() render(<Component />, {organization: OrganizationFixture({...})}) // ❌ Don't mock router hooks jest.mocked(useLocation) // ✅ Use the provided router config render(<TestComponent />, { initialRouterConfig: { location: { pathname: "/foo/", }, }, }); // ❌ Don't mock page filters hook jest.mocked(usePageFilters) // ✅ Update the corresponding data store with your data PageFiltersStore.onInitializeUrlState( PageFiltersFixture({ projects: [1]}), ) // ❌ Don't recreate the basic context providers renderHook(useNavigate, { wrapper: (children) => (<AllTheProviders>{children}</AllTheProviders>), }) // ✅ Use the provided helpers that mock everything renderHookWithProviders(useNavigate)
Sentry fixtures are located in tests/js/fixtures/ while GetSentry fixtures are located in tests/js/getsentry-test/fixtures/.
tsx// ❌ Don't import type and initialize it import type {Project} from 'sentry/types/project'; const project: Project = {...} // ✅ Import a fixture instead import {ProjectFixture} from 'sentry-fixture/project'; const project = ProjectFixture(partialProject)
screen instead of destructuringtsx// ❌ Don't do this const {getByRole} = render(<Component />); // ✅ Do this render(<Component />); const button = screen.getByRole('button');
getBy... for elements that should existqueryBy... ONLY when checking for non-existenceawait findBy... when waiting for elements to appeartsx// ❌ Wrong expect(screen.queryByRole('alert')).toBeInTheDocument(); // ✅ Correct expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument();
tsx// ❌ Don't use waitFor for appearance await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument(); }); // ✅ Use findBy for appearance expect(await screen.findByRole('alert')).toBeInTheDocument(); // ✅ Use waitForElementToBeRemoved for disappearance await waitForElementToBeRemoved(() => screen.getByRole('alert'));
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.
tsx// ❌ Wrong - findBy errors if element not found, and loading indicators are flakey expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument(); // ✅ Correct - wait for the actual content you care about await waitFor(() => { expect(screen.getByRole('button', {name: 'Submit'})).toBeInTheDocument(); }); // ✅ Also correct - use findBy on the content that appears after loading expect(await screen.findByRole('button', {name: 'Submit'})).toBeInTheDocument();
tsx// ❌ Don't use fireEvent fireEvent.change(input, {target: {value: 'text'}}); // ✅ Use userEvent await userEvent.click(input); await userEvent.keyboard('text');
tsxconst {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 the component uses useParams(), the route property can be used:
tsxfunction 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();
tsx// Simple GET request MockApiClient.addMockResponse({ url: '/projects/', body: [{id: 1, name: 'my project'}], }); // POST request MockApiClient.addMockResponse({ url: '/projects/', method: 'POST', body: {id: 1, name: 'my project'}, }); // Complex matching with query params and request body MockApiClient.addMockResponse({ url: '/projects/', method: 'POST', body: {id: 2, name: 'other'}, match: [ MockApiClient.matchQuery({param: '1'}), MockApiClient.matchData({name: 'other'}), ], }); // Error responses MockApiClient.addMockResponse({ url: '/projects/', body: { detail: 'Internal Error', }, statusCode: 500, });
Network requests are asynchronous. Always use findBy queries or properly await assertions:
tsx// ❌ Wrong - will fail intermittently expect(screen.getByText('Loaded Data')).toBeInTheDocument(); // ✅ Correct - waits for element to appear expect(await screen.findByText('Loaded Data')).toBeInTheDocument();
When testing mutations that trigger data refetches, update mocks before the refetch occurs:
tsxit('adds item and updates list', async () => { // Initial empty state 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'})); // CRITICAL: Override mock before refetch happens MockApiClient.addMockResponse({ url: '/items/', body: [{id: 1, name: 'New Item'}], }); await waitFor(() => expect(createRequest).toHaveBeenCalled()); expect(await screen.findByText('New Item')).toBeInTheDocument(); });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | fail→pass | 14,470 | 9,210 | -36% | 1 | 1 | 0% | 3,005 | 4,098 | +36% | 0 | 0 | — |
case-01 | fail→pass | 20,387 | 14,213 | -30% | 1 | 1 | 0% | 2,982 | 4,380 | +47% | 0 | 0 | — |
case-03 | fail→pass | 15,080 | 13,157 | -13% | 1 | 1 | 0% | 3,226 | 4,109 | +27% | 0 | 0 | — |
case-04 | fail→pass | 9,996 | 7,408 | -26% | 1 | 1 | 0% | 1,707 | 3,147 | +84% | 0 | 0 | — |
case-05 | fail→pass | 12,231 | 6,029 | -51% | 1 | 1 | 0% | 1,823 | 3,211 | +76% | 0 | 0 | — |
case-06 | pass→pass | 9,950 | 5,441 | -45% | 1 | 1 | 0% | 1,694 | 3,104 | +83% | 0 | 0 | — |
case-07 | pass→pass | 11,678 | 5,274 | -55% | 1 | 1 | 0% | 1,475 | 2,950 | +100% | 0 | 0 | — |
case-08 | pass→pass | 8,462 | 9,881 | +17% | 1 | 1 | 0% | 1,686 | 3,556 | +111% | 0 | 0 | — |
case-09 | pass→pass | 12,791 | 9,165 | -28% | 1 | 1 | 0% | 2,297 | 3,772 | +64% | 0 | 0 | — |
case-10 | fail→pass | 9,294 | 6,553 | -29% | 1 | 1 | 0% | 1,674 | 2,968 | +77% | 0 | 0 | — |
case-11 | fail→pass | 7,533 | 7,472 | -1% | 1 | 1 | 0% | 1,123 | 3,186 | +184% | 0 | 0 | — |
case-12 | fail→pass | 13,940 | 6,835 | -51% | 1 | 1 | 0% | 2,593 | 3,358 | +30% | 0 | 0 | — |
case-13 | fail→pass | 11,555 | 4,103 | -64% | 1 | 1 | 0% | 1,605 | 2,740 | +71% | 0 | 0 | — |
case-14 | pass→fail | 14,775 | 11,714 | -21% | 1 | 1 | 0% | 2,715 | 4,011 | +48% | 0 | 0 | — |
case-15 | fail→pass | 19,322 | 8,586 | -56% | 1 | 1 | 0% | 2,642 | 3,416 | +29% | 0 | 0 | — |
case-16 | pass→pass | 9,135 | 4,135 | -55% | 1 | 1 | 0% | 1,362 | 2,756 | +102% | 0 | 0 | — |
case-17 | fail→pass | 11,987 | 5,589 | -53% | 1 | 1 | 0% | 2,127 | 2,918 | +37% | 0 | 0 | — |
case-18 | pass→pass | 7,347 | 3,413 | -54% | 1 | 1 | 0% | 1,276 | 2,691 | +111% | 0 | 0 | — |
case-19 | pass→pass | 6,374 | 3,812 | -40% | 1 | 1 | 0% | 900 | 2,795 | +211% | 0 | 0 | — |
case-20 | pass→pass | 3,868 | 2,539 | -34% | 1 | 1 | 0% | 631 | 2,363 | +274% | 0 | 0 | — |
case-21 | fail→pass | 9,447 | 5,536 | -41% | 1 | 1 | 0% | 1,726 | 2,859 | +66% | 0 | 0 | — |
case-22 | pass→pass | 7,383 | 3,883 | -47% | 1 | 1 | 0% | 1,117 | 2,631 | +136% | 0 | 0 | — |
case-23 | pass→pass | 11,659 | 7,266 | -38% | 1 | 1 | 0% | 1,793 | 3,370 | +88% | 0 | 0 | — |
case-24 | pass→fail | 19,410 | 11,314 | -42% | 1 | 1 | 0% | 2,677 | 3,913 | +46% | 0 | 0 | — |
case-25 | pass→pass | 13,723 | 7,982 | -42% | 1 | 1 | 0% | 1,984 | 3,617 | +82% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +40 percentage points is the difference between those two pass rates over the 25 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.