# Testing

> Testing rules - Vitest (unit/integration), Playwright (e2e), React Testing Library, MSW, coverage, CI integration

- Skill: `14bryanespinoza/testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add 14bryanespinoza/testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/14bryanespinoza/testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: 14BryanEspinoza (https://skillmd.com/u/14bryanespinoza)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/14bryanespinoza/testing

---


# Testing — Rules and Conventions

---

## 1. Philosophy

1. **Test pyramid** — Many unit, some integration, few e2e. Fast feedback > coverage theater.
2. **Test behavior, not implementation** — Assert what user sees/does, not internal state.
3. **Fast & deterministic** — Unit < 10ms, integration < 100ms, e2e < 30s. No flakes.
4. **Isolated & parallel** — No shared state. Each test independent, runnable in any order.
5. **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`)

```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`)

```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`, `expect` without imports
- **`environment: 'jsdom'`** — for DOM testing; `'happy-dom'` lighter alternative
- **`setupFiles`** — global config, cleanup, MSW
- **`tsconfigPaths`** — path aliases from `tsconfig.json`

---

## 4. Vitest — Patterns

### Basic test

```ts
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

```ts
// 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

```bash
# Run with coverage
pnpm test --coverage

# Update thresholds in vitest.config.ts
thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 }
```

### Snapshots

```ts
it('renders correctly', () => {
  const { container } = render(<UserCard user={mockUser} />)
  expect(container).toMatchSnapshot()
})

# Update: pnpm test -- -u
```

---

## 5. Vitest — Testing Library

### React Component Testing

```tsx
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 onClick={handleClick}>Click 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

```tsx
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

```tsx
it("submits form", async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  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`)

```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

```ts
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`)

```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`)

```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

```ts
// 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

```ts
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)

```ts
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

```ts
// 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

```ts
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

```ts
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

```ts
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",
  });
});
```

```bash
# View trace
pnpm playwright show-trace trace.zip
```

---

## 10. CI Integration

### GitHub Actions (`.github/workflows/test.yml`)

```yaml
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

```yaml
# 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:

1. **MCP Context7** (priority): `context7_resolve-library-id` + `context7_query-docs` for Vitest, Playwright, Testing Library, MSW.
2. **Official docs**: vitest.dev, playwright.dev, testing-library.com, mswjs.io — verify current APIs.
3. **Project config**: `vitest.config.ts`, `playwright.config.ts`, `package.json` — verify against actual setup.
4. **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 `waitFor` without `await` — always `await 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 `webServer` or running dev server
- ❌ Do not commit `playwright-report/` or `test-results/` — CI artifacts only
- ❌ Do not use `test.only`/`test.skip` in committed code

---

## 13. References

> **Note:** For JavaScript conventions (mocks, async), see [JavaScript](../javascript/SKILL.md)
> **Note:** For TypeScript types, see [TypeScript](../typescript/SKILL.md)
> **Note:** For React patterns, see [React](../reactjs/SKILL.md)
> **Note:** For Astro patterns, see [Astro](../astro/SKILL.md)
> **Note:** For Next.js patterns, see [Next.js](../nextjs/SKILL.md)
> **Note:** For CI/CD, see [Deploy](../deploy/SKILL.md)

---

Last updated: 2026-08

