Skip to content

Testing Layers — Depth

FieldValue
TypeSkill Resource
Source~/.copilot/skills/quality/references/layers.md
DescriptionNot 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

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 fireEventuserEvent 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):

  1. getByRole (optionally with { name }) — mirrors the accessibility tree.
  2. getByLabelText — form fields.
  3. getByPlaceholderText, getByText, getByDisplayValue.
  4. getByAltText, getByTitle.
  5. 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 setup
beforeAll(() => 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), then resetHandlers() in afterEach.
  • 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.