Frontend Testing in Practice: Vitest, Testing Library and E2E
"Frontend is hard to test" is a thing of the past. With mature Vitest, Testing Library and Playwright, frontend testing is now straightforward — the only difficulty is layering it correctly: what deserves a unit test, what needs a component test, what must be end-to-end, and where each runs in CI. Get the pyramid right and tests become a moat, not a burden.
Testing engineering is closely tied to the frontend building category. To build testable projects, start with the Vite build tool and the TypeScript guide; for the CI stage, see the GitHub Actions CI/CD guide.
The Testing Pyramid: Decide What to Test First
- Unit tests: pure functions, utilities and state logic (stores/reducers) — fast with precise failure locations;
- Component tests: render a component, simulate user interaction, assert UI state — cover "does the component respond correctly";
- Integration/E2E tests: run a real browser across key user journeys (login, checkout, payment) — closest to reality but slowest and most fragile;
- Practice advice: more at the bottom, fewer at the top. Bottom layers give fast feedback and run on every commit; E2E protects only core flows and runs before merge.
Unit Testing: Vitest
Vitest is a next-generation testing framework powered by Vite, reusing Vite's config and plugins (it even reads vite.config.* directly). It needs Node 20+, and test files follow the .test./.spec. naming convention:
import { expect, test } from 'vitest'
import { sum } from './sum.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
Common capabilities: describe/it/expect assertions, vi.mock for mocking modules, snapshot tests, coverage reports (--coverage), and vitest run for one-off runs. It also supports Browser Mode (running tests in a real browser) and component testing — great when you need DOM but do not want a full E2E setup.
Where Test Files Live
Where test files go and how they are named drives maintenance experience. A common practice is "co-location": unit and component tests sit next to the source file with a .test.tsx suffix, while shared test utilities (render helpers, MSW handlers) live in src/test/. A Vite + React project typically looks like this:
src/
components/
Button.tsx
Button.test.tsx
lib/
format.ts
format.test.ts
test/
setup.ts # test environment bootstrap
server.ts # MSW server
In vitest.config.ts, set test.environment to jsdom (for DOM) or node (pure logic) and load setup.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: { provider: 'v8' },
},
})
Component Testing: Testing Library
Testing Library's core principle in one sentence: "The more your tests resemble the way your software is used, the more confidence they can give you." It does not test implementation details but queries the page the way a user would — finding forms by their label text and buttons by their text, instead of data-testid (which is only an escape hatch):
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('logs in with credentials', async () => {
render(<Login />)
await userEvent.type(screen.getByLabelText('Email'), '[email protected]')
await userEvent.click(screen.getByRole('button', { name: 'Log in' }))
expect(await screen.findByText('Welcome back')).toBeInTheDocument()
})
Queries like getByRole/getByLabelText/findByText naturally push you to build more accessible components — a failed query usually means a missing label or semantic role. Testing Library is not a test runner; pair it with Vitest or Jest.
Query Priority
Query priority also follows a convention, starting from the most user-like: getByRole (semantic role) → getByLabelText (forms) → getByPlaceholderText → getByText → getByTestId (last resort). The table below helps you balance accessibility against stable queries:
| Query | Best for | Notes |
|---|---|---|
getByRole |
Buttons, links, headings | Closest to real users and the a11y tree |
getByLabelText |
Inputs, selects | First choice for forms |
getByText |
Paragraphs, plain text | Beware matching too many elements |
getByTestId |
Containers hard to express semantically | An escape hatch only; don't overuse |
A real payoff story: a team refactored their cart component, migrating state from useState to useReducer. Without component tests, that change meant manually clicking through "add to cart -> change quantity -> checkout"; with a render(<Cart />) plus userEvent test, a single npm test confirms no interaction broke, giving confidence before and after the refactor.
E2E Testing: Playwright and Cypress
- Playwright: from Microsoft, one API across Chromium/Firefox/WebKit with built-in auto-waiting (no hand-written sleeps), Trace Viewer (failure replay), Codegen (record scripts) and cross-browser parallelism;
- Cypress: excellent developer experience, runs in a real browser with time-travel debugging, network stubbing, plus component testing.
A typical E2E shape:
test('user can complete checkout', async ({ page }) => {
await page.goto('/checkout')
await page.getByLabel('Email').fill('[email protected]')
await page.getByRole('button', { name: 'Pay' }).click()
await expect(page.getByText('Order confirmed')).toBeVisible()
})
How do you choose between Playwright and Cypress? If your team already has Cypress experience and prefers its time-travel debugging, keep it. If you value multi-browser coverage, need Codegen to record regression scripts quickly, or want test code decoupled from the framework, Playwright is usually the smoother fit. Both integrate with CI, so the selection criterion should be "lowest maintenance cost for the team", not a feature checklist.
Mocks, Snapshots and Test Doubles
vi.mock/jest.mock: mock network requests, timers and third-party SDKs;- MSW (Mock Service Worker): intercepts at the network layer and returns realistic-shaped fake data, shared between component and E2E tests;
- Snapshot tests: record a component's rendered output to catch unintended changes, but do not overuse them — large snapshots are brittle and hard to read; prefer asserting key content.
The principle of mocking is "stub the outside, don't fake the subject": mock external dependencies unrelated to the test at hand (network, time, third-party SDKs), not the logic of the component under test. If you mock too much, you are effectively testing a fake implementation and gaining nothing. The check is simple — swap a dependency for its real implementation: if the test still holds and merely gets slower, your mocking is reasonable.
Wiring Tests into CI
- Run unit and component tests on every PR:
vitest run(ornpm test); failure blocks the merge; - Run E2E on PR or before merge: Playwright's official CI example (GitHub Actions installs browsers) or Cypress's CI integration;
- Treat coverage only as a trend reference, not a hard "must be 100%" — otherwise teams write meaningless tests for the number;
- Keep tests fast: mark slow E2E cases
@slowand run them in parallel so developers will run them locally.
16IDC Takeaway
For indie developers and small teams, start with "test the pure logic, then the key components, finally a couple of E2E cases" rather than doing everything at once. The return on testing is most visible during refactoring: with tests as a safety net, you can confidently upgrade React/Vue versions and restructure components. Combined with the component conventions in the frontend building category, the test system grows naturally with the project.
References: https://vitest.dev/guide/, https://testing-library.com/docs/, https://playwright.dev/docs/intro
Source: https://vitest.dev/guide/