Testing — Rules and Conventions
1. Philosophy
- Test pyramid — Many unit, some integration, few e2e. Fast feedback > coverage theater.
- Test behavior, not implementation — Assert what user sees/does, not internal state.
- Fast & deterministic — Unit < 10ms, integration < 100ms, e2e < 30s. No flakes.
- Isolated & parallel — No shared state. Each test independent, runnable in any order.
- CI-first — Tests run on every PR. Fail fast, artifacts on failure.
2. Minimum Versions
| Tool | Minimum Version |
|---|---|
| Vitest | 2.0+ |
| Playwright | 1.40+ |
| @testing-library/react | 14.0+ |
| @testing-library/user-event | 14.0+ |
| MSW | 2.0+ |
| Node.js | 22+ |
3. Vitest — Setup
Config (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: {
globals: true,
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
include: ["**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
});
Setup (tests/setup.ts)
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import "@testing-library/jest-dom";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// MSW
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
Rules
globals: true—describe,it,expectwithout importsenvironment: 'jsdom'— for DOM testing;'happy-dom'lighter alternativesetupFiles— global config, cleanup, MSWtsconfigPaths— path aliases fromtsconfig.json
4. Vitest — Patterns
Basic test
import { describe, it, expect, vi, beforeEach } from "vitest";
describe("UserService", () => {
let service: UserService;
let mockApi: Mocked<ApiClient>;
beforeEach(() => {
mockApi = { getUser: vi.fn(), updateUser: vi.fn() };
service = new UserService(mockApi);
});
it("fetches user by id", async () => {
mockApi.getUser.mockResolvedValue({ id: "1", name: "Alice" });
const user = await service.getUser("1");
expect(user.name).toBe("Alice");
expect(mockApi.getUser).toHaveBeenCalledWith("1");
});
it("throws on not found", async () => {
mockApi.getUser.mockRejectedValue(new NotFoundError());
await expect(service.getUser("999")).rejects.toThrow(NotFoundError);
});
});
Mocking
// Module mock
vi.mock("@/lib/api", () => ({
apiClient: { get: vi.fn(), post: vi.fn() },
}));
// Spy
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
spy.mockRestore();
// Timer
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.useRealTimers();
Coverage
# Run with coverage
pnpm test --coverage
# Update thresholds in vitest.config.ts
thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 }
Snapshots
it('renders correctly', () => {
const { container } = render(<UserCard user={mockUser} />)
expect(container).toMatchSnapshot()
})
# Update: pnpm test -- -u
5. Vitest — Testing Library
React Component Testing
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/Button";
it("calls onClick when clicked", async () => {
const handleClick = vi.fn();
render(<Button me</Button>);
await userEvent.click(screen.getByRole("button", { name: /click me/i }));
expect(handleClick).toHaveBeenCalledOnce();
});
Queries (priority order)
| Query | Use When |
|---|---|
getByRole('button', { name: /submit/i }) |
Accessible, preferred |
getByLabelText(/email/i) |
Form inputs |
getByPlaceholderText(/search/i) |
Search inputs |
getByText(/welcome/i) |
Text content |
getByTestId('custom') |
Last resort |
Async
it("loads user data", async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText(/loading/i)).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
Forms
it("submits form", async () => {
const
render(<LoginForm />);
await userEvent.type(screen.getByLabelText(/email/i), "alice@example.com");
await userEvent.type(screen.getByLabelText(/password/i), "secret123");
await userEvent.click(screen.getByRole("button", { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "secret123",
});
});
6. Vitest — API Testing (MSW)
Handler (tests/mocks/handlers.ts)
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
if (params.id === "not-found") {
return HttpResponse.json({ message: "Not found" }, { status: 404 });
}
return HttpResponse.json({ id: params.id, name: "Alice" });
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
Test
import { http, HttpResponse } from "msw";
import { server } from "./mocks/server";
it("handles API error", async () => {
server.use(
http.get("/api/users/1", () =>
HttpResponse.json({ message: "Server error" }, { status: 500 }),
),
);
const { result } = renderHook(() => useUser("1"));
await waitFor(() => expect(result.current.error).toBeDefined());
});
7. Playwright — Setup
Config (playwright.config.ts)
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 12"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
Auth Setup (tests/e2e/auth.setup.ts)
import { test as setup, expect } from "@playwright/test";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="password"]', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await page.context().storageState({ path: "tests/e2e/.auth/user.json" });
});
Use Auth in Tests
// playwright.config.ts
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "tests/e2e/.auth/user.json",
},
dependencies: ["setup"],
},
];
8. Playwright — Patterns
Basic Navigation
import { test, expect } from "@playwright/test";
test("navigates to dashboard", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveTitle(/Dashboard/);
await expect(page.locator("h1")).toContainText("Welcome");
});
Locators (auto-waiting)
test("submits form", async ({ page }) => {
await page.goto("/login");
// Preferred: user-facing attributes
await page.getByRole("textbox", { name: /email/i }).fill("alice@example.com");
await page.getByRole("textbox", { name: /password/i }).fill("secret123");
await page.getByRole("button", { name: /login/i }).click();
await expect(page).toHaveURL(/.*dashboard/);
});
Assertions
// Auto-retrying
await expect(page.getByRole("heading")).toHaveText("Welcome");
await expect(page.locator(".card")).toHaveCount(3);
await expect(page).toHaveURL(/.*dashboard/);
// Negative
await expect(page.getByRole("alert")).not.toBeVisible();
// Soft assertions (continue on failure)
await expect.soft(page.getByText("Item 1")).toBeVisible();
await expect.soft(page.getByText("Item 2")).toBeVisible();
Network Interception
test("mocks API response", async ({ page }) => {
await page.route("/api/users", (route) => {
route.fulfill({ json: [{ id: "1", name: "Mocked" }] });
});
await page.goto("/users");
await expect(page.getByText("Mocked")).toBeVisible();
});
test("waits for API call", async ({ page }) => {
const response = await page.waitForResponse("/api/users");
const data = await response.json();
expect(data).toHaveLength(3);
});
9. Playwright — Advanced
Visual Regression
test('visual snapshot', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
threshold: 0.2
})
})
# Update: pnpm playwright test --update-snapshots
Trace & Debug
test("fails with trace", async ({ page }, testInfo) => {
await page.goto("/flaky-page");
await expect(page.getByText("Success")).toBeVisible();
// Attach trace on failure
testInfo.attach("trace.zip", {
path: "trace.zip",
contentType: "application/zip",
});
});
# View trace
pnpm playwright show-trace trace.zip
10. CI Integration
GitHub Actions (.github/workflows/test.yml)
name: Test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: "pnpm" }
- run: pnpm install --frozen-lockfile
- run: pnpm test --coverage
- uses: actions/upload-artifact@v4
if: always()
with: { name: coverage, path: coverage/ }
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: "pnpm" }
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install --with-deps
- run: pnpm build
- run: pnpm playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Parallel & Sharding
# Playwright sharding
jobs:
e2e:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pnpm playwright test --shard=${{ matrix.shard }}/4
11. Methodology
Before using ANY testing pattern not documented in this skill:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor Vitest, Playwright, Testing Library, MSW. - Official docs: vitest.dev, playwright.dev, testing-library.com, mswjs.io — verify current APIs.
- Project config:
vitest.config.ts,playwright.config.ts,package.json— verify against actual setup. - HARD RULE: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.
12. Prohibitions
- ❌ Do not test implementation details (private methods, internal state)
- ❌ Do not use
waitForwithoutawait— alwaysawait waitFor(...) - ❌ Do not use
page.waitForTimeout()— use auto-waiting locators - ❌ Do not share state between tests — use
beforeEach/afterEach - ❌ Do not snapshot huge objects — snapshot minimal UI
- ❌ Do not skip
cleanup()in Vitest setup - ❌ Do not run e2e tests without
webServeror running dev server - ❌ Do not commit
playwright-report/ortest-results/— CI artifacts only - ❌ Do not use
test.only/test.skipin committed code
13. References
Note: For JavaScript conventions (mocks, async), see JavaScript Note: For TypeScript types, see TypeScript Note: For React patterns, see React Note: For Astro patterns, see Astro Note: For Next.js patterns, see Next.js Note: For CI/CD, see Deploy
Last updated: 2026-08