React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummarytesting

Testing Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. Prefer getByRole Over Test IDs: Walk the Testing Library query priority (getByRolegetByLabelTextgetByTextgetByTestId) so assertions match the accessibility tree; reaching for getByTestId first couples tests to implementation and misses a11y regressions.
  2. queryBy for Absence, getBy for Presence: 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.
  3. Scope Queries With within: When tests render list items that share labels or buttons, wrap queries in within(listItem) (or a uniquely named aria-label) so you hit exactly the row you mean instead of the first match on the page.
  4. Await Every userEvent Call: Every 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).
  5. Prefer userEvent Over fireEvent: 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.
  6. Wrap Jest With createJestConfig: Export your Jest config through 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.
  7. Configure Vitest Explicitly: Vitest needs @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.
  8. Use vi.hoisted for Mock Factory Refs: 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.
  9. Clear Mocks Between Tests: Mock return values and call history leak across tests in the same file, so call vi.clearAllMocks() (or jest.clearAllMocks()) in beforeEach; the same applies to vi.restoreAllMocks() for spies and server.resetHandlers() for MSW.
  10. findBy for Appearance, waitFor for State: Reach for 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.
  11. No Side Effects in waitFor: 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.
  12. Reset MSW Between Tests: Call 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.
  13. Reset Zustand State Per Test: Zustand stores are module singletons, so state persists across tests in the same file unless you reset with useCartStore.setState({ items: [] }) (or getState().reset()) in beforeEach — otherwise one test's mutations invisibly pre-seed the next.
  14. Fresh QueryClient Per Test: Create a new QueryClient with retry: false inside a renderWithProviders helper for every test; a shared client caches responses across tests and hangs suites on failed retries.
  15. Query role=alert for RHF Errors: Render validation errors with 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".
  16. Mock useActionState in jsdom: 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.
  17. Wrap Hook State Updates in act: In 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 }.
  18. Don't Destructure result.current: 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.
  19. Await Async Server Components First: 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.
  20. Mock next/headers and next/cache: 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.
  21. Use Accessible Playwright Locators + POM: Prefer 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.
  22. Replace waitForTimeout With Auto-Waiting: Playwright's 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.
  23. Auto-Start Server + Traces On Retry: Configure 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.
  24. Authenticate Once, Route Before Goto: Use a 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.
  25. Follow the Testing Trophy and Write Regressions: Prioritize static checks (TypeScript strict, ESLint) and integration tests first, use E2E sparingly for critical flows (login, checkout), set coverage near 80 % rather than chasing 100 %, and write a regression test for every bug you fix.