Frontend / Checklist
The test layers a frontend actually needs
Five layers, each catching a class of bug the others cannot: unit tests for logic, integration tests for workflows, end-to-end tests in real browser engines, device and visual checks for what the user sees, and the gates that turn all of it into a merge decision. Every rule names the tool, so the choice is already made.
13 rules in 5 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
Which layers of automated testing a frontend needs before a change is safe to merge, what each layer is for, and which tool does it without a fight: Vitest and Testing Library for units, Playwright with axe-core for workflows and accessibility, Playwright across three engines end to end, Storybook with Chromatic or Playwright screenshots for visuals, Sentry, Stryker and Lighthouse CI for the gates.
The source lists 13 rules in seven groups. The one-rule Performance group is folded into Quality gates here, and the one-rule Mobile and Visual groups share a section, so there are five groups and no one-row table. Nothing was dropped and nothing was merged; the count is the source's.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Unit
The fast layer: milliseconds per test, run on every save, and only worth writing for code that has inputs and outputs of its own.
| Rule | Priority | What to do |
|---|---|---|
| Unit-test the logic, not the framework | High | Vitest (Jest if the project already has it) for pure functions, reducers, formatters, validators and anything else with no DOM. The few components that hold real logic of their own get Testing Library, querying by role and accessible name - getByRole('button', { name: /save/i }) - never by class name; the rest are covered by the integration layer below and need no mounted test at all. |
| Mock at the boundary and nowhere else | Medium | Mock the network with MSW, the clock with vi.useFakeTimers(), and third-party SDKs at their import. Do not mock your own modules to make a test pass; a test that stubs the thing under test proves the stub works. |
A component test that reads like the user story, with the query the first row insists on:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import { Counter } from './Counter';
test('increments when the button is clicked', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByRole('status')).toHaveTextContent('1');
}); Gotcha: a snapshot test of rendered markup is an approval, not an assertion. It passes on garbage the day it is written and fails on every deliberate change afterwards. Keep snapshots for serialized output you actually read in review.
Integration
Several components, the real router and store, and the network mocked at the wire: the layer where most frontend bugs actually live.
| Rule | Priority | What to do |
|---|---|---|
| Run axe on every page the suite visits | High | @axe-core/playwright scans the rendered DOM and fails the test on WCAG violations - missing labels, low contrast, invalid ARIA - that a linter cannot see because they depend on the final render. It is a floor, not a substitute for a keyboard walk-through and a screen-reader pass. |
| Test the key workflows across components | High | Sign-up, checkout, search, the form that pays the bills: rendered with the real store and router, the API mocked at the network layer with MSW, and assertions on what the user sees. A few wide tests beat many shallow ones, because the bugs are in the seams. |
| Contract-test the API boundary | Medium | Pact records what the frontend expects from each endpoint as a consumer-driven contract and verifies the provider against it in CI. A backend change that would break the UI then fails their build instead of your users - and the MSW mocks stop drifting from reality. |
The first row as a Playwright spec, scoped to the WCAG 2.x A and AA rule sets so the run stays focused:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout has no WCAG A or AA violations', async ({ page }) => {
await page.goto('/checkout/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
}); Gotcha: axe only sees the state it scans. Open the modal, expand the menu and trigger the validation errors before calling analyze(), or the parts of the page most likely to be broken are never checked.
End to end
A real browser, the built site, and the journeys that make money. Slow and expensive, so keep the count small and the coverage deliberate.
| Rule | Priority | What to do |
|---|---|---|
| Cover the critical journeys end to end | High | Playwright (Cypress if the team already owns it) driving a real browser against a built, served copy of the site: login, the purchase or sign-up path, and the top three flows from analytics. Run on every pull request, against the preview deployment where the host provides one. |
| Test across the three browser engines | High | Cross-browser means Chromium plus the Firefox and WebKit engines - not three Chromium-based brands. Playwright installs all three with one command; run the smoke suite in each and the full suite in Chromium, and treat a WebKit-only failure as a Safari bug report with a repro attached. |
Gotcha: a test that waits with a fixed timeout is a flake with a delay attached. Use Playwright's auto-waiting locators and web-first assertions - await expect(locator).toBeVisible() retries until it passes or times out - and never page.waitForTimeout() in a committed test.
Mobile and visual
The source's Mobile and Visual groups, one rule each, share a section: both are about what the user sees rather than what the DOM contains.
| Rule | Priority | What to do |
|---|---|---|
| Test on real devices and real viewports | High | Playwright's device presets (devices['iPhone 15']) on every PR catch layout at phone width; a physical phone before a release catches touch-target size, the keyboard covering the input, and Safari's viewport behavior. BrowserStack or LambdaTest when a device lab is not on the desk. |
| Add visual regression where a pixel change is a bug | Medium | Playwright's toHaveScreenshot() for whole pages; Chromatic on a Storybook, or Percy, for components in isolation. Review the diffs the way you review code - approve each one deliberately, and never wire the baseline to auto-accept. |
Gotcha: screenshots differ by operating system, because font rendering does. Generate and compare baselines inside the same container image CI uses, or the visual suite fails on every developer laptop and gets switched off within the month.
Quality gates
The rules that turn test results into a merge decision, plus the one that catches what no test did. The source's one-rule Performance group is folded in here as the second row.
| Rule | Priority | What to do |
|---|---|---|
| Monitor errors in production | High | Sentry (or Bugsnag, Rollbar, Datadog RUM) with source maps uploaded from CI, a release tag on every deploy, and an alert on the first occurrence of a new issue. Tests prove what you thought of; monitoring finds the rest, in the browsers you did not test. |
| Enforce performance budgets in CI | Medium | Lighthouse CI with assertions on the Core Web Vitals and total byte weight, or size-limit on the built bundle, so the pull request fails the moment LCP, CLS or the transfer size crosses the line. A budget that only lives in a document is a wish. |
| Set coverage thresholds and ratchet them | Medium | coverage.thresholds in the Vitest config fails the run below the line. Set it at today's number so it can only go up, and resist a 100% target - it produces tests that execute code without asserting anything. |
| Measure test quality with mutation testing | Medium | Stryker mutates the source - flips an operator, deletes a statement - and checks whether any test fails. A surviving mutant is a line your tests run without checking. Run it on a schedule against the core modules, not on every pull request; it is slow by design. |
The budget row as a lighthouserc.json - the Vitals thresholds are the published ones and the byte ceiling is the source's 500KB target:
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost/", "http://localhost/checkout/"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["error", { "maxNumericValue": 512000 }],
"categories:accessibility": ["error", { "minScore": 1 }]
}
}
}
} Gotcha: coverage measures execution, not verification. A file at 90% coverage with no meaningful expect is 0% tested, and the number will not tell you - that is what the mutation-testing row is for.
Keep going
Error monitoring and an end-to-end test on the money path are the two rows here that block a deploy, and both sit with the other disciplines' blockers in the launch checklist. The axe row is a floor; the accessibility checklist is the full set of rules it is checking against, and the performance checklist is where the budget numbers in the Lighthouse config come from.
The tool picks above are argued in full in the testing tools guide - Vitest for logic, Playwright for the flows that pay you, MSW when a fake API is needed - and the production half of the story, Sentry plus an uptime check that pages you, in the monitoring guide.
None of it matters until it runs on every pull request. The CI/CD pipeline guide lays out the stages, and the GitHub Actions guide covers caching the browser install and sharding a slow suite across runners so the gate stays fast enough that nobody routes around it.