Skip to content

Frontend Testing

Agent Quick Rules {#agent-quick-rules}

  • E2E: Playwright in apps/{app}/e2e/; mock APIs with page.route; selectors via roles or data-testid.
  • Unit: Vitest + RTL for hooks, Zod schemas, and complex components/ui/ variants only.
  • MUST NOT unit-test Server Components with RTL; cover via Playwright or extracted pure functions.
  • Trace E2E scenarios to UI page docs and use case test specs where the page composes documented use cases.
  • Coverage: production-tier apps SHOULD meet minimums in section 3 unless a documented exception applies.

Full convention: docs/conventions/frontend/testing.md When generating new files: Load and copy from docs/blueprints/frontend/feature-test-utils.md rather than assembling from examples in this file.


Overview

Frontend tests validate user-visible behavior and non-trivial client logic. They do not validate framework internals. Server state is tested at the API layer; the frontend proves composition, interaction, and error display.


Test pyramid

LayerToolRequirement
E2EPlaywrightREQUIRED for every happy-path user journey per app
UnitVitest + React Testing LibraryREQUIRED for complex hooks, Zustand stores, Zod schemas
ComponentVitest + RTLREQUIRED for shared components/ui/ with non-trivial variants

Coverage tiers

Declare tier in project docs/domain/README.md (same vocabulary as backend). Default is production.

TierVitest line coverage (per app package)Playwright
Production70% minimum on features/** and lib/**All documented happy paths
Internal60%Critical paths only
PrototypeBest-effortSmoke only

CI SHOULD fail when coverage drops below threshold. Use Vitest coverage with coverage.include scoped to features/ and lib/, not app/ route shells.

Terminal window
pnpm exec vitest run --coverage --project apps/web

Fixtures and test utilities

ArtifactLocationRule
Playwright authapps/{app}/e2e/fixtures/auth.tsOne fixture per role; document credentials in project ADR, not committed secrets
API mocksInline page.route or e2e/mocks/{feature}.tsTyped JSON matching OpenAPI shapes
RTL wrappersfeatures/{feature}/__tests__/test-utils.tsxProvide QueryClient with retry: false for hook tests
Seed dataPrefer API setup route or factory in backend test projectMUST NOT duplicate domain rules in frontend fixtures

TanStack Query hook tests

Test observable behavior, not internal query cache keys.

// GOOD: wrapper with QueryClient, assert rendered outcome
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { describe, it, expect, vi } from "vitest"
import { usePostList } from "./usePostList"
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
describe("usePostList", () => {
it("returns posts when API succeeds", async () => {
vi.mock("@/lib/api-client", () => ({
getApiClient: vi.fn().mockResolvedValue({
GET: vi.fn().mockResolvedValue({ data: [{ id: "1", title: "A" }] }),
}),
}))
const { result } = renderHook(() => usePostList(), { wrapper })
await waitFor(() => expect(result.current.data).toHaveLength(1))
})
})
// BAD: assert queryClient.getQueryCache().getAll().length

Server Action tests

Server Actions run on the server. Test them as async functions with mocked getApiClient and session, not as RTL components.

// GOOD: invoke action directly with mocked dependencies
import { describe, it, expect, vi } from "vitest"
import { createPostAction } from "./createPost.action"
describe("createPostAction", () => {
it("returns validation error for empty title", async () => {
const result = await createPostAction({ title: "", content: "x" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.fieldErrors.title).toBeDefined()
})
})

Document the action’s contract in the use case doc UI section. E2E covers the full browser path.


Playwright E2E rules

  • Tests live in apps/{app}/e2e/. Multi-app monorepos use one config per app (docs/templates/config/playwright.config.ts).
  • Link scenarios to UI page docs in test file header comments: // Page: docs/ui/web/pages/create-post.md.
  • When a page composes use cases, trace assertions to rows in {use-case}.tests.md where applicable.
// GOOD: accessible role selector and mocked API
import { test, expect } from "@playwright/test"
test("user can publish post", async ({ page }) => {
await page.route("**/api/posts", (route) =>
route.fulfill({ json: { id: "123" } })
)
await page.goto("/posts/new")
await page.getByRole("textbox", { name: "Title" }).fill("My Post")
await page.getByRole("button", { name: "Publish" }).click()
await expect(page.getByText("Post created successfully")).toBeVisible()
})
// BAD: brittle CSS selector and live API dependency
test("publish", async ({ page }) => {
await page.goto("/posts/new")
await page.click(".btn-primary")
await expect(page.locator("#toast")).toBeVisible()
})

Strict mocking guidelines

AllowedForbidden
Mock getApiClient, auth(), headers() in unit testsMocking child components to avoid RTL interaction
page.route for E2ELive third-party APIs in CI
vi.mock of module boundariesSpying on React internal state

CI verification

When frontend code changes, agents MUST run gates for each affected app:

Terminal window
pnpm run type-check --filter apps/web
pnpm run test --filter apps/web
pnpm exec playwright test --config apps/web/playwright.config.ts

Anti-patterns

  • Duplicating backend validation matrices in frontend unit tests without Zod schema ownership.
  • Shared global Playwright storage state mutated across unrelated specs.
  • Testing Tailwind class names instead of roles or visible text.

DocumentTopic
state-management.mdZustand store unit tests
data-fetching.mdServer Actions and mutations
docs/conventions/backend/testing.mdBackend coverage tiers
docs/conventions/shared/ci.mdCI gate list