| name | react-testing |
| description | Testing strategy for React apps — when to reach for Playwright (E2E) vs Vitest (unit/component) vs both, the senior testing pyramid for an SPA, Playwright setup with config + webServer + per-spec project structure, Vitest setup with jsdom + Testing Library, a11y-first locators (getByRole, getByLabel, toBeFocused, toHaveAttribute), assertion patterns for aria-pressed/aria-current/role=alert/focus-on-route-change, @axe-core/playwright for automated a11y audits, running against localhost vs deployed URL via env var, what NOT to test, common flakiness sources and fixes. Use whenever the project needs tests added — interview "would you add tests?" question, real production CI, or post-build polish layer. Triggers on "test", "testing", "playwright", "vitest", "jest", "e2e", "end-to-end", "unit test", "component test", "@playwright/test", "@axe-core/playwright", "getByRole", "testing library", "rtl", "smoke test", "ci tests", "test runner". |
react-testing
Senior testing isn't about coverage percentages — it's about catching the regressions that matter while staying fast and stable. This skill gives you the decision framework and paste-ready setup for both layers.
The senior testing pyramid for a typical React SPA
Most SPAs need two thin layers, not five:
| Layer | Tool | What to test | Roughly |
|---|
| Unit / pure logic | Vitest | Pure functions, transforms, adapters, the parts of the API client that aren't fetch | 20% of test code |
| E2E | Playwright | The 4-6 happy paths a real user takes, plus focus / a11y wiring | 80% of test code |
Component tests in RTL | (skip by default) | Most "component tests" duplicate either unit or E2E coverage with more boilerplate | 0% |
When to add RTL component tests anyway:
- A single component has gnarly internal state worth isolating (a complex form, a step machine)
- You have a component library that's the deliverable, and each export needs its own spec
- A11y-sensitive primitive (combobox, listbox) where the internal interaction matrix is the product
For a typical product SPA, skip the middle layer. Playwright's getByRole queries give you the same a11y guarantees as RTL with the real browser doing the rendering.
The Playwright recipe (this is what to install)
npm install -D @playwright/test
npx playwright install chromium
Then playwright.config.ts — the version that lets one spec suite test both localhost AND the deployed URL:
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173";
const usingExternalServer = !!process.env.E2E_BASE_URL;
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: usingExternalServer
? undefined
: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});
And the package.json scripts:
{
"scripts": {
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:prod": "E2E_BASE_URL=https://your-app.netlify.app playwright test"
}
}
Why these three and not more:
e2e — local headless run, the daily driver
e2e:ui — debugging a failing spec, time-travel snapshots, locator inspector
e2e:prod — smoke test the live deploy after pushing; CI can run this on a schedule
.gitignore additions:
/test-results
/playwright-report
/playwright/.cache
What to put in e2e/ — the 4-spec template
For a typical SPA, four spec files cover almost everything worth testing:
e2e/
homepage.spec.ts Landing/list page: data renders, filters work, pagination wires up
detail.spec.ts Navigation in + out of a detail route; focus management
errors.spec.ts 404, network failure, invalid input — every error state
a11y.spec.ts Keyboard order, landmarks, no console errors, axe scan
Don't make one giant e2e.spec.ts. Per-file describe blocks let Playwright parallelize and let you read failures faster.
A11y-first locators — the senior signal
Use role-based queries by default. Anything else is a smell that you're testing implementation, not behavior.
await page.getByRole("button", { name: /audio/i }).click();
await page.getByRole("heading", { level: 1, name: "Post title" });
await page.getByRole("alert");
await page.getByRole("navigation", { name: "Pagination" });
await page.getByRole("link", { name: /skip to main content/i });
await page.getByLabel("Email");
await page.getByText(/\d+ shown/);
await page.locator("main article");
await page.locator(".filter-chip-button");
await page.locator("#filter-3");
await page.locator("[data-testid='filter']");
Order of preference (matches React Testing Library guidance):
getByRole with name (matches what a screen reader announces)
getByLabel, getByPlaceholder, getByText
getByTestId — only when nothing semantic exists, which should be rare
If you can't find a role-based query, that's often a signal the UI itself is missing semantics.
Assertion patterns for the four a11y wirings you'll most often test
These are the patterns that came up in the kratecms-reader build. Memorize them.
Focus moves to the H1 on SPA route change
await page.getByRole("link", { name: "Some post" }).click();
const h1 = page.getByRole("heading", { level: 1 });
await expect(h1).toBeFocused();
await expect(h1).toHaveAttribute("tabindex", "-1");
aria-current="page" on the active nav / pagination item
const pagination = page.getByRole("navigation", { name: "Pagination" });
await expect(pagination.getByRole("button", { name: "Page 1" }))
.toHaveAttribute("aria-current", "page");
await pagination.getByRole("button", { name: "Page 2" }).click();
await expect(pagination.getByRole("button", { name: "Page 2" }))
.toHaveAttribute("aria-current", "page");
aria-pressed on toggle buttons (filter chips, etc.)
const audioChip = page.getByRole("group", { name: "Filter by category" })
.getByRole("button", { name: /^audio/ });
await audioChip.click();
await expect(audioChip).toHaveAttribute("aria-pressed", "true");
role="alert" for error states
await page.goto("/posts/99999");
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText(/not found/i);
await expect(alert.getByRole("button", { name: /try again/i })).toHaveCount(0);
Skip link revealed on first Tab
await page.goto("/");
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: /skip to main content/i }))
.toBeFocused();
The no-console-errors spec — every project should have this
Catches the bugs that don't break a render but ship a noisy console:
test("no console errors on a typical browse session", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/");
await page.locator("main article").first().getByRole("link").first().click();
await page.waitForURL(/\/posts\//);
await page.getByRole("link", { name: /back|all posts/i }).click();
const ownErrors = errors.filter(
(e) => !/soundcloud\.com|youtube\.com|X-Frame-Options/i.test(e),
);
expect(ownErrors, ownErrors.join("\n")).toHaveLength(0);
});
Two key things:
- Listen on both
console (msg.type() === 'error') and pageerror (uncaught exceptions).
- Filter known third-party noise — third-party iframes are notorious for
X-Frame-Options complaints that you can't fix.
@axe-core/playwright for automated a11y scans
npm install -D @axe-core/playwright
import AxeBuilder from "@axe-core/playwright";
test("homepage has no axe violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Axe catches ~30-40% of automatable a11y issues (color contrast, missing alt, mis-wired ARIA). It's not a substitute for manual testing — but landing it in CI prevents regressions from sneaking in.
Common refinements:
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.disableRules(["color-contrast"])
.exclude("iframe")
.analyze();
The Vitest recipe (only if you need it)
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
vitest.config.ts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
globals: true,
},
});
vitest.setup.ts:
import "@testing-library/jest-dom/vitest";
Then a spec that tests the parts worth isolating — pure functions, not components:
import { describe, it, expect } from "vitest";
import { normalizeMeta } from "./client";
describe("normalizeMeta", () => {
it("unboxes [v, v] arrays back to scalars", () => {
expect(normalizeMeta({
current_page: [3, 3], last_page: [5, 5], per_page: [15, 15], total: [73, 73],
from: 31, to: 45, path: "x",
}).current_page).toBe(3);
});
it("passes through scalar values unchanged (forward-compat with fixed API)", () => {
expect(normalizeMeta({
current_page: 3, last_page: 5, per_page: 15, total: 73,
from: 31, to: 45, path: "x",
} as any).current_page).toBe(3);
});
});
When to add a Vitest component test:
- The component has internal state that's hard to drive via a real browser
- You'd otherwise need 20 lines of E2E setup to test one branch
- The component is a primitive in a design system
When NOT to add a component test:
- The "component test" would be
render() → click → assert text — that's an E2E with extra steps
- The behavior depends on routing, network, or browser APIs — use Playwright
- You're testing prop combinations — use a TypeScript test (
expectTypeOf) instead
What NOT to test
| Don't test | Why |
|---|
| Implementation details (state shape, internal hooks) | Refactors break the test for no reason |
| Third-party iframe content | You don't own it; flake guaranteed |
| Exact pixel layouts (use visual regression instead) | Pixels shift with font hinting, AA |
The framework itself (does useState work?) | Not your code |
| Every prop combination (use TS types) | TypeScript catches this at compile time |
| Snapshots of large trees | Reviewers blind-accept; not a real test |
Flakiness — the 5 most common causes and fixes
| Cause | Fix |
|---|
await page.waitForTimeout(500) | Use waitForURL, waitForResponse, or auto-retrying assertions. Hard timeouts are flake-bait. |
| Asserting on text that depends on live API data | Assert on structure ("at least one card visible") not specific titles. Pin to a stable record for content assertions. |
| Animations not finished when asserting | await expect(thing).toBeVisible() waits for stable layout. If still flaky, gate animations on prefers-reduced-motion in the test fixture. |
| Network race between two fetches | await page.waitForResponse(url) before asserting on the result. |
| State leaking between tests | Use test.beforeEach to reset. Never share page instances across tests. |
Running against localhost vs the deployed URL
The E2E_BASE_URL pattern in the config above lets one suite serve two purposes:
npm run e2e
npm run e2e:prod
For CI: run npm run e2e on every PR (catches regressions before merge). Schedule npm run e2e:prod to run hourly or after deploys (catches infra drift).
Don't point E2E_BASE_URL at production for the PR run — every PR would hit your live site dozens of times.
When the interviewer asks "what about tests?"
Senior answer follows this skill:
- "I'd add Playwright for the routing, focus management, and filter behavior — those are the 4-6 happy paths a user takes."
- "Vitest for the pure helpers —
normalizeMeta, the YouTube ID extractor. Not for components; the E2E covers them."
- "I skip component-level RTL by default unless I have a primitive that needs prop-by-prop coverage."
- "axe in CI for automated a11y regressions, plus manual keyboard pass before shipping."
- "I'd run E2E against localhost in PRs and against the deployed URL on a schedule."
That's the answer, in 30 seconds, that signals senior.
Narration phrases
- "I used
getByRole everywhere — if I can't find a role-based query, that's a signal the UI is missing semantics."
- "The focus-on-route-change spec is a one-liner:
await expect(h1).toBeFocused(). That's the single highest-value a11y assertion for an SPA."
- "I have one
no-console-errors spec walking a typical browse path — catches the bugs that don't break a render but ship a noisy console."
- "I skipped component tests — Playwright covers them with the real browser, and Vitest covers the pure helpers. The middle layer would duplicate both."
- "Vitest config for
normalizeMeta is six lines including imports. Not worth a heavier framework."
Authoritative references