| name | description |
|---|---|
testing |
Write comprehensive unit and integration tests following project conventions and best practices |
- Runner: Vitest (jsdom) — globals enabled (
describe,it,expect, etc.) - React:
@testing-library/react,@testing-library/user-event - API Mocking: MSW (Mock Service Worker)
| Convention | Value |
|---|---|
| Extension | .spec.ts / .spec.tsx (NOT .test.ts) |
| Location | __specs__ directories (NOT __tests__) |
| Structure | Mirror source structure within __specs__ |
src/features/auth/
├── pages/login-page.tsx
├── components/registration-flow.tsx
└── __specs__/
├── pages/login-page.spec.tsx
└── components/registration-flow.spec.tsx
- Plan first — List all test cases, get user confirmation before writing
- Write iteratively — One test at a time, run and pass before moving on
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
it("handles user input", async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByLabelText(/email/i), "user@example.com")
await user.click(screen.getByRole("button", { name: /submit/i }))
})await waitFor(() => {
expect(screen.getByText("Loading complete")).toBeInTheDocument()
}, { timeout: 3000 })userEvent handles act() internally. Manual wrapping causes issues with subscription-based reactivity.
// ✅ CORRECT
await user.click(screen.getByRole("button", { name: /submit/i }))
// ❌ WRONG — userEvent already handles act()
act(() => { await user.click(button) })Only write tests that prevent real bugs, not tests that achieve coverage metrics.
- Complex business logic with multiple branches/edge cases
- Data transformations (parsing, validation, normalization)
- Error handling with multiple states
- Critical user paths (auth, payments, data submission)
- Bug-prone or frequently modified code
- Simple pass-through functions, trivial getters/setters
- Framework code (React, Zustand, TanStack Query itself)
- Pure I/O without transformation logic
- Obvious behavior (
return a + b), static config objects
Ask: "If this breaks, how would we know?"
- User reports bug → HIGH ROI
- TypeScript/CI catches it → LOW ROI
- Silent failure → HIGH ROI
Rule: 10 tests for critical code > 100 tests for trivial code
// ❌ LOW ROI — Trivial Zustand store
export const useRegisterStore = create((set) => ({
password: null,
setPassword: (password) => set({ password })
}))
// ❌ LOW ROI — Thin I/O wrapper
export async function saveStepData<T>(step: 1|2|3, data: T): Promise<void> {
await registerStorage.setItem(getStepKey(step), data)
}
// ✅ HIGH ROI — Complex validation with branches
export async function validateStepAccess(requestedStep: number): Promise<StepValidationResult> {
if (requestedStep === 1) return { canAccess: true, redirectTo: null }
const step1Complete = await checkStep1()
if (!step1Complete) return { canAccess: false, redirectTo: 1 }
// ... more branches
}
// ✅ HIGH ROI — Error transformation
export function normalizeApiError(error: unknown): FriendlyError {
if (error instanceof NetworkError) return { message: "Connection failed", code: "NETWORK" }
if (error instanceof ValidationError) return { message: error.details, code: "VALIDATION" }
return { message: "Unknown error", code: "UNKNOWN" }
}Don't skip testing based on function names. HTTP/database functions often contain pure logic worth testing.
Unit-testable in I/O functions:
- Request/response transformations
- Header construction, URL building
- Error normalization, status code routing
- Parse/validation logic
Requires integration tests:
- Actual network calls, real database ops, file system I/O
Ask: "Would a consumer of this API care about this test?"
- Test breaks on internal refactor → testing implementation ❌
- Test breaks on behavior change → testing behavior ✅
| ✅ Assert (Behavior) | ❌ Avoid (Implementation) |
|---|---|
| Return values | Mock call arguments structure |
| Thrown errors | Internal function calls |
| Observable side effects | mockFetch.mock.calls[0] inspection |
| Error messages | expect(mockFn).toHaveBeenCalledWith(exactArgs) |
// ❌ BAD — Testing implementation
it("calls fetch with correct headers", async () => {
await httpResource("/api/test")
expect(mockFetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({
headers: expect.objectContaining({ "Content-Type": "application/json" })
}))
})
// ✅ GOOD — Testing behavior
it("returns data when authenticated", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ data: "test" }), { status: 200 }))
const result = await httpResource("/api/test")
expect(result).toEqual({ data: "test" })
})Keep internal functions private. Test the public contract.
Use object-based test cases for readability:
const testCases = [
{ description: "value within range", value: 5, min: 0, max: 10, expected: 5 },
{ description: "value below min", value: -5, min: 0, max: 10, expected: 0 },
{ description: "value above max", value: 15, min: 0, max: 10, expected: 10 },
]
it.each(testCases)("returns correct value when $description", ({ value, min, max, expected }) => {
expect(clamp(value, min, max)).toBe(expected)
})Benefits: Self-documenting, no positional confusion, $description interpolation
import * as v from "valibot"
// ✅ Valid inputs — use schemaMatching
it.each(validNames)("accepts %s: %s", (_, name) => {
expect(name).toEqual(expect.schemaMatching(NameSchema))
})
// ✅ Invalid inputs — use safeParse for error access
it.each(invalidNames)("rejects %s", (_, name, expectedMessage) => {
const result = v.safeParse(NameSchema, name)
expect(result.success).toBe(false)
if (!result.success) expect(result.issues[0].message).toBe(expectedMessage)
})
// Transformation test — need safeParse for output
const result = v.safeParse(EmailSchema, "USER@EXAMPLE.COM")
expect(result.output).toBe("user@example.com")-
.spec.ts(x)extension in__specs__directory (mirrored structure) -
userEventfor all interactions, no manualact()around it -
waitForfor async operations - Object-based
it.eachtest cases -
expect.schemaMatching()for valid schema inputs - Tests behavior, not implementation
- Reasonable test count — no redundant assertions
- All tests pass with zero warnings
// Multiple buttons — use specific identifiers
screen.getByRole("button", { name: /browse files/i }) // ✅
screen.getByRole("button") // ❌ fails with multiple buttons
// State updates — use act() for DOM events, but NOT with userEvent
act(() => { component.dispatchEvent(dragEnterEvent) }) // ✅ DOM events
await user.click(button) // ✅ userEvent handles act() internallypnpm test # Run all
pnpm test path/to/file.spec.tsx # Run specific file
vitest # Watch mode
vitest --coverage # With coverage