Testing Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Prefer getByRole Over Test IDs: Walk the Testing Library query priority (
getByRole→getByLabelText→getByText→getByTestId) so assertions match the accessibility tree; reaching forgetByTestIdfirst couples tests to implementation and misses a11y regressions. - queryBy for Absence, getBy for Presence:
getBy*throws on zero matches andfindBy*retries, so onlyqueryBy*is safe for "should not exist" assertions — usinggetBy*for absence gives a confusing "not found" error instead of a clean failed assertion. - Scope Queries With within: When tests render list items that share labels or buttons, wrap queries in
within(listItem)(or a uniquely namedaria-label) so you hit exactly the row you mean instead of the first match on the page. - Await Every userEvent Call: Every
userEventmethod (v14+) returns a Promise, so forgettingawaitlets the next assertion run before the event flushes and turns failing tests into silent false-positive passes — alwaysawait userEvent.click(button). - Prefer userEvent Over fireEvent:
userEvent.typedispatches the full focus → keydown → keypress → input → keyup sequence, which is what real components and validators listen to;fireEvent.changeonly sets the value and skips keystroke events that libraries like React Hook Form rely on. - Wrap Jest With createJestConfig: Export your Jest config through
next/jest'screateJestConfigso SWC transforms, CSS-module mocks, andnext.config.js-aware env/alias resolution come for free; put jest-dom matchers insetupFilesAfterSetup(notsetupFiles) orexpectextensions won't be in scope. - Configure Vitest Explicitly: Vitest needs
@vitejs/plugin-reactfor JSX,environment: "jsdom",globals: true, an import of@testing-library/jest-dom/vitestin the setup file, and"vitest/globals"intsconfig.compilerOptions.types— miss any one and JSX or matcher types fail silently. - Use vi.hoisted for Mock Factory Refs:
vi.mock()is hoisted above imports, so variables declared in the module body areundefinedinside the factory; usevi.hoisted(() => ({ mockPush: vi.fn() }))and reference that shared ref from both the factory and your tests. - Clear Mocks Between Tests: Mock return values and call history leak across tests in the same file, so call
vi.clearAllMocks()(orjest.clearAllMocks()) inbeforeEach; the same applies tovi.restoreAllMocks()for spies andserver.resetHandlers()for MSW. - findBy for Appearance, waitFor for State: Reach for
findByRole/findByTextwhen you are waiting for an element to appear, and forwaitForonly when the assertion is not about an element (e.g., a side-effect call count) —findByencapsulates retry and gives better error messages. - No Side Effects in waitFor:
waitForpolls its callback every ~50ms, so puttinguserEvent.click()or a fetch spy inside it fires the event repeatedly and corrupts state; make the interaction outsidewaitForand keep only assertions inside. - Reset MSW Between Tests: Call
server.resetHandlers()inafterEachso per-test overrides don't leak into the next test, and keeponUnhandledRequest: "error"so any forgotten endpoint surfaces immediately instead of masking as a mysterious hang. - 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: [] })(orgetState().reset()) inbeforeEach— otherwise one test's mutations invisibly pre-seed the next. - Fresh QueryClient Per Test: Create a new
QueryClientwithretry: falseinside arenderWithProvidershelper for every test; a shared client caches responses across tests and hangs suites on failed retries. - Query role=alert for RHF Errors: Render validation errors with
role="alert"so they are screen-reader-announced and queryable asawait screen.findAllByRole("alert"); remember React Hook Form validates on submit by default, so asserting errors before submission finds nothing unless you setmode: "onChange". - Mock useActionState in jsdom:
useActionStatecannot 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. - Wrap Hook State Updates in act: In
renderHook, any synchronous call that triggers a state update needsact(() => result.current.increment())or you get a warning plus a staleresult.current; under fake timers, also advance insideactor pass{ shouldAdvanceTime: true }. - Don't Destructure result.current:
result.currentis a live reference that changes after every render, soconst { count } = result.currentcaptures a snapshot that goes stale — always readresult.current.countdirectly on each assertion. - 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. - Mock next/headers and next/cache:
cookies(),headers(),revalidatePath, andrevalidateTagthrow outside the Next.js request context, so stub them withvi.mock("next/headers", …)andvi.mock("next/cache", …)— also mocknotFound()to throw a sentinel error soawait expect(fn()).rejects.toThrow()works. - Use Accessible Playwright Locators + POM: Prefer
page.getByRole,getByLabel, andgetByTextover CSS selectors and encapsulate them in Page Object classes; CSS selectors like.btn-primary.mt-4break the instant someone renames a class. - Replace waitForTimeout With Auto-Waiting: Playwright's
expect()assertions auto-retry for up to 5 seconds, soawait expect(page).toHaveURL("/dashboard")replaces everypage.waitForTimeout(500)— hard-coded sleeps are the #1 source of flaky E2E tests. - Auto-Start Server + Traces On Retry: Configure
webServerinplaywright.config.tsto start Next.js automatically withreuseExistingServer: !CI, and enabletrace: "on-first-retry"so failed runs ship a full DOM/network/console timeline you can open withplaywright show-trace. - Authenticate Once, Route Before Goto: Use a
setupproject plusstorageState: "e2e/.auth/user.json"so every test starts logged in without paying the login cost, and registerpage.route()mocks beforepage.goto()— routes set after the navigation miss the initial page load. - 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.