Testing Layers — Depth
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/quality/references/layers.md |
| Description | Not specified |
Source Content
Testing Layers — Depth
Each section answers: what the layer tests, the house tool, the exact API shape, and the rule for when it applies. Read the layer you need; skip the rest.
Table of contents
- Unit — Vitest
- Component — React Testing Library + userEvent
- Network — MSW
- Accessibility — jest-axe
- Visual regression — Lost Pixel
- E2E — project choice
Unit — Vitest
Vitest is the house unit runner (Vite-native, Jest-compatible API, fast watch). Use it for anything with no DOM and no network: pure functions, reducers, Zod refinements, custom hooks (via @testing-library/react’s renderHook), and Go logic mirrors at the equivalent tier.
A unit test earns its place when the code has branches, edge cases, or arithmetic. A function with a single straight-line path rarely needs one.
import { describe, it, expect } from "vitest";import { proratedAmount } from "./billing";
describe("proratedAmount", () => { it("returns zero when no days remain", () => { expect(proratedAmount({ monthly: 3000, daysLeft: 0, daysInMonth: 30 })).toBe(0); });
it("returns the full amount on the first day", () => { expect(proratedAmount({ monthly: 3000, daysLeft: 30, daysInMonth: 30 })).toBe(3000); });});Rules:
- Assert on return values and observable effects, never on how the function got there.
- One behavior per
it. A test that needs “and” in its name is two tests. - No network, no timers, no filesystem at this layer — if you reach for those, it is a component or integration test.
Component — React Testing Library + userEvent
React Testing Library (RTL) tests a component the way a user meets it: render it, interact with it, assert on what the user can perceive. This is the highest-confidence-per-cost layer for UI and is where most UI tests live (Dodds’ “testing trophy”).
Always drive interaction with @testing-library/user-event, not fireEvent — userEvent simulates the full event sequence a real user triggers (focus, keydown, input, keyup), catching bugs fireEvent misses.
import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { SubscribeForm } from "./SubscribeForm";
it("shows a confirmation when the user submits a valid email", async () => { const user = userEvent.setup(); render(<SubscribeForm />);
await user.type(screen.getByLabelText(/email/i), "ada@example.com"); await user.click(screen.getByRole("button", { name: /subscribe/i }));
expect(await screen.findByText(/check your inbox/i)).toBeInTheDocument();});Query priority (use the highest that works; getByTestId is the last resort):
getByRole(optionally with{ name }) — mirrors the accessibility tree.getByLabelText— form fields.getByPlaceholderText,getByText,getByDisplayValue.getByAltText,getByTitle.getByTestId— only when no accessible query can reach the element.
getBy* for what must be present now; queryBy* to assert absence; findBy* (async) for anything that appears after an interaction or fetch. await every userEvent call and every findBy*.
Network — MSW
Mock Service Worker (MSW) intercepts requests at the network layer, so the component runs its real fetch/mutation code against canned responses. Mock at this edge, never by stubbing the modules you own — a deep mock of your own data layer tests the mock, not the code.
import { setupServer } from "msw/node";import { http, HttpResponse } from "msw";
export const server = setupServer( http.get("/api/invoices", () => HttpResponse.json([{ id: "inv_1", total: 14500 }]), ),);
// test setupbeforeAll(() => server.listen({ onUnhandledRequest: "error" }));afterEach(() => server.resetHandlers());afterAll(() => server.close());Rules:
onUnhandledRequest: "error"— an unmocked request is a test failure, not a silent pass. This is also the first line of defense against network flake.- Override per-test with
server.use(...)to model error states (401, 500, empty, slow), thenresetHandlers()inafterEach. - The handlers encode the API contract. When the contract is Zod-defined (
packages/contracts), build handler responses from the same schema so the mock cannot drift from reality.
Accessibility — jest-axe
jest-axe runs the axe-core rule engine against rendered output and fails on WCAG violations. Add it to every component test — it is cheap and it is one of ADR-005’s release gates.
import { axe } from "jest-axe";
it("has no accessibility violations", async () => { const { container } = render(<SubscribeForm />); expect(await axe(container)).toHaveNoViolations();});Scope: automated axe catches roughly 30% of accessibility issues (missing labels, contrast on static output, ARIA misuse, heading order). The remaining 70% — keyboard traps, focus order, screen-reader semantics — needs manual keyboard and screen-reader review and belongs to the design-principles a11y reference, not to this gate. jest-axe is the floor, not the ceiling.
Visual regression — Lost Pixel
Lost Pixel is the house visual-regression tool. It captures screenshots of Storybook stories (or pages) and diffs them against a committed baseline, catching layout, spacing, color, and theme regressions that assertions never see.
Use Lost Pixel, never Chromatic. Chromatic is closed SaaS; Lost Pixel is open-source and self-hostable, which is why ADR-005 and STANDARDS §2 name it as the gate. Do not introduce Chromatic.
Use it on surfaces where a pixel could regress unnoticed: component stories, key page layouts, themed/dark-mode variants. Do not use it as a substitute for behavioral assertions — a green pixel diff says nothing about whether the button works.
- Baselines are committed to the repo and updated deliberately on intended visual change (review the diff before accepting).
- It runs in CI as a release gate; a diff blocks merge until reviewed.
- Pair with stable stories — randomized data or live timestamps make every run a false diff.
E2E — project choice
End-to-end tests drive a full critical journey through real pages, routing, auth, and persistence. STANDARDS §2 leaves the framework to project choice — there is no house default; pick one per project (commonly Playwright) and standardize within that repo.
E2E is the slowest, most brittle layer — keep it thin:
- Cover only critical journeys: at minimum one happy path plus one failure path per journey. Everything else belongs lower in the pyramid.
- Use stable, accessible selectors (role/label/text), the same priority as RTL — not brittle CSS paths.
- Keep the suite under 10 minutes wall-clock in CI; parallelize and shard if it grows past that.
- Retain artifacts (trace, video, screenshot) on failure for diagnosis.
- A flaky E2E is quarantined and fixed like any other flake — never globally retried until green.
When asked “which E2E framework?”, confirm what the project already uses before recommending; do not impose a default the repo has not chosen.