Testing

Unit (Jest + RTL), integration (mocked repositories), and E2E (Playwright). Coverage thresholds, fixture strategy, and CI gating.

1. Testing Strategy

Three layers, each with a specific tool:

  • Unit — Jest + React Testing Library. Fast, isolated. Pure functions, hooks, presentational components.
  • Integration — Jest + RTL with mocked services. Component trees, form flows, state transitions.
  • E2E — Playwright. Real browser, real network. Auth flows, payment, business-critical paths only.

2. Install

# Unit + integration
yarn add -D jest jest-environment-jsdom @types/jest \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event \
  ts-jest

# E2E
yarn add -D @playwright/test
npx playwright install --with-deps

3. Jest Config

Next.js ships next/jest which handles SWC compilation, CSS modules, and module aliases automatically.

1// jest.config.ts
2import type { Config } from "jest";
3import nextJest from "next/jest.js";
4
5const createJestConfig = nextJest({ dir: "./" });
6
7const config: Config = {
8 setupFilesAfterEach: ["<rootDir>/jest.setup.ts"],
9 testEnvironment: "jest-environment-jsdom",
10 moduleNameMapper: {
11 "^@/(.*)$": "<rootDir>/src/$1",
12 },
13 coverageThreshold: {
14 global: { branches: 70, functions: 70, lines: 70, statements: 70 },
15 },
16};
17
18export default createJestConfig(config);
19
20// jest.setup.ts
21import "@testing-library/jest-dom";

4. Component Test Example

1// src/shared/ui/__tests__/button.test.tsx
2import { render, screen } from "@testing-library/react";
3import userEvent from "@testing-library/user-event";
4import { Button } from "@/shared/ui/button";
5
6describe("Button", () => {
7 it("renders children", () => {
8 render(<Button>Click me</Button>);
9 expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
10 });
11
12 it("calls onClick when clicked", async () => {
13 const user = userEvent.setup();
14 const onClick = jest.fn();
15 render(<Button onClick={onClick}>Submit</Button>);
16 await user.click(screen.getByRole("button"));
17 expect(onClick).toHaveBeenCalledTimes(1);
18 });
19
20 it("is disabled when prop set", () => {
21 render(<Button disabled>Submit</Button>);
22 expect(screen.getByRole("button")).toBeDisabled();
23 });
24});

5. Mocking Server Modules

Mock at the service boundary, never deeper. The repository pattern makes this clean: inject a fake repository in tests instead of mocking Prisma.

1// src/features/users/services/__tests__/user.service.test.ts
2import { UserService } from "../user.service";
3import type { UserRepository } from "../../server/user.repository";
4
5describe("UserService.findOrCreate", () => {
6 it("returns existing user when email is found", async () => {
7 const fakeRepo: UserRepository = {
8 findById: jest.fn(),
9 findByEmail: jest.fn().mockResolvedValue({ id: "u1", email: "a@b.com" }),
10 create: jest.fn(),
11 update: jest.fn(),
12 };
13 const service = new UserService(fakeRepo);
14 const result = await service.findOrCreate("a@b.com");
15
16 expect(result.id).toBe("u1");
17 expect(fakeRepo.create).not.toHaveBeenCalled();
18 });
19});

6. Playwright E2E

Cover only flows that matter for revenue or trust: signup, login, checkout, password reset. E2E is slow — be selective.

1// playwright.config.ts
2import { defineConfig, devices } from "@playwright/test";
3
4export default defineConfig({
5 testDir: "./e2e",
6 webServer: {
7 command: "yarn build && yarn start",
8 url: "http://localhost:3000",
9 reuseExistingServer: !process.env.CI,
10 },
11 use: { baseURL: "http://localhost:3000" },
12 projects: [
13 { name: "chromium", use: devices["Desktop Chrome"] },
14 { name: "mobile", use: devices["iPhone 14"] },
15 ],
16});
17
18// e2e/login.spec.ts
19import { test, expect } from "@playwright/test";
20
21test("user can log in", async ({ page }) => {
22 await page.goto("/login");
23 await page.getByLabel("Email").fill("demo@example.com");
24 await page.getByLabel("Password").fill("password123");
25 await page.getByRole("button", { name: /sign in/i }).click();
26 await expect(page).toHaveURL("/dashboard");
27 await expect(page.getByText("Welcome back")).toBeVisible();
28});

7. Scripts

1// package.json
2{
3 "scripts": {
4 "test": "jest",
5 "test:watch": "jest --watch",
6 "test:coverage": "jest --coverage",
7 "test:e2e": "playwright test",
8 "test:e2e:ui": "playwright test --ui"
9 }
10}

8. Rules

  • Tests ship in the same diff as the behavior they verify. No "tests later."
  • Test through the public API. If you need to import an internal helper to test it, your unit boundary is wrong.
  • Use real database fixtures over mocks for migrations and queries — mocked DB tests pass while production breaks.
  • Run test, lint, and build in CI on every PR.

Built with Next.js, deployed from a couch in Indonesia.

© 2025 Erik Yuntantyo · turmerrific