Vitest — Testing Patterns¶
OWNER: marije, judith ALSO_USED_BY: antje LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: vitest ^4.0.18
Overview¶
Standard testing patterns used across GE projects with Vitest v4. Covers unit testing, mocking strategies, async patterns, hooks testing, and snapshots. Agents MUST follow these patterns for consistency across the codebase.
Unit Testing Basics¶
import { describe, it, expect } from "vitest"
import { formatCurrency, slugify } from "@/lib/utils"
describe("formatCurrency", () => {
it("formats euros with two decimal places", () => {
expect(formatCurrency(1234.5, "EUR")).toBe("EUR 1,234.50")
})
it("returns '0.00' for zero", () => {
expect(formatCurrency(0, "EUR")).toBe("EUR 0.00")
})
it("handles negative values", () => {
expect(formatCurrency(-50, "EUR")).toBe("-EUR 50.00")
})
})
CHECK: A unit test is being written.
THEN: Test one behavior per it block.
THEN: Follow Arrange-Act-Assert pattern (setup, execute, verify).
THEN: Test names describe the expected outcome, not the implementation.
Mocking with vi.mock — Full Module Replacement¶
Use vi.mock when you need to completely replace an external dependency.
Vitest hoists vi.mock calls to the top of the file before any imports.
import { describe, it, expect, vi } from "vitest"
import { fetchUser } from "@/lib/api"
import { getUserProfile } from "@/lib/user-service"
// Replaces the entire module — hoisted to top
vi.mock("@/lib/api", () => ({
fetchUser: vi.fn(),
}))
describe("getUserProfile", () => {
it("returns formatted profile from API data", async () => {
// Arrange
vi.mocked(fetchUser).mockResolvedValue({
id: "1",
name: "Test User",
email: "test@example.com",
})
// Act
const profile = await getUserProfile("1")
// Assert
expect(fetchUser).toHaveBeenCalledWith("1")
expect(profile.displayName).toBe("Test User")
})
})
CHECK: You are choosing between vi.mock and vi.spyOn.
IF: You need to isolate from an external dependency (API client, database, third-party SDK).
THEN: Use vi.mock — fully replace the module.
IF: You need to observe calls while keeping real behavior.
THEN: Use vi.spyOn — see next section.
ANTI_PATTERN: Using vi.mock without a factory function.
Mocking with vi.spyOn — Targeted Observation¶
Use vi.spyOn to watch a specific function while preserving its real behavior.
import { describe, it, expect, vi } from "vitest"
import * as mathUtils from "@/lib/math"
describe("calculateTotal", () => {
it("calls applyDiscount with correct args", () => {
const spy = vi.spyOn(mathUtils, "applyDiscount")
mathUtils.calculateTotal(100, 0.1)
expect(spy).toHaveBeenCalledWith(100, 0.1)
expect(spy).toHaveReturnedWith(90) // Real implementation ran
})
it("can override return value for one test", () => {
vi.spyOn(mathUtils, "applyDiscount").mockReturnValue(42)
const result = mathUtils.calculateTotal(100, 0.1)
expect(result).toBe(42)
})
})
ANTI_PATTERN: Using vi.spyOn after the function was already called at import time.
FIX: vi.spyOn only captures calls made after the spy is set. If the module runs code on import, use vi.mock instead.
Mocking Partial Modules¶
When you need to mock one export but keep the rest real:
vi.mock("@/lib/config", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/config")>()
return {
...actual,
getFeatureFlag: vi.fn().mockReturnValue(true),
}
})
CHECK: You are using importOriginal in a vi.mock factory.
THEN: Always type it with typeof import("...") for type safety.
THEN: Spread the original first, then override specific exports.
Testing Async Code¶
describe("async operations", () => {
it("resolves with data on success", async () => {
const data = await fetchData("valid-id")
expect(data).toEqual({ id: "valid-id", name: "Test" })
})
it("rejects with error on failure", async () => {
await expect(fetchData("invalid")).rejects.toThrow("Not found")
})
it("handles concurrent operations", async () => {
const results = await Promise.all([
fetchData("1"),
fetchData("2"),
fetchData("3"),
])
expect(results).toHaveLength(3)
})
})
CHECK: Testing async code.
THEN: Always use async/await — never .then() chains in tests.
THEN: Use rejects.toThrow() for error cases — never try/catch in tests.
Parameterized Tests (Test.each)¶
describe("slugify", () => {
it.each([
["Hello World", "hello-world"],
[" spaces ", "spaces"],
["Special Ch@rs!", "special-chrs"],
["Already-slugified", "already-slugified"],
["UPPERCASE", "uppercase"],
])("converts '%s' to '%s'", (input, expected) => {
expect(slugify(input)).toBe(expected)
})
})
CHECK: Multiple inputs produce predictable outputs.
IF: You are writing 3+ tests that differ only in input/output.
THEN: Use it.each — reduces duplication, makes adding cases trivial.
Testing React Hooks¶
import { renderHook, act } from "@testing-library/react"
import { useCounter } from "@/hooks/use-counter"
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(5))
expect(result.current.count).toBe(5)
})
it("increments correctly", () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it("resets to initial value", () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})
CHECK: Testing a custom hook.
THEN: Use renderHook from @testing-library/react.
THEN: Wrap state updates in act().
THEN: Access return values via result.current.
Testing React Components¶
import { render, screen, fireEvent } from "@testing-library/react"
import { ContactForm } from "@/components/contact-form"
describe("ContactForm", () => {
it("renders all form fields", () => {
render(<ContactForm onSubmit={vi.fn()} />)
expect(screen.getByLabelText("Email")).toBeInTheDocument()
expect(screen.getByLabelText("Name")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument()
})
it("calls onSubmit with form data", async () => {
const onSubmit = vi.fn()
render(<ContactForm onSubmit={onSubmit} />)
await fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "test@example.com" },
})
await fireEvent.change(screen.getByLabelText("Name"), {
target: { value: "Test User" },
})
await fireEvent.click(screen.getByRole("button", { name: "Submit" }))
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
name: "Test User",
})
})
})
CHECK: Testing a React component.
THEN: Use screen queries from Testing Library.
THEN: Prefer getByRole, getByLabelText, getByText — accessible queries.
THEN: Avoid getByTestId unless no semantic alternative exists.
Snapshot Testing¶
import { render } from "@testing-library/react"
import { Badge } from "@/components/ui/badge"
describe("Badge", () => {
it("matches snapshot for default variant", () => {
const { container } = render(<Badge>Status</Badge>)
expect(container.firstChild).toMatchSnapshot()
})
it("matches inline snapshot for specific output", () => {
const { container } = render(<Badge variant="destructive">Error</Badge>)
expect(container.firstChild).toMatchInlineSnapshot(`
<div
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-destructive text-destructive-foreground"
>
Error
</div>
`)
})
})
CHECK: You are considering snapshot testing. IF: The component has stable, well-defined output (badges, icons, static elements). THEN: Snapshots are appropriate — they catch unintended changes. IF: The component has dynamic content or frequent intentional changes. THEN: Do NOT use snapshots — they create noise with constant updates.
ANTI_PATTERN: Snapshot testing entire pages or complex components. FIX: Snapshot only stable leaf components. Test complex components with behavioral assertions.
Fixture Pattern¶
// tests/fixtures/users.ts
export const mockUser = {
id: "usr_123",
name: "Test User",
email: "test@example.com",
role: "admin" as const,
} satisfies User
export const mockUsers = [
mockUser,
{ ...mockUser, id: "usr_456", name: "Other User", role: "viewer" as const },
] satisfies User[]
// In tests
import { mockUser, mockUsers } from "@/tests/fixtures/users"
CHECK: Test data is needed.
IF: The same shape of test data appears in 3+ test files.
THEN: Extract to tests/fixtures/ — single source of truth for test data.
Timer Mocking¶
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("calls function after delay", () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledOnce()
})
})
CHECK: Testing code that uses setTimeout, setInterval, or Date.
THEN: Use vi.useFakeTimers() in beforeEach.
THEN: ALWAYS restore with vi.useRealTimers() in afterEach.
Cross-References¶
READ_ALSO: wiki/docs/stack/vitest/index.md READ_ALSO: wiki/docs/stack/vitest/coverage.md READ_ALSO: wiki/docs/stack/vitest/pitfalls.md READ_ALSO: wiki/docs/stack/vitest/checklist.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/component-patterns.md READ_ALSO: wiki/docs/stack/playwright/patterns.md