| name | fe-testing-strategy |
| description | Frontend testing strategy based on the Testing Trophy. Use when writing, reviewing, or refactoring tests. Covers integration-first philosophy, React Testing Library best practices, MSW patterns, Vitest performance tips, and human-readable test conventions. |
Frontend Testing Strategy — Testing Trophy
Philosophy
"Write tests. Not too many. Mostly integration." — Kent C. Dodds
We follow the Testing Trophy approach. The goal is confidence that the app works as users expect, not code coverage percentages. Tests should double as living documentation of the app's expected behavior.
Guiding principle: The more your tests resemble the way your software is used, the more confidence they give you.
The Trophy Layers
| Layer | Purpose | Volume | Tools |
|---|
| Static | Catch typos and type errors at write-time | Always on | TypeScript, Biome |
| Unit | Verify isolated, complex logic | Few | Vitest |
| Integration | Verify user flows through rendered components | Most tests | Vitest + RTL + MSW |
| E2E | Validate critical paths in a real browser | Targeted | Playwright (future) |
Integration tests are the sweet spot: they catch real bugs at reasonable cost and speed.
When to Write What
Integration Tests (Default)
Write integration tests for every user-facing feature. These are the backbone of our test suite.
- Render the full component tree (no shallow rendering, no mocking child components)
- Mock only the network boundary (MSW) — everything else runs for real
- Test complete user flows: render → interact → assert on visible outcome
- Each test file documents "what this feature does" for future developers
Good candidates:
- Form submissions and validation
- Data display after fetching
- Navigation and routing behavior
- Conditional UI (permissions, feature flags, empty states, error states)
- User interactions (clicks, typing, selections, hover)
Unit Tests (Constrained)
Reserve unit tests for small pieces of complex or critical logic that are hard to reach through integration tests.
Good candidates:
- Pure utility functions with many edge cases (date formatting, currency parsing, string manipulation)
- Complex data transformers or reducers
- Auth logic (token parsing, permission checks)
- Mathematical or algorithmic functions
- Validation schemas
Bad candidates (use integration tests instead):
- Component rendering
- Hook behavior that depends on providers
- Anything that requires mocking multiple modules
Project Test Infrastructure
Imports
Always import from our test utilities, not directly from libraries:
import { HttpResponse, mockApi, render, screen } from "@/test/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
Our render wraps components with QueryClientProvider and I18nextProvider automatically.
File Structure
src/components/feature-name/
feature-name.tsx
__tests__/
feature-name.test.tsx # Integration tests
src/lib/
utils.ts
__tests__/
utils.test.ts # Unit tests for pure functions
- Use
__tests__/ directories colocated with the code they test
- Name files
<subject>.test.tsx (not .spec.tsx)
React Testing Library — Best Practices
Query Priority
Query elements the way a user would find them. This is the priority order:
| Priority | Query | When |
|---|
| 1 | getByRole | Buttons, headings, textboxes, links, checkboxes — default choice |
| 2 | getByLabelText | Form fields with associated labels |
| 3 | getByPlaceholderText | Only when there is no label |
| 4 | getByText | Non-interactive content, paragraphs, spans |
| 5 | getByDisplayValue | Filled form elements |
| 6 | getByAltText | Images |
| 7 | getByTestId | Last resort — user cannot see or interact with data-testid |
screen.getByRole("button", { name: /submit/i });
screen.getByRole("heading", { name: /invoice details/i });
screen.getByLabelText(/email address/i);
container.querySelector(".btn-primary");
screen.getByTestId("submit-button");
Always Use screen
render(<MyComponent />);
screen.getByRole("button", { name: /save/i });
const { getByRole } = render(<MyComponent />);
Use user-event, Not fireEvent
user-event simulates real browser interactions (focus, hover, keydown/keyup sequences). fireEvent only dispatches a single DOM event.
import userEvent from "@testing-library/user-event";
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /save/i }));
await user.type(screen.getByLabelText(/name/i), "Alice");
fireEvent.click(screen.getByRole("button", { name: /save/i }));
fireEvent.change(input, { target: { value: "Alice" } });
Exception: fireEvent is acceptable for events that user-event doesn't support (e.g., paste, scroll, custom events).
Async Patterns
Use findBy* to wait for elements to appear:
expect(await screen.findByText("Invoice #123")).toBeInTheDocument();
await expect(screen.findByText("Invoice #123")).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Invoice #123")).toBeInTheDocument();
});
Use waitFor only when you need to wait for something other than element appearance:
await waitFor(() => {
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
await waitFor(() => {
expect(postSpy).toHaveBeenCalledOnce();
});
Use waitForElementToBeRemoved for disappearance:
import { waitForElementToBeRemoved } from "@testing-library/react";
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
waitFor Pitfalls
await waitFor(() => {
await user.click(button);
expect(screen.getByText("Details")).toBeInTheDocument();
});
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Details")).toBeInTheDocument();
});
await waitFor(() => {});
expect(screen.getByText("loaded")).toBeInTheDocument();
await waitFor(() => {
expect(nameField).toHaveValue("Alice");
expect(emailField).toHaveValue("alice@test.com");
});
await waitFor(() => expect(nameField).toHaveValue("Alice"));
expect(emailField).toHaveValue("alice@test.com");
queryBy* Is for Asserting Absence
expect(screen.queryByText("Error")).not.toBeInTheDocument();
expect(screen.getByText("Error")).not.toBeInTheDocument();
Semantic Matchers
expect(button).toBeDisabled();
expect(input).toHaveValue("Alice");
expect(alert).toHaveTextContent(/error/i);
expect(link).toHaveAttribute("href", "/dashboard");
expect(element).toBeVisible();
expect(button.disabled).toBe(true);
expect(input.value).toBe("Alice");
expect(alert.textContent).toContain("error");
Don't Wrap in act()
render() and user-event methods are already wrapped in act(). If you see an act() warning, the fix is to properly await async operations, not to add act() wrappers.
await act(async () => {
render(<MyComponent />);
});
render(<MyComponent />);
await screen.findByText("loaded");
MSW — Network Mocking Strategy
Core Rules
- Never mock
fetch, axios, or wretch directly — always use MSW at the network layer
- Default handlers = happy path — defined in
core-api-mocks.ts
- Test-specific overrides via
server.use() — scoped to single tests
onUnhandledRequest: "error" — already configured in setup.ts, catches unmocked requests
Using mockApi Helper
Our mockApi utility combines MSW handler registration with a vi.fn() spy:
import { HttpResponse, mockApi, render, screen } from "@/test/test-utils";
mockApi.get("/api/v1/invoices", () =>
HttpResponse.json([{ id: "1", number: "INV-001", total: 100 }]),
);
const postSpy = mockApi.post("/api/v1/invoices", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new", ...body }, { status: 201 });
});
expect(postSpy).toHaveBeenCalledOnce();
Testing Error States
import { http } from "msw";
import { server } from "@/test/mocks/server";
it("shows error message when the API returns 500", async () => {
server.use(
http.get("*/api/v1/invoices", () =>
new HttpResponse(null, { status: 500 })
)
);
render(<InvoiceList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
it("shows not-found state on 404", async () => {
server.use(
http.get("*/api/v1/invoices/:id", () =>
new HttpResponse(null, { status: 404 })
)
);
render(<InvoiceDetail id="missing" />);
expect(await screen.findByText(/not found/i)).toBeInTheDocument();
});
Testing Loading States
import { delay, http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
it("shows loading spinner while fetching", async () => {
server.use(
http.get("*/api/v1/invoices", async () => {
await delay("infinite");
return HttpResponse.json([]);
})
);
render(<InvoiceList />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
Handler Organization
- Global defaults go in
src/test/mocks/core-api-mocks.ts
- Feature-specific defaults go in
src/test/mocks/<feature>-mocks.ts
- Test-specific overrides stay in the test file — use
server.use() or mockApi
- Never put error/edge-case handlers in default handlers — those belong in individual tests
Avoid Request Assertions
expect(fetchSpy).toHaveBeenCalledWith("/api/v1/invoices?page=2");
await user.click(screen.getByRole("button", { name: /next page/i }));
expect(await screen.findByText("INV-050")).toBeInTheDocument();
Exception: asserting that a POST/DELETE was called is acceptable when the UI has no visible feedback (e.g., analytics events, fire-and-forget mutations).
Human-Readable Test Patterns
Tests are documentation. A developer reading a test file should understand the feature's behavior without reading the implementation.
Naming: Active Voice, Behavior-Focused
it("displays invoice total after loading", ...);
it("shows validation error when email field is empty", ...);
it("disables submit button while form is submitting", ...);
it("removes the comment from the list when the user deletes it", ...);
it("calls fetchInvoice with the correct ID", ...);
it("sets state to loading", ...);
it("renders a div with className error", ...);
it("should display the invoice total", ...);
Arrange-Act-Assert
Every test has three clear phases separated by blank lines:
it("shows success message after form submission", async () => {
const user = userEvent.setup();
mockApi.post("/api/v1/contacts", () =>
HttpResponse.json({ id: "1" }, { status: 201 })
);
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/message/i), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));
expect(await screen.findByText(/message sent/i)).toBeInTheDocument();
});
Setup Functions (Page Object Pattern)
When 3+ tests share the same rendering/interaction setup, extract a setup function:
function renderInvoiceForm(overrides = {}) {
const user = userEvent.setup();
const props = { clientId: "c1", onSuccess: vi.fn(), ...overrides };
render(<InvoiceForm {...props} />);
return {
typeAmount: (amount: string) =>
user.type(screen.getByLabelText(/amount/i), amount),
typeDescription: (desc: string) =>
user.type(screen.getByLabelText(/description/i), desc),
submit: () =>
user.click(screen.getByRole("button", { name: /create invoice/i })),
findError: () => screen.findByRole("alert"),
props,
};
}
it("shows validation error for negative amount", async () => {
const form = renderInvoiceForm();
await form.typeAmount("-100");
await form.submit();
expect(await form.findError()).toHaveTextContent(/positive/i);
});
Rules:
- Setup functions return interaction helpers and element accessors
- Assertions stay in the test, never in the setup function
- Data factories (
makeComment, makeInvoice) are separate from render setup
Data Factories
function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: crypto.randomUUID(),
number: "INV-001",
total: 1000,
status: "pending",
created_at: new Date().toISOString(),
...overrides,
};
}
Flat Structure Over Deep Nesting
describe(InvoiceList, () => {
it("displays invoices after loading", async () => { ... });
it("shows empty state when there are no invoices", async () => { ... });
it("filters invoices by status when user selects a filter", async () => { ... });
it("shows error message when API fails", async () => { ... });
});
describe(InvoiceList, () => {
describe("when authenticated", () => {
beforeEach(() => { ... });
describe("with invoices", () => {
beforeEach(() => { ... });
describe("when filtering", () => {
it("filters by status", () => { ... });
});
});
});
});
Maximum nesting: 2 levels (describe → it). If you need grouping, use descriptive test names.
Parametric Tests
Use it.each for pure functions with many edge cases:
it.each([
{ input: "hello world", expected: "Hello World" },
{ input: "HELLO", expected: "Hello" },
{ input: "", expected: "" },
{ input: "a", expected: "A" },
])("toTitleCase($input) returns $expected", ({ input, expected }) => {
expect(toTitleCase(input)).toBe(expected);
});
Vitest — Tips, Tricks & Performance
Mocking: vi.spyOn Over vi.mock
Prefer vi.spyOn as the default. vi.mock is a footgun.
| vi.spyOn | vi.mock |
|---|
| Scope | Single function, single test | Entire module, entire file |
| Type safety | Full | Lost (needs vi.mocked()) |
| Hoisting | No hoisting, runs in place | Hoisted to top of file |
| Risk | Low — explicit | High — new imports silently become undefined |
import * as dateUtils from "@/lib/date-utils";
afterEach(() => vi.restoreAllMocks());
it("formats relative time correctly", () => {
vi.spyOn(dateUtils, "now").mockReturnValue(new Date("2026-01-01"));
expect(formatRelativeTime(someDate)).toBe("3 days ago");
});
Use vi.mock only when:
- You need to mock an entire module (heavy dependency, charting library)
- The module cannot be spied on (default exports, non-object exports)
- The mock must be in place before any code runs (e.g., the global
setup.ts mocks for auth and router)
vi.hoisted for Module Mock Variables
When vi.mock needs to reference variables, use vi.hoisted so they're available at hoist-time:
const { mockNavigate } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
}));
vi.mock("@/lib/router/router", () => ({
router: { navigate: mockNavigate },
}));
it("navigates to dashboard after login", async () => {
expect(mockNavigate).toHaveBeenCalledWith({ to: "/dashboard" });
});
Fake Timers
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("debounces search input", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<SearchInput />);
await user.type(screen.getByRole("searchbox"), "query");
vi.advanceTimersByTime(300);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledOnce();
});
});
Important: When combining user-event with fake timers, pass advanceTimers: vi.advanceTimersByTime to userEvent.setup(). Otherwise user-event's internal delays hang.
mockResolvedValue vs mockImplementation
spy.mockResolvedValue({ users: [] });
spy.mockResolvedValueOnce({ users: ["Alice"] }).mockResolvedValueOnce({ users: [] });
spy.mockImplementation((id) =>
id === "1" ? Promise.resolve({ name: "Alice" }) : Promise.reject(new Error("Not found")),
);
Snapshot Testing — Mostly Avoid
Snapshots are an anti-pattern for component testing. They produce noisy diffs, false positives on unrelated changes, and get rubber-stamped in reviews.
Acceptable uses (inline snapshots only):
- Small serialized data structures
- Error message formatting
- Configuration objects
expect(formatCurrency(1234.5, "USD")).toMatchInlineSnapshot('"$1,234.50"');
expect(container).toMatchSnapshot();
Performance Optimizations
For CI:
- Use
--reporter=dot or --reporter=github-actions to reduce log noise
- Consider
--shard=1/N for parallelizing across CI jobs
- Set
pool: "threads" in vitest config for faster execution (vs default forks)
For test code:
- Keep test files independent — no shared mutable state between files
- Import only what you need from test-utils
- Use
mockApi helper instead of raw MSW handler registration (fewer imports)
- Avoid
vi.useFakeTimers() unless testing time-dependent behavior — it slows tests
- Never use
setTimeout or sleep in tests — use findBy*, waitFor, or waitForElementToBeRemoved
QueryClient settings for tests (already configured in test-providers.tsx):
{ queries: { gcTime: 0, retry: false } }
This prevents stale cache between tests and avoids retry delays.
Cleanup
Vitest + RTL handle cleanup automatically. Do not add manual cleanup() calls.
afterEach(() => cleanup());
Common Gotchas
-
Internal function calls bypass mocks: If module A exports foo and bar, and bar calls foo internally, mocking foo from outside won't affect bar's internal call. This is how ESM works.
-
vi.mock factory must return all exports: If you forget a named export in the factory, it silently becomes undefined. Use vi.importActual for partial mocks:
vi.mock("@/lib/utils", async () => {
const actual = await vi.importActual("@/lib/utils");
return { ...actual, heavyFunction: vi.fn() };
});
-
vi.mock is hoisted: Code inside vi.mock factories runs before imports. Variables from the test scope are not available unless created with vi.hoisted.
-
i18n in assertions: Use i18n.t("key") to match translated text rather than hardcoding English strings. This keeps tests working if translations change:
import { i18n } from "@/lib/i18n";
expect(await screen.findByText(i18n.t("common.save"))).toBeInTheDocument();
Anti-Patterns Checklist
| Anti-Pattern | Why It's Bad | Do Instead |
|---|
| Mocking child components (shallow rendering) | Misses integration bugs, couples tests to component structure | Render the full tree |
container.querySelector(".css-class") | Breaks on any styling change | Use RTL queries (getByRole, getByText) |
getByTestId for accessible elements | User can't see test IDs | Use getByRole with name option |
fireEvent for user interactions | Synthetic, misses real event sequences | Use user-event |
| Testing internal state or hook return values | Couples to implementation | Test visible behavior |
Mocking fetch/wretch directly | Fragile, tightly coupled to HTTP client | Use MSW |
.only or .skip in committed code | Other tests stop running in CI | Remove before committing |
done callbacks | Flaky, error-prone | Use async/await |
| Large component snapshots | Noisy diffs, rubber-stamped reviews | Write explicit assertions |
Deeply nested describe with beforeEach chains | Hard to trace test setup | Flat structure + setup functions |
sleep / setTimeout in tests | Slow, flaky | findBy*, waitFor, waitForElementToBeRemoved |
| Asserting on request details | Tests implementation, not behavior | Assert on UI outcome |
expect(x.disabled).toBe(true) | Less readable, less semantic | expect(x).toBeDisabled() |
Quick Reference
import userEvent from "@testing-library/user-event";
import { HttpResponse, mockApi, render, screen } from "@/test/test-utils";
import { i18n } from "@/lib/i18n";
import { MyFeature } from "../my-feature";
function makeItem(overrides = {}) {
return { id: crypto.randomUUID(), name: "Test Item", ...overrides };
}
describe(MyFeature, () => {
it("displays items after loading", async () => {
mockApi.get("/api/v1/items", () =>
HttpResponse.json([makeItem({ name: "Widget" })])
);
render(<MyFeature />);
expect(await screen.findByText("Widget")).toBeInTheDocument();
});
it("creates a new item when user submits the form", async () => {
const user = userEvent.setup();
mockApi.get("/api/v1/items", () => HttpResponse.json([]));
mockApi.post("/api/v1/items", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(makeItem(body), { status: 201 });
});
render(<MyFeature />);
await screen.findByText(i18n.t("items.emptyState"));
await user.type(screen.getByLabelText(/name/i), "New Widget");
await user.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText("New Widget")).toBeInTheDocument();
});
});