Testing Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
Search across all documentation pages
A condensed summary of the 25 most important best practices drawn from every page in this section.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
getByRole → getByLabelText → getByText → getByTestId) so assertions match the accessibility tree; reaching for getByTestId first couples tests to implementation and misses a11y regressions.getBy* throws on zero matches and findBy* retries, so only queryBy* is safe for "should not exist" assertions - using getBy* for absence gives a confusing "not found" error instead of a clean failed assertion.within(listItem) (or a uniquely named aria-label) so you hit exactly the row you mean instead of the first match on the page.userEvent method (v14+) returns a Promise, so forgetting await lets the next assertion run before the event flushes and turns failing tests into silent false-positive passes - always await userEvent.click(button).userEvent.type dispatches the full focus → keydown → keypress → input → keyup sequence, which is what real components and validators listen to; fireEvent.change only sets the value and skips keystroke events that libraries like React Hook Form rely on.next/jest's createJestConfig so SWC transforms, CSS-module mocks, and next.config.js-aware env/alias resolution come for free; put jest-dom matchers in setupFilesAfterSetup (not setupFiles) or expect extensions won't be in scope.@vitejs/plugin-react for JSX, environment: "jsdom", globals: true, an import of @testing-library/jest-dom/vitest in the setup file, and "vitest/globals" in tsconfig.compilerOptions.types - miss any one and JSX or matcher types fail silently.vi.mock() is hoisted above imports, so variables declared in the module body are undefined inside the factory; use vi.hoisted(() => ({ mockPush: vi.fn() })) and reference that shared ref from both the factory and your tests.vi.clearAllMocks() (or jest.clearAllMocks()) in beforeEach; the same applies to vi.restoreAllMocks() for spies and server.resetHandlers() for MSW.findByRole / findByText when you are waiting for an element to appear, and for waitFor only when the assertion is not about an element (e.g., a side-effect call count) - findBy encapsulates retry and gives better error messages.waitFor polls its callback every ~50ms, so putting userEvent.click() or a fetch spy inside it fires the event repeatedly and corrupts state; make the interaction outside waitFor and keep only assertions inside.server.resetHandlers() in afterEach so per-test overrides don't leak into the next test, and keep onUnhandledRequest: "error" so any forgotten endpoint surfaces immediately instead of masking as a mysterious hang.useCartStore.setState({ items: [] }) (or getState().reset()) in beforeEach - otherwise one test's mutations invisibly pre-seed the next.QueryClient with retry: false inside a renderWithProviders helper for every test; a shared client caches responses across tests and hangs suites on failed retries.role="alert" so they are screen-reader-announced and queryable as await screen.findAllByRole("alert"); remember React Hook Form validates on submit by default, so asserting errors before submission finds nothing unless you set mode: "onChange".useActionState cannot actually run a Server Action under jsdom, so mock the hook to return a controlled [state, formAction, isPending] tuple when testing form error/pending UI - testing the real action belongs in a Node/Vitest integration test.renderHook, any synchronous call that triggers a state update needs act(() => result.current.increment()) or you get a warning plus a stale result.current; under fake timers, also advance inside act or pass { shouldAdvanceTime: true }.result.current is a live reference that changes after every render, so const { count } = result.current captures a snapshot that goes stale - always read result.current.count directly on each assertion.render() does not accept a Promise, so test async Server Components by calling them as functions and awaiting the JSX: const ui = await PostList({ id: "1" }); render(ui); - passing the component directly throws.cookies(), headers(), revalidatePath, and revalidateTag throw outside the Next.js request context, so stub them with vi.mock("next/headers", …) and vi.mock("next/cache", …) - also mock notFound() to throw a sentinel error so await expect(fn()).rejects.toThrow() works.page.getByRole, getByLabel, and getByText over CSS selectors and encapsulate them in Page Object classes; CSS selectors like .btn-primary.mt-4 break the instant someone renames a class.expect() assertions auto-retry for up to 5 seconds, so await expect(page).toHaveURL("/dashboard") replaces every page.waitForTimeout(500) - hard-coded sleeps are the #1 source of flaky E2E tests.webServer in playwright.config.ts to start Next.js automatically with reuseExistingServer: !CI, and enable trace: "on-first-retry" so failed runs ship a full DOM/network/console timeline you can open with playwright show-trace.setup project plus storageState: "e2e/.auth/user.json" so every test starts logged in without paying the login cost, and register page.route() mocks before page.goto() - routes set after the navigation miss the initial page load.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥