Front-end unit testing worth keeping: Vitest, Testing Library and what to actually assert
A practical guide to component and unit testing in 2026: the Vitest and React Testing Library stack, testing behaviour instead of implementation, MSW for the network, mocking sparingly, handling async, and treating coverage as a signal rather than a target.
Most front-end test suites fail one of two ways: they are so coupled to implementation that every refactor breaks a hundred tests, or they are so shallow they pass while the feature is broken. A suite worth keeping catches real regressions, survives refactors, and runs fast enough that nobody skips it.
The stack in 2026#
| Layer | Tool | Job |
|---|---|---|
| Runner | Vitest | ESM-native, Vite-config reuse, fast watch mode, Jest-compatible API |
| DOM | jsdom or happy-dom | A fake DOM in Node; happy-dom is faster, jsdom more complete |
| Component queries | React Testing Library | Render components, query them the way a user would |
| User input | @testing-library/user-event | Realistic events (focus, keydown, paste) not synthetic clicks |
| Network | MSW (Mock Service Worker) | Intercept fetch/XHR at the network layer |
| End-to-end | Playwright | Real browser, real navigation; a separate, thinner suite |
If you are on Jest and Create React App, moving to Vitest and Vite is the single biggest quality-of-life win: no separate Babel/transform config, and watch mode that reruns in tens of milliseconds.
Test behaviour, not implementation#
The more your tests resemble the way your software is used, the more confidence they can give you. (Testing Library's guiding principle.)
Concretely: query by what the user perceives (role, label, text) and assert on what they would see happen. Do not reach into state, props, or named internal functions.
// ❌ implementation-coupled: breaks if you rename state or swap useState for useReducer
it("sets isOpen to true", () => {
const { result } = renderHook(() => useDropdown())
act(() => result.current.setIsOpen(true))
expect(result.current.isOpen).toBe(true)
})
// ✅ behaviour: survives any refactor that keeps the UX the same
it("opens the menu when the trigger is clicked", async () => {
const user = userEvent.setup()
render(<AccountMenu />)
expect(screen.queryByRole("menu")).not.toBeInTheDocument()
await user.click(screen.getByRole("button", { name: /account/i }))
expect(screen.getByRole("menu")).toBeVisible()
})getByRole (with a name), then getByLabelText, getByText, getByPlaceholderText. Fall back to getByTestId only when there is truly no accessible handle, and treat that as a hint that the markup needs a role or label anyway. Never query by class name or DOM structure.The shape of the suite#
The old "test pyramid" over-weights isolated unit tests. The more useful model for front-end is the testing trophy: a wide band of integration-style component tests, fewer pure units, a thin cap of E2E, all sitting on static analysis.
┌───────────┐ E2E (Playwright): critical journeys only
│ E2E │
┌─┴───────────┴─┐ Integration: components with real children,
│ INTEGRATION │ mocked network. The bulk of the suite.
┌─┴───────────────┴─┐ Unit: pure logic, reducers, formatters, hooks
│ UNIT │
┌─┴───────────────────┴─┐ Static: TypeScript, ESLint, typecheck in CI
│ STATIC │
└──────────────────────┘What to actually test#
- Pure logic: formatters, parsers, reducers, selectors, validation, money and date math. Cheapest tests, highest value; test the edge cases hard.
- Component behaviour: it renders each meaningful state (loading, empty, error, populated), responds to interaction, shows and hides the right things, and produces accessible output (roles, names, focus).
- Error and edge states: the empty list, the failed request, the 0 and the 1 and the 10,000, the very long string, the missing optional field.
- Custom hooks: through a tiny test component, or
renderHookfor standalone logic hooks. - Bug regressions: every fixed bug gets a test that fails without the fix.
Do not test:
- Third-party libraries (React, the router, the date lib); assume they work.
- Trivial passthrough components with no logic.
- Exact styling, class names, or snapshot blobs of markup; they break on every change and nobody reads the diff.
- Implementation details: internal function names, state variable names, call counts of private helpers.
Network: MSW, not a pile of mocks#
Mocking fetch per test with hand-rolled return values is brittle and drifts from reality. MSW intercepts at the network layer, so your code runs its real data-fetching path and you describe responses once:
// test/handlers.ts: shared by tests AND local dev
import { http, HttpResponse } from "msw"
export const handlers = [
http.get("/api/invoices/:id", ({ params }) =>
HttpResponse.json({ id: params.id, total: 4200, status: "paid" })
),
]
// test/setup.ts
import { setupServer } from "msw/node"
import { handlers } from "./handlers"
export const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())it("shows an error banner when the invoice fails to load", async () => {
server.use(
http.get("/api/invoices/:id", () => new HttpResponse(null, { status: 500 }))
)
render(<InvoicePage id="42" />)
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn.t load/i)
})onUnhandledRequest: "error" matters: it fails the test if your code calls an endpoint you did not model, instead of hanging.
Mock as little as possible#
- Time.
vi.useFakeTimers()for anything withsetTimeout, debounce or polling, thenvi.advanceTimersByTimeAsync(300). Restore inafterEach. - Browser APIs jsdom lacks:
matchMedia,IntersectionObserver,ResizeObserver,scrollTo. Stub these once in setup. - True external boundaries: analytics, error reporting, a payment SDK. Mock the module at its edge.
- Do not mock what you own. If you are mocking your own components or hooks to test another component, that is a design smell: the seam is in the wrong place, or the test is at the wrong level.
Async and user events#
const user = userEvent.setup() // v14+: call once per test
await user.type(screen.getByLabelText(/email/i), "a@b.com")
await user.click(screen.getByRole("button", { name: /save/i }))
// wait for the result to appear, never a fixed sleep
expect(await screen.findByText(/saved/i)).toBeInTheDocument()
// wait for something to disappear
await waitForElementToBeRemoved(() => screen.queryByRole("progressbar"))userEventoverfireEvent: it dispatches the full event sequence a real user triggers (pointerdown, focus, click, and so on).findBy*isgetBy*plus awaitFor; use it for anything that appears after an await.- An
act(...)warning means state updated after the test stopped awaiting. You missed anawait, usually on auseraction or afindBy. - Never assert with
queryBy*that something exists.queryByis only for asserting absence.
Coverage is a signal, not a target#
100% coverage of assertion-free tests catches nothing. A blanket "80% or the build fails" rule mostly produces tests written to touch lines, not to check behaviour.
- Use the coverage report to find untested branches in logic that matters: an error path, a rarely-hit conditional.
- Gate coverage only on critical modules (payments, auth, pricing), not the whole repo.
- For code you truly need confidence in, mutation testing (Stryker) is the real check: it changes your code and sees whether a test fails. Surviving mutants are gaps your line coverage hid.
Keep the suite fast#
- Vitest runs files in parallel workers by default, so keep tests independent (no shared mutable module state).
happy-dominstead ofjsdomwhen you do not need the edge cases; noticeably faster.- Fake timers everywhere possible. Real
setTimeout(cb, 300)in 200 tests is a minute of nothing. vitest --changedlocally; shard across CI machines (--shard=1/4) for the full run.- Budget it: if
vitesttakes more than ~2 minutes on CI for a mid-size app, it will start getting bypassed. Profile with--reporter=verboseand fix the slow files.