Topic 17.7
End-to-End, Visual and Accessibility Tests
In one line
Playwright drives your real built app in real browsers for a few critical journeys, screenshot comparisons catch visual breakage, axe catches common accessibility errors automatically, and a clear plan decides which check lives at which layer.
Think of it like this
Before a new metro line opens, engineers test each part in the workshop (brakes, doors, signals), and then run whole trial journeys on the real track, with real stations and real signals, before passengers ride. Component tests are the workshop; end-to-end tests are the trial runs: fewer, slower, and the only way to know the whole line works together.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Playwright
- A tool that drives real browsers (Chromium, Firefox, WebKit) to test a running app end to end.
- Locator
- Playwright's way to find elements, like
page.getByRole("button", { name: "Save" }). It re-finds the element every time it is used. - Auto-wait
- Playwright waits for an element to be ready (visible, enabled, still) before clicking or typing.
- Trace
- A recording of a test run with DOM snapshots, network and console for each step, for debugging.
- Visual regression test
- A test that compares a new screenshot with a saved baseline and fails if they differ too much.
- Baseline
- The approved screenshot that later screenshots are compared with.
- axe-core
- A widely used library that scans a page for common accessibility problems.
- WCAG
- The Web Content Accessibility Guidelines: the standard rules for accessible websites.
- Browser component test
- Rendering one component in a real browser engine instead of jsdom.
Step by step
01Install Playwright and let it start your app
npm init playwright@latest installs @playwright/test, downloads browsers and writes a config and an example test. Point webServer at the command that serves your built app, so npx playwright test starts it automatically.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: { baseURL: "http://localhost:4173", trace: "on-first-retry" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["Pixel 7"] } },
],
webServer: {
command: "npm run build && npm run preview", // Vite preview serves the build on 4173
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
},
});02Write the journey like a user story
One E2E test covers a whole journey, not one button. Use role locators, as in Testing Library, and let assertions retry.
import { test, expect } from "@playwright/test";
test("a visitor logs in and reaches their courses", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Log in" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Asha");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL(/\/courses$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome, Asha");
});03Add a screenshot check for a key page
The first run writes the baseline next to the test; later runs compare. Hide or freeze moving parts (dates, animations, random avatars) so screenshots are stable: Playwright's mask option covers given locators and animations: "disabled" stops CSS animations.
test("home page looks right", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("home.png", {
mask: [page.getByTestId("today")],
maxDiffPixelRatio: 0.01,
});
});04Scan every important page with axe
Run axe after the page is in the state you care about (a dialog open, an error shown), not only on first load.
import AxeBuilder from "@axe-core/playwright";
test("home has no detectable a11y violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
expect(results.violations).toEqual([]);
});05Place each check at the right layer
Push each check as low as it can go while still catching the bug. A form's validation messages belong in fast integration tests; "the login cookie survives a page reload" can only be an E2E test.
Try it yourself
- 1
Fix BadSearch one problem at a time
Add
alt="Hectal"to the image and run: predict the new failure message. Then addaria-label="Search"to the button, and finally a<label>for the input, until the test passes. - 2
Catch an empty link
Add
<a href="/cart"><svg width="16" height="16" /></a>to GoodSearch. Predict the message. How would you give the link a name without visible text? - 3
Break the journey
In the journey example, change the Continue handler to
navigate("/"). Predict which assertion fails and its message. This is the kind of wiring bug E2E tests exist for.
Code & diagrams
This is a homemade check, far smaller than axe, but it shows the idea: compute each control's accessible name and flag empty ones. Testing Library's name computation ignores placeholders, so the bare input is flagged here; axe accepts a placeholder as a name, but a visible label is still the right fix (placeholders vanish when you type).
Preview
Console
Nothing logged yet.Expected console output
✗ BadSearch: every control has a name: a button with no name, a text field with no label, an image with no alt text
✓ GoodSearch: every control has a nameThis runs in the preview with a MemoryRouter, so it is still an integration test. The Playwright version (above, in the walkthrough) uses the same steps against the real built app, real URLs and a real browser: same journey, more confidence, more cost.
Preview
Console
Nothing logged yet.Expected console output
✓ a visitor logs in and reaches their courses$ npx playwright test
Running 6 tests using 3 workers
✓ [chromium] › e2e/login.spec.ts › a visitor logs in and reaches their courses
✓ [chromium] › e2e/a11y.spec.ts › home has no detectable a11y violations
✓ [chromium] › e2e/visual.spec.ts › home page looks right
✓ [mobile] › e2e/login.spec.ts › a visitor logs in and reaches their courses
...
6 passed
$ npx playwright show-report # open the HTML report
$ npx playwright test --ui # interactive mode: watch, time-travel through steps
$ npx playwright test --update-snapshots # accept new screenshots after an intended changebutton-name (critical): Buttons must have discernible text - 1 element(s)
image-alt (critical): Images must have alternative text - 1 element(s)
# The input with only a placeholder is not reported: axe counts a placeholder as a name.
# Each violation in results.violations also lists the failing elements' selectors and a help URL.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Wait with fixed sleeps in Playwright
Write await page.waitForTimeout(2000) after clicking Continue, then read the heading with await page.locator("h1").textContent() and compare it yourself.
Break #2
Screenshot a page with live data
Take toHaveScreenshot() of a dashboard that shows today's date and a random avatar.
Myth vs fact
Myth
If axe reports no violations, the app is accessible.
Fact
Automatic checks catch a good share of common problems (missing names, contrast, invalid ARIA), not all of them. Whether focus order makes sense, whether a screen reader announcement is understandable, whether a flow works by keyboard: those need people (Phase 18).
Myth
E2E tests are the most realistic, so write most tests as E2E.
Fact
They are realistic but slow, need a running app and data, and are harder to debug. A large E2E suite becomes slow and flaky. Keep them for critical journeys and test details lower down.
Myth
Snapshot tests of the rendered HTML catch visual bugs.
Fact
HTML snapshots don't know about CSS: the markup can be identical while the page looks broken. Visual bugs need real screenshots from a real browser.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Playwright isolates each test in a fresh browser context (like a new incognito profile: separate cookies and storage) and runs files in parallel workers. Log in once in a setup project and save
storageStateto a file, then reuse it so every test starts signed in without clicking through the login form. - ▸
Control the backend for E2E: either a dedicated test environment with seeded data, or
page.route("**/api/**", ...)to mock specific requests in the browser. Mock the backend and you test the frontend in isolation (fast, stable); use the real one and you also test the contract. Many teams do both: mostly mocked, plus a small smoke suite against a real staging backend. - ▸
Traces are the best debugging tool for flaky E2E failures in CI: with
trace: "on-first-retry", a failing test retries once with tracing on, andnpx playwright show-trace trace.zipshows every action with before/after DOM snapshots, network and console. - ▸
In Vitest with jsdom,
vitest-axe/jest-axecan run axe against a rendered component, but jsdom has no layout or computed colours, so rules likecolor-contrastcan't run there. Run axe in a real browser (Playwright, or Storybook's accessibility addon) for full coverage.
Remember this
- 1
An end-to-end (E2E) test starts your real app (built and served, talking to a real or test backend) and drives it in a real browser: it opens pages, clicks, types and checks what appears. Playwright (from Microsoft) is the most popular E2E tool for React apps today; Cypress is the other well-known one. Playwright runs Chromium, Firefox and WebKit (Safari's engine), in parallel, headless (no visible window) or headed, and records traces (a step-by-step replay with DOM snapshots, network and console) for debugging failures.
- 2
Playwright's API will look familiar: locators like
page.getByRole("button", { name: "Continue" }),page.getByLabel("Email")andpage.getByText(...)follow the same user-first idea as Testing Library. Actions (click(),fill(),press("Enter")) auto-wait until the element is visible, enabled and stable, and assertions likeawait expect(locator).toHaveText(...),toBeVisible()andawait expect(page).toHaveURL(...)retry until they pass or time out. That built-in waiting removes most of the sleeps that made older E2E suites flaky. - 3
Visual regression tests compare screenshots.
await expect(page).toHaveScreenshot("home.png")saves a baseline image the first time, and on later runs fails if the new screenshot differs by more than a threshold (options likemaxDiffPixelRatio). They catch what behaviour tests can't: a CSS change that hides a button behind another, a broken layout on mobile, a wrong colour. Screenshots differ between operating systems and fonts, so run them in one fixed environment (usually CI in Docker) and review and update baselines on purpose (--update-snapshots). - 4
Accessibility (a11y) tests check that people using screen readers, keyboards or zoom can use the app. axe-core (by Deque) is the standard automatic checker: it scans the page against rules from WCAG (the Web Content Accessibility Guidelines) and reports violations such as
button-name(a button with no text),image-alt,labelandcolor-contrast. Use it in Playwright through@axe-core/playwright(new AxeBuilder({ page }).analyze()), or in Vitest throughvitest-axe/jest-axe(in jsdom, which can't check colour contrast). Automatic tools find a good share of problems, not all: keyboard walkthroughs and screen reader checks by people are still needed (Phase 18). - 5
Component tests in a real browser sit between the layers: Vitest Browser Mode and Playwright's experimental component testing render a single component in a real browser engine, so layout, real CSS, real focus and real events work, while still mounting one component with props. Use them for components whose behaviour depends on the real browser (drag and drop, virtualised lists, canvas, complex focus handling).
- 6
What to automate where: pure logic → unit tests. Most UI behaviour, forms, states and edge cases → integration tests with Testing Library (fast, many). Browser-dependent components → browser component tests. A handful of money-making or must-never-break journeys (sign up, log in, checkout) and the routing/server/auth wiring → Playwright E2E. Layout and styling of key pages → visual tests. Accessibility → role queries everywhere, axe on every page in E2E, and manual checks. Run fast tests on every save and every pull request; run E2E and visual suites on every pull request too if they're quick enough, otherwise on merge.
Explain it without notes
What is an end-to-end test and how is Playwright different from Testing Library in jsdom?
How do Playwright's auto-waiting and web-first assertions reduce flakiness?
What are visual regression tests, and how do you keep them stable?
How would you split testing of a React shop app across unit, integration, E2E, visual and accessibility tests?
Practice
Write a Playwright test that searches for "mango" in a search box labelled "Search products" and checks that exactly 3 results (list items) appear.
Using the live helper, write a check that every <input> of type checkbox on the page has a name. Test it on a component with one labelled and one unlabelled checkbox.
Trade-offs
- ↔
E2E tests give the most confidence per test but cost the most to run and maintain; a small, stable set beats a large flaky one.
- ↔
Visual tests catch styling regressions nothing else does, but need a fixed environment and human review of diffs; limit them to key pages and components.
Done when you can
I can set up Playwright with a webServer and write a journey test with role locators.
I use web-first assertions instead of fixed sleeps.
I can add a stable screenshot test.
I can run axe in Playwright and know what automatic checks miss.
I can decide which layer each kind of check belongs to.