Unit Testing
Jest and Vitest setup, React Testing Library, mocking strategies, testing hooks and Server Components, async patterns, snapshot testing, code coverage, and test factories — everything you need to write reliable unit tests for modern React and Next.js applications.
Unit Testing
Jest and Vitest setup, React Testing Library, mocking strategies, testing hooks and Server Components, async patterns, snapshot testing, code coverage, and test factories — everything you need to write reliable unit tests for modern React and Next.js applications.
Principles
1. Test Runner Setup — Vitest vs Jest
Vitest is the recommended test runner for modern projects. It uses the same config as Vite, supports ESM natively, and runs significantly faster than Jest. Jest remains the standard for projects already using it or those with complex configurations that predate Vite.
When to choose Vitest:
- New projects or projects using Vite/Next.js with Turbopack
- You want native ESM and TypeScript support without transpilation config
- You want faster test execution via Vite's transform pipeline
- You want a Jest-compatible API (nearly drop-in replacement)
When to choose Jest:
- Existing project with extensive Jest config and custom transforms
- You need a specific Jest ecosystem plugin that has no Vitest equivalent
- Your team already knows Jest deeply
Vitest setup for Next.js:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "e2e"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}", "app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"],
exclude: [
"**/*.d.ts",
"**/*.test.{ts,tsx}",
"**/types/**",
"**/__mocks__/**",
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});// tests/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// Automatic cleanup after each test
afterEach(() => {
cleanup();
});
// Mock Next.js router
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
}));
// Mock Next.js Image component
vi.mock("next/image", () => ({
default: (props: Record<string, unknown>) => {
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
const { fill, priority, ...rest } = props;
return <img {...rest} />;
},
}));Jest setup for Next.js:
// jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest";
const createJestConfig = nextJest({ dir: "./" });
const config: Config = {
testEnvironment: "jsdom",
setupFilesAfterSetup: ["<rootDir>/tests/setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
testMatch: ["**/*.test.{ts,tsx}"],
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"app/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
"!**/*.d.ts",
"!**/types/**",
],
};
export default createJestConfig(config);2. Test File Structure and Organization
Good test organization makes tests discoverable, readable, and maintainable. Co-locate tests next to the code they test.
File naming conventions:
component-name.test.tsxfor React componentsutility-name.test.tsfor pure functions and utilitieshook-name.test.tsfor custom hooksroute-name.test.tsfor API route handlers
Recommended structure — co-located tests:
app/
dashboard/
page.tsx
page.test.tsx
_components/
stats-card.tsx
stats-card.test.tsx
lib/
utils/
format-currency.ts
format-currency.test.ts
hooks/
use-debounce.ts
use-debounce.test.tsTest structure with describe/it blocks:
import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("formatCurrency", () => {
// Group related behaviors
describe("with USD", () => {
it("formats whole numbers without decimals", () => {
expect(formatCurrency(100, "USD")).toBe("$100.00");
});
it("formats decimal amounts correctly", () => {
expect(formatCurrency(99.99, "USD")).toBe("$99.99");
});
it("formats negative amounts with a minus sign", () => {
expect(formatCurrency(-50, "USD")).toBe("-$50.00");
});
});
describe("with EUR", () => {
it("uses euro symbol", () => {
expect(formatCurrency(100, "EUR")).toMatch(/100/);
});
});
describe("edge cases", () => {
it("handles zero", () => {
expect(formatCurrency(0, "USD")).toBe("$0.00");
});
it("throws for invalid currency code", () => {
expect(() => formatCurrency(100, "INVALID")).toThrow();
});
});
});Setup and teardown:
describe("UserService", () => {
let service: UserService;
// Runs once before all tests in this describe block
beforeAll(async () => {
await connectToTestDatabase();
});
// Runs before each individual test
beforeEach(() => {
service = new UserService();
});
// Runs after each individual test
afterEach(async () => {
await clearTestData();
});
// Runs once after all tests in this describe block
afterAll(async () => {
await disconnectFromTestDatabase();
});
it("creates a user", async () => {
const user = await service.create({ name: "Alice", email: "alice@test.com" });
expect(user.id).toBeDefined();
expect(user.name).toBe("Alice");
});
});3. Testing React Components with Testing Library
React Testing Library enforces testing components the way users interact with them — by querying the DOM as a user would, not by inspecting component internals.
Core principle: Test behavior, not implementation.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { LoginForm } from "./login-form";
describe("LoginForm", () => {
it("renders email and password fields", () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});
it("submits the form with entered values", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "password123",
});
});
it("shows validation error for empty email", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
it("disables submit button while loading", () => {
render(<LoginForm onSubmit={vi.fn()} isLoading />);
expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
});
});Query priority (use in this order):
getByRole— accessible role queries (best for accessibility)getByLabelText— form field labelsgetByPlaceholderText— input placeholdersgetByText— visible text contentgetByDisplayValue— current form input valuesgetByAltText— image alt textgetByTestId— last resort,data-testidattributes
Query variants:
| Variant | No Match | 1 Match | 1+ Matches | Async? |
|---|---|---|---|---|
getBy | Throws | Returns | Throws | No |
queryBy | null | Returns | Throws | No |
findBy | Throws | Returns | Throws | Yes |
getAllBy | Throws | Array | Array | No |
queryAllBy | [] | Array | Array | No |
findAllBy | Throws | Array | Array | Yes |
Use queryBy when testing that something does NOT exist:
// Correct: use queryBy for asserting absence
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
// Wrong: getBy would throw before the assertion runs
// expect(screen.getByText(/error/i)).not.toBeInTheDocument();Use findBy when waiting for async content:
// Waits for element to appear (default timeout: 1000ms)
const successMessage = await screen.findByText(/successfully saved/i);
expect(successMessage).toBeInTheDocument();Testing with user events:
import userEvent from "@testing-library/user-event";
it("handles complex user interactions", async () => {
const user = userEvent.setup();
render(<SearchFilter />);
// Type in search box
await user.type(screen.getByRole("searchbox"), "react");
// Select from dropdown
await user.selectOptions(screen.getByRole("combobox"), "newest");
// Toggle checkbox
await user.click(screen.getByRole("checkbox", { name: /include archived/i }));
// Clear and retype
await user.clear(screen.getByRole("searchbox"));
await user.type(screen.getByRole("searchbox"), "vitest");
// Tab to next element
await user.tab();
// Keyboard shortcut
await user.keyboard("{Control>}k{/Control}");
});Testing components with context providers:
// tests/utils.tsx — reusable render with providers
import { render, type RenderOptions } from "@testing-library/react";
import { ThemeProvider } from "@/providers/theme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light">
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Usage in tests
import { renderWithProviders } from "@/tests/utils";
it("renders themed component", () => {
renderWithProviders(<ThemedButton>Click me</ThemedButton>);
expect(screen.getByRole("button")).toHaveClass("theme-light");
});4. Mocking
Mocking isolates the code under test by replacing dependencies with controlled substitutes. Use mocking strategically — over-mocking leads to brittle tests that pass but miss real bugs.
Mocking modules:
import { vi, describe, it, expect } from "vitest";
import { createUser } from "./user-service";
// Mock the entire database module
vi.mock("@/lib/db", () => ({
db: {
user: {
create: vi.fn(),
findUnique: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("createUser", () => {
it("creates a user and returns it", async () => {
const mockUser = { id: "1", name: "Alice", email: "alice@test.com" };
vi.mocked(db.user.create).mockResolvedValue(mockUser);
const result = await createUser({ name: "Alice", email: "alice@test.com" });
expect(db.user.create).toHaveBeenCalledWith({
data: { name: "Alice", email: "alice@test.com" },
});
expect(result).toEqual(mockUser);
});
});Mocking API calls with MSW (Mock Service Worker):
MSW intercepts network requests at the service worker level, making it the most realistic way to mock APIs.
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () => {
return HttpResponse.json({
data: [
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
],
meta: { total: 2 },
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ data: { id: "3", ...body } },
{ status: 201 }
);
}),
http.get("/api/users/:id", ({ params }) => {
if (params.id === "404") {
return HttpResponse.json(
{ error: { code: "NOT_FOUND", message: "User not found" } },
{ status: 404 }
);
}
return HttpResponse.json({
data: { id: params.id, name: "Alice", email: "alice@example.com" },
});
}),
];// tests/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);// tests/setup.ts — add MSW lifecycle
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());// Override handlers per test for error scenarios
import { server } from "@/tests/mocks/server";
import { http, HttpResponse } from "msw";
it("shows error when API fails", async () => {
server.use(
http.get("/api/users", () => {
return HttpResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Server error" } },
{ status: 500 }
);
})
);
render(<UserList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});Mocking Next.js router:
import { vi } from "vitest";
import { useRouter, usePathname } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
usePathname: vi.fn(),
useSearchParams: vi.fn(() => new URLSearchParams()),
}));
it("redirects after successful submission", async () => {
const push = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
});
vi.mocked(usePathname).mockReturnValue("/dashboard");
const user = userEvent.setup();
render(<CreatePostForm />);
await user.type(screen.getByLabelText(/title/i), "My Post");
await user.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(push).toHaveBeenCalledWith("/posts/my-post");
});
});Spy functions:
// Spy on an existing function without replacing it
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
it("logs errors", () => {
processData(invalidData);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid data")
);
});
afterEach(() => {
consoleSpy.mockRestore();
});Mocking environment variables:
it("uses production API URL in production", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
expect(getApiUrl()).toBe("https://api.example.com");
vi.unstubAllEnvs();
});5. Testing Hooks
Custom hooks are tested using renderHook from React Testing Library. This renders the hook inside a test component and gives you access to its return value.
import { renderHook, act, waitFor } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useCounter } from "./use-counter";
describe("useCounter", () => {
it("initializes with default value", () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it("initializes with custom value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments the count", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements the count", () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
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);
});
});Testing hooks with context providers:
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useUsers } from "./use-users";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
describe("useUsers", () => {
it("fetches users", async () => {
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for data
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(2);
expect(result.current.data[0].name).toBe("Alice");
});
});Testing async hooks with debounce:
import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import { useDebounce } from "./use-debounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("debounces value changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
// Update the value
rerender({ value: "world", delay: 500 });
// Value should not have changed yet
expect(result.current).toBe("hello");
// Advance timers past the debounce delay
act(() => {
vi.advanceTimersByTime(500);
});
// Now the value should be updated
expect(result.current).toBe("world");
});
});6. Testing Server Components and Server Actions
Server Components and Server Actions require different testing strategies since they run on the server and may access databases, file systems, or external APIs directly.
Testing Server Components:
Server Components are async functions that return JSX. Test them by calling the function directly and rendering the result.
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import UserProfile from "./page";
// Mock the data layer
vi.mock("@/lib/db", () => ({
db: {
user: {
findUnique: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("UserProfile page", () => {
it("renders user information", async () => {
vi.mocked(db.user.findUnique).mockResolvedValue({
id: "1",
name: "Alice",
email: "alice@example.com",
bio: "Software engineer",
});
// Server Components are async — await them
const ui = await UserProfile({ params: { id: "1" } });
render(ui);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Software engineer")).toBeInTheDocument();
});
it("shows not found for missing user", async () => {
vi.mocked(db.user.findUnique).mockResolvedValue(null);
const ui = await UserProfile({ params: { id: "999" } });
render(ui);
expect(screen.getByText(/user not found/i)).toBeInTheDocument();
});
});Testing Server Actions:
import { describe, it, expect, vi } from "vitest";
import { createPost } from "./actions";
vi.mock("@/lib/db", () => ({
db: {
post: {
create: vi.fn(),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
describe("createPost action", () => {
it("creates a post and revalidates", async () => {
vi.mocked(db.post.create).mockResolvedValue({
id: "1",
title: "Test Post",
content: "Content here",
status: "draft",
});
const formData = new FormData();
formData.set("title", "Test Post");
formData.set("content", "Content here");
const result = await createPost(formData);
expect(db.post.create).toHaveBeenCalledWith({
data: {
title: "Test Post",
content: "Content here",
status: "draft",
},
});
expect(revalidatePath).toHaveBeenCalledWith("/posts");
expect(result).toEqual({ success: true });
});
it("returns validation errors for invalid input", async () => {
const formData = new FormData();
formData.set("title", "");
formData.set("content", "");
const result = await createPost(formData);
expect(result).toEqual({
success: false,
errors: expect.objectContaining({
title: expect.any(String),
}),
});
expect(db.post.create).not.toHaveBeenCalled();
});
});Testing API Route Handlers:
import { describe, it, expect, vi } from "vitest";
import { GET, POST } from "./route";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
db: {
post: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("POST /api/posts", () => {
it("creates a post and returns 201", async () => {
const mockPost = {
id: "1",
title: "Test",
content: "Content",
status: "draft",
};
vi.mocked(db.post.create).mockResolvedValue(mockPost);
const request = new NextRequest("http://localhost:3000/api/posts", {
method: "POST",
body: JSON.stringify({
title: "Test",
content: "Content",
}),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(201);
expect(body.data).toEqual(mockPost);
});
it("returns 400 for invalid input", async () => {
const request = new NextRequest("http://localhost:3000/api/posts", {
method: "POST",
body: JSON.stringify({ title: "" }),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error.code).toBe("VALIDATION_ERROR");
});
});
describe("GET /api/posts", () => {
it("returns paginated posts", async () => {
vi.mocked(db.post.findMany).mockResolvedValue([
{ id: "1", title: "Post 1", content: "Content 1", status: "published" },
{ id: "2", title: "Post 2", content: "Content 2", status: "draft" },
]);
const request = new NextRequest(
"http://localhost:3000/api/posts?limit=20"
);
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data).toHaveLength(2);
expect(body.meta).toBeDefined();
});
});7. Async Testing Patterns
Async operations — data fetching, timers, animations — require special handling to avoid flaky tests.
waitFor — wait for an assertion to pass:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays users", async () => {
render(<UserList />);
// Shows loading state initially
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for users to appear
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
// Loading indicator should be gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});findBy — shorthand for waitFor + getBy:
it("displays success message after save", async () => {
const user = userEvent.setup();
render(<SettingsForm />);
await user.click(screen.getByRole("button", { name: /save/i }));
// findBy automatically waits
const message = await screen.findByText(/settings saved/i);
expect(message).toBeInTheDocument();
});Testing error states:
it("shows error message when fetch fails", async () => {
// Override MSW handler for this test
server.use(
http.get("/api/users", () => {
return HttpResponse.json(
{ error: { message: "Internal server error" } },
{ status: 500 }
);
})
);
render(<UserList />);
const errorMessage = await screen.findByRole("alert");
expect(errorMessage).toHaveTextContent(/something went wrong/i);
// Retry button should be visible
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});Fake timers for testing debounce, polling, and timeouts:
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
describe("AutoSave", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-saves after 2 seconds of inactivity", async () => {
const onSave = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<AutoSaveEditor onSave={onSave} />);
await user.type(screen.getByRole("textbox"), "Hello");
// Not saved yet (debounce period hasn't elapsed)
expect(onSave).not.toHaveBeenCalled();
// Advance past debounce delay
act(() => {
vi.advanceTimersByTime(2000);
});
expect(onSave).toHaveBeenCalledWith("Hello");
});
});8. Snapshot Testing
Snapshot testing captures a component's rendered output and compares it against a saved reference. Use sparingly — for stable UI components where you want to detect unintended changes.
When to use snapshots:
- Icon components, SVG wrappers
- Static marketing pages or email templates
- Component libraries with stable APIs
- Serialized data structures
When NOT to use snapshots:
- Components with dynamic data (timestamps, random IDs)
- Rapidly evolving components during active development
- Components where behavioral tests are more valuable
it("renders the logo correctly", () => {
const { container } = render(<Logo size="large" />);
expect(container.firstChild).toMatchSnapshot();
});Inline snapshots — better for small outputs:
it("formats user display name", () => {
expect(formatDisplayName({ firstName: "Alice", lastName: "Smith" }))
.toMatchInlineSnapshot(`"Alice Smith"`);
});
it("formats user initials", () => {
expect(getInitials({ firstName: "Alice", lastName: "Smith" }))
.toMatchInlineSnapshot(`"AS"`);
});Updating snapshots when intentional changes are made:
# Update all snapshots
vitest --update
# or
jest --updateSnapshot
# Update snapshots interactively
vitest --ui9. Code Coverage
Code coverage measures how much of your code is exercised by tests. It is a useful signal but not a goal — 100% coverage does not mean 100% correctness.
Coverage configuration (Vitest):
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8", // or "istanbul"
reporter: ["text", "json", "html", "lcov"],
include: [
"src/**/*.{ts,tsx}",
"app/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
],
exclude: [
"**/*.d.ts",
"**/*.test.{ts,tsx}",
"**/*.stories.{ts,tsx}",
"**/types/**",
"**/__mocks__/**",
"**/index.ts", // barrel files
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Running coverage:
# Generate coverage report
vitest run --coverage
# Watch mode with coverage
vitest --coverageWhat the metrics mean:
| Metric | What it measures | What it misses |
|---|---|---|
| Statements | % of statements executed | Dead code after early returns |
| Branches | % of if/else/switch branches taken | Complex boolean logic |
| Functions | % of functions called | Functions called with wrong args |
| Lines | % of lines executed | Multi-statement lines |
What coverage does NOT tell you:
- Whether your assertions are correct (test could execute code without checking results)
- Whether edge cases are covered (you might hit the happy path but miss null/undefined)
- Whether the code is correct (covered code can still have bugs)
- Whether integration between units works (unit coverage says nothing about integration)
v8 vs istanbul:
- v8: Faster, uses V8's built-in coverage. Occasionally less accurate for complex source maps.
- istanbul: Slower, instrumenting-based. More mature, better source map support, more precise for complex transformations.
10. Test Factories and Fixtures
Test factories create realistic test data consistently across your test suite. They prevent copy-pasting mock objects and make tests more readable.
Factory function pattern:
// tests/factories/user.ts
import { faker } from "@faker-js/faker";
interface UserOverrides {
id?: string;
name?: string;
email?: string;
role?: "admin" | "user" | "moderator";
createdAt?: Date;
}
export function createUser(overrides: UserOverrides = {}) {
return {
id: overrides.id ?? faker.string.uuid(),
name: overrides.name ?? faker.person.fullName(),
email: overrides.email ?? faker.internet.email(),
role: overrides.role ?? "user",
createdAt: overrides.createdAt ?? faker.date.past(),
updatedAt: new Date(),
};
}
export function createUsers(count: number, overrides: UserOverrides = {}) {
return Array.from({ length: count }, () => createUser(overrides));
}// tests/factories/post.ts
import { faker } from "@faker-js/faker";
import { createUser } from "./user";
interface PostOverrides {
id?: string;
title?: string;
content?: string;
status?: "draft" | "published" | "archived";
authorId?: string;
}
export function createPost(overrides: PostOverrides = {}) {
return {
id: overrides.id ?? faker.string.uuid(),
title: overrides.title ?? faker.lorem.sentence(),
content: overrides.content ?? faker.lorem.paragraphs(3),
status: overrides.status ?? "draft",
authorId: overrides.authorId ?? faker.string.uuid(),
createdAt: faker.date.past(),
updatedAt: new Date(),
};
}Using factories in tests:
import { createUser, createUsers } from "@/tests/factories/user";
import { createPost } from "@/tests/factories/post";
describe("UserDashboard", () => {
it("displays user posts", async () => {
const user = createUser({ name: "Alice" });
const posts = [
createPost({ authorId: user.id, title: "First Post", status: "published" }),
createPost({ authorId: user.id, title: "Draft Post", status: "draft" }),
];
vi.mocked(db.user.findUnique).mockResolvedValue(user);
vi.mocked(db.post.findMany).mockResolvedValue(posts);
render(<UserDashboard userId={user.id} />);
expect(await screen.findByText("First Post")).toBeInTheDocument();
expect(screen.getByText("Draft Post")).toBeInTheDocument();
});
it("shows empty state for users with no posts", async () => {
const user = createUser();
vi.mocked(db.user.findUnique).mockResolvedValue(user);
vi.mocked(db.post.findMany).mockResolvedValue([]);
render(<UserDashboard userId={user.id} />);
expect(await screen.findByText(/no posts yet/i)).toBeInTheDocument();
});
});Builder pattern for complex objects:
// tests/factories/order.ts
class OrderBuilder {
private order: Record<string, unknown> = {
id: faker.string.uuid(),
status: "pending",
items: [],
total: 0,
createdAt: new Date(),
};
withStatus(status: string) {
this.order.status = status;
return this;
}
withItems(items: Array<{ name: string; price: number; quantity: number }>) {
this.order.items = items;
this.order.total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return this;
}
withShipping(address: Record<string, string>) {
this.order.shippingAddress = address;
return this;
}
build() {
return { ...this.order };
}
}
export function orderBuilder() {
return new OrderBuilder();
}
// Usage
const order = orderBuilder()
.withStatus("confirmed")
.withItems([
{ name: "Widget", price: 9.99, quantity: 2 },
{ name: "Gadget", price: 24.99, quantity: 1 },
])
.withShipping({ city: "Portland", state: "OR" })
.build();11. CI Integration
Run tests automatically on every push and pull request to catch regressions early.
GitHub Actions workflow:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- run: npm ci
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/LLM Instructions
Writing Unit Tests for a Component
When generating unit tests for a React component:
- Import from
@testing-library/reactand@testing-library/user-event - Use
screenqueries, preferringgetByRoleandgetByLabelTextovergetByTestId - Set up
userEvent.setup()before render for interaction tests - Test user-facing behavior, not implementation details (do not test state values directly)
- Mock only external dependencies (API calls, router, database) not internal modules
- Use
vi.fn()for callback props and assert they are called with expected arguments - Include tests for: initial render, user interactions, loading states, error states, edge cases
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
describe("ComponentName", () => {
it("renders initial state correctly", () => {
render(<ComponentName />);
expect(screen.getByRole("heading")).toHaveTextContent("Expected Title");
});
it("handles user interaction", async () => {
const user = userEvent.setup();
const onAction = vi.fn();
render(<ComponentName onAction={onAction} />);
await user.click(screen.getByRole("button", { name: /action/i }));
expect(onAction).toHaveBeenCalledWith(expectedArgs);
});
});Writing Tests for Server Actions
When generating tests for Next.js Server Actions:
- Mock
@/lib/dbandnext/cache(revalidatePath, revalidateTag) - Create FormData objects to pass as arguments
- Test both success and validation failure paths
- Assert database calls and cache revalidation
- Never test the action through UI rendering — test the function directly
Writing Tests for API Route Handlers
When generating tests for Next.js API Route Handlers:
- Import the handler functions (GET, POST, PUT, DELETE) directly
- Create
NextRequestobjects with appropriate URL, method, body, and headers - Assert both the response status code and body content
- Test validation errors, auth failures, and not-found scenarios
- Mock the database layer, not the HTTP layer
Setting Up Test Infrastructure
When setting up a test suite for a new project:
- Install:
vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event msw @faker-js/faker - Create
vitest.config.tswith jsdom environment and path aliases - Create
tests/setup.tswith cleanup, jest-dom matchers, and Next.js mocks - Create
tests/mocks/handlers.tsfor MSW API mocks - Create
tests/factories/for test data factories - Add coverage thresholds to prevent regression
Examples
1. Complete Component Test Suite
A comprehensive test suite for a search component with debounced input, API fetching, and result rendering:
// components/search.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { server } from "@/tests/mocks/server";
import { http, HttpResponse } from "msw";
import { Search } from "./search";
describe("Search", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("renders search input with placeholder", () => {
render(<Search />);
expect(screen.getByRole("searchbox")).toHaveAttribute(
"placeholder",
"Search..."
);
});
it("debounces search input and fetches results", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
server.use(
http.get("/api/search", ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
return HttpResponse.json({
data: [
{ id: "1", title: `Result for "${q}"` },
],
});
})
);
render(<Search />);
await user.type(screen.getByRole("searchbox"), "react");
// Advance past debounce delay
act(() => {
vi.advanceTimersByTime(300);
});
// Wait for results
expect(await screen.findByText(/result for "react"/i)).toBeInTheDocument();
});
it("shows empty state when no results found", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
server.use(
http.get("/api/search", () => {
return HttpResponse.json({ data: [] });
})
);
render(<Search />);
await user.type(screen.getByRole("searchbox"), "xyznonexistent");
act(() => {
vi.advanceTimersByTime(300);
});
expect(await screen.findByText(/no results/i)).toBeInTheDocument();
});
it("shows error state when search fails", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
server.use(
http.get("/api/search", () => {
return HttpResponse.json(
{ error: { message: "Search unavailable" } },
{ status: 503 }
);
})
);
render(<Search />);
await user.type(screen.getByRole("searchbox"), "test");
act(() => {
vi.advanceTimersByTime(300);
});
expect(await screen.findByRole("alert")).toBeInTheDocument();
});
it("clears results when input is emptied", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
server.use(
http.get("/api/search", () => {
return HttpResponse.json({
data: [{ id: "1", title: "Some Result" }],
});
})
);
render(<Search />);
await user.type(screen.getByRole("searchbox"), "test");
act(() => {
vi.advanceTimersByTime(300);
});
expect(await screen.findByText("Some Result")).toBeInTheDocument();
await user.clear(screen.getByRole("searchbox"));
act(() => {
vi.advanceTimersByTime(300);
});
await waitFor(() => {
expect(screen.queryByText("Some Result")).not.toBeInTheDocument();
});
});
});2. Complete API Route Handler Test Suite
// app/api/posts/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "./route";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
db: {
post: {
findMany: vi.fn(),
create: vi.fn(),
count: vi.fn(),
},
},
}));
vi.mock("@/lib/auth", () => ({
requireAuth: vi.fn(),
}));
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/posts", () => {
it("returns paginated posts", async () => {
const posts = [
{ id: "1", title: "Post 1", status: "published" },
{ id: "2", title: "Post 2", status: "published" },
];
vi.mocked(db.post.findMany).mockResolvedValue(posts);
const req = new NextRequest("http://localhost/api/posts?limit=10");
const res = await GET(req);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data).toEqual(posts);
});
it("filters by status", async () => {
vi.mocked(db.post.findMany).mockResolvedValue([]);
const req = new NextRequest("http://localhost/api/posts?status=draft");
await GET(req);
expect(db.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: "draft" }),
})
);
});
it("rejects invalid query parameters", async () => {
const req = new NextRequest("http://localhost/api/posts?limit=abc");
const res = await GET(req);
expect(res.status).toBe(400);
});
});
describe("POST /api/posts", () => {
it("creates a post when authenticated", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" });
vi.mocked(db.post.create).mockResolvedValue({
id: "new-1",
title: "New Post",
content: "Content",
status: "draft",
authorId: "user-1",
});
const req = new NextRequest("http://localhost/api/posts", {
method: "POST",
body: JSON.stringify({ title: "New Post", content: "Content" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.title).toBe("New Post");
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireAuth).mockRejectedValue(new Error("Unauthorized"));
const req = new NextRequest("http://localhost/api/posts", {
method: "POST",
body: JSON.stringify({ title: "Test" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(401);
});
});Common Mistakes
1. Testing Implementation Details
Wrong:
it("updates the state when button is clicked", () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1));
expect(result.current[0]).toBe(1);
});Fix: Test what the user sees, not internal state. Render the component, click the button, and assert the visible output changes. If the component refactors from useState to useReducer, the test should still pass.
2. Using getBy for Absence Assertions
Wrong:
expect(screen.getByText("Error")).not.toBeInTheDocument();
// Throws before the assertion — getBy throws when element not foundFix: Use queryBy when asserting something is NOT present: expect(screen.queryByText("Error")).not.toBeInTheDocument().
3. Not Awaiting User Events
Wrong:
it("submits the form", () => {
const user = userEvent.setup();
render(<Form />);
user.click(screen.getByRole("button")); // Missing await
expect(handleSubmit).toHaveBeenCalled(); // Might fail intermittently
});Fix: Always await user event calls. userEvent.setup() returns async methods that simulate real browser events including focus, pointer, and keyboard sequences.
4. Over-Mocking
Wrong:
// Mocking every internal function
vi.mock("./validate");
vi.mock("./transform");
vi.mock("./format");
// Now you're testing that functions are called, not that they workFix: Only mock external boundaries (network, database, file system, browser APIs). Let internal functions run for real. If a function is pure, test its output directly without mocking its dependencies.
5. Snapshot Overuse
Wrong:
it("renders correctly", () => {
const { container } = render(<EntireApp />);
expect(container).toMatchSnapshot();
// 5000-line snapshot that nobody reviews
});Fix: Use snapshots only for small, stable components (icons, badges). For everything else, write explicit assertions about the content you care about. Large snapshots get auto-updated without review.
6. Missing Error Path Tests
Wrong: Only testing the happy path.
it("creates a user", async () => {
// Only tests successful creation
const user = await createUser({ name: "Alice" });
expect(user).toBeDefined();
});Fix: Test error scenarios: invalid input, network failures, auth errors, race conditions. Error paths are where most bugs live.
7. Test Interdependence
Wrong:
let createdUserId: string;
it("creates a user", async () => {
const user = await createUser({ name: "Alice" });
createdUserId = user.id; // Shared state between tests
});
it("fetches the created user", async () => {
const user = await fetchUser(createdUserId); // Depends on first test
expect(user.name).toBe("Alice");
});Fix: Each test must be independent. Use beforeEach to set up required state. Tests should pass in any order and in isolation.
8. Ignoring Async Cleanup
Wrong:
it("polls for updates", async () => {
render(<Poller />);
expect(await screen.findByText("Updated")).toBeInTheDocument();
// Test ends but polling interval is still running
// Next test gets "act() warning" from lingering updates
});Fix: Clean up timers, subscriptions, and intervals. Use vi.useFakeTimers() for timer-based code. Use cleanup functions in afterEach. Ensure components properly cancel async work on unmount.
9. Hardcoded Test Data
Wrong:
const mockUser = { id: "1", name: "Test", email: "test@test.com", role: "admin" };
// Copied across 50 test files, some slightly differentFix: Use factory functions that generate consistent test data. Factories are the single source of truth for test object shapes and keep tests readable.
10. Testing Framework Internals
Wrong:
it("calls useEffect on mount", () => {
const spy = vi.spyOn(React, "useEffect");
render(<Component />);
expect(spy).toHaveBeenCalled();
});Fix: Never test that React hooks are called. Test the observable behavior that results from the hook — the rendered output, side effects (API calls), or DOM changes.
See also: E2E Testing | Test Strategy | Performance Testing | Frontend/React Fundamentals
Last reviewed: 2026-03
By Ryan Lind, Assisted by Claude Code and Google Gemini.
Testing Vibe Coding Knowledge Base
Test strategy, unit testing, end-to-end testing, and performance testing — structured for AI-assisted development. Feed these files to your AI coding assistant to ship tested, reliable software by default.
E2E Testing
Playwright setup and architecture, resilient selectors, page object model, testing user flows, visual regression, network interception, multi-browser testing, parallel execution, CI/CD integration, accessibility testing, Cypress comparison, and test data management — everything you need to ship confidence in every deploy.