Portfolio/Writing/Front-end unit testing worth keeping: Vitest, Testing Library and what to actually assert

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#

LayerToolJob
RunnerVitestESM-native, Vite-config reuse, fast watch mode, Jest-compatible API
DOMjsdom or happy-domA fake DOM in Node; happy-dom is faster, jsdom more complete
Component queriesReact Testing LibraryRender components, query them the way a user would
User input@testing-library/user-eventRealistic events (focus, keydown, paste) not synthetic clicks
NetworkMSW (Mock Service Worker)Intercept fetch/XHR at the network layer
End-to-endPlaywrightReal 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.

tsx
// ❌ 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()
})
Query priority
Prefer 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.

text
        ┌───────────┐   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         │
  └──────────────────────┘
Where the effort goes. Component/integration tests give the most confidence per line of test code.

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 renderHook for 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:

ts
// 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())
tsx
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 with setTimeout, debounce or polling, then vi.advanceTimersByTimeAsync(300). Restore in afterEach.
  • 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#

tsx
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"))
  • userEvent over fireEvent: it dispatches the full event sequence a real user triggers (pointerdown, focus, click, and so on).
  • findBy* is getBy* plus a waitFor; use it for anything that appears after an await.
  • An act(...) warning means state updated after the test stopped awaiting. You missed an await, usually on a user action or a findBy.
  • Never assert with queryBy* that something exists. queryBy is 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-dom instead of jsdom when 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 --changed locally; shard across CI machines (--shard=1/4) for the full run.
  • Budget it: if vitest takes more than ~2 minutes on CI for a mid-size app, it will start getting bypassed. Profile with --reporter=verbose and fix the slow files.
Summary
Use Vitest, React Testing Library and MSW. Query by role/label/text and assert on what the user sees, so refactors do not break tests. Weight the suite toward component-level integration tests, keep pure logic in fast unit tests, and leave a thin E2E layer for critical journeys. Mock only time, missing browser APIs and true external boundaries. Read coverage for gaps, not as a gate. And keep the whole thing fast enough that it always runs.

Next up
Front-end performance: how to measure it, and what to actually fix

Core Web Vitals explained (LCP, INP, CLS), lab vs field measurement, the tools that give real answers, how to read a performance trace, and the fix toolbox: code splitting, breaking up long tasks, debounce and throttle, fewer re-renders, images and fonts.

Read next →