aboutsummaryrefslogtreecommitdiff
path: root/languages/typescript/claude/rules/typescript-testing.md
blob: 31c50fc07566ebcb2a07abf9c1c487c862168fae (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# TypeScript Testing Rules

Applies to: `**/*.{ts,tsx}`

Implements the core principles from `testing.md`. All rules there apply here —
this file covers TypeScript-specific patterns.

## Framework: Vitest (canonical)

Use Vitest for new TypeScript code. It's native ESM, native TS, fastest watch
mode, and shares its config with Vite when the project already uses it.

For legacy code, the same principles apply with different idioms:

- **Jest** — same `describe`/`it`/`expect` API; substitute `jest.mock` for
  `vi.mock`. Most patterns below port directly.
- **Mocha + Chai** (Node backends) — `describe`/`it` are the same; assertions
  use `expect(x).to.equal(y)` instead of `expect(x).toBe(y)`. Sinon for spies.
- **Angular + Karma** (`ng test`) — different planet. Follow Angular's testing
  guide; the Normal/Boundary/Error category discipline still applies.

Don't mix frameworks in one package. Don't introduce a second framework to
"try it out" — pick the one that fits and commit.

## Test Structure

Group tests in `describe` blocks that mirror the source module. Use
`beforeEach` for setup that every test in the block needs:

```ts
import { beforeEach, describe, expect, it } from "vitest";
import { Cart } from "./cart";

describe("Cart", () => {
  let cart: Cart;

  beforeEach(() => {
    cart = new Cart({ userId: 42 });
  });

  it("normal: adding an in-stock item increases quantity", () => {
    cart.add("SKU-1", 2);
    expect(cart.itemCount("SKU-1")).toBe(2);
  });

  it("boundary: quantity 0 is a no-op, not an error", () => {
    cart.add("SKU-1", 0);
    expect(cart.itemCount("SKU-1")).toBe(0);
  });

  it("error: negative quantity throws", () => {
    expect(() => cart.add("SKU-1", -1)).toThrow(/quantity must be non-negative/);
  });
});
```

Co-locate test files with the source under test (`cart.ts` ↔ `cart.test.ts`)
unless the project layout dictates a separate `tests/` tree. Match the project's
existing convention.

## Fixtures via Factory Functions

TypeScript has no pytest-style fixture system. Use plain factory functions for
test data — they're typed, refactor-safe, and explicit:

```ts
const makeUser = (overrides: Partial<User> = {}): User => ({
  id: "u-1",
  email: "alice@example.com",
  role: "member",
  ...overrides,
});

it("admin can delete posts", () => {
  const admin = makeUser({ role: "admin" });
  expect(canDeletePosts(admin)).toBe(true);
});
```

Avoid `faker`/`@faker-js/faker` unless the project genuinely needs random data.
Random fixtures hide off-by-one bugs and make test failures non-reproducible.
Seed deterministically when faker is unavoidable.

## Parametrize for Category Coverage

Use `it.each` to cover normal, boundary, and error cases concisely instead of
hand-writing near-duplicate tests:

```ts
it.each<[number, boolean]>([
  [1, true],     // Normal
  [100, true],   // Normal: bulk
  [0, true],     // Boundary: zero is a no-op
  [-1, false],   // Error: negative
])("add(SKU-1, %i) — valid=%s", (quantity, valid) => {
  if (valid) {
    cart.add("SKU-1", quantity);
  } else {
    expect(() => cart.add("SKU-1", quantity)).toThrow();
  }
});
```

### Pairwise / Combinatorial for Parameter-Heavy Functions

When `it.each` would require listing dozens of combinations (feature flags ×
permissions × shipping × payment × etc.), switch to combinatorial coverage via
`/pairwise-tests`. The skill generates a minimal matrix covering every 2-way
parameter interaction — typically 80-99% fewer cases than exhaustive,
catching most combinatorial bugs.

Workflow: invoke `/pairwise-tests` → get a PICT model + generated test matrix
→ paste the matrix into an `it.each` block. See `testing.md` § Combinatorial
Coverage for the general rule and when to skip.

## Measuring Coverage — `make coverage-summary`

The bundle ships a coverage summary at `.claude/scripts/coverage-summary.js`
and a Makefile fragment (`coverage-makefile.txt`) with `coverage` and
`coverage-summary` targets. After the suite runs under c8 (or Vitest/Jest with
the json-summary reporter) and writes `coverage/coverage-summary.json`, `make
coverage-summary` prints a file-weighted project number and the source files no
test imported.

The number to watch is that missing-file count. A module no test imports never
appears in the Istanbul report, so a statement-weighted total skips it silently
and the suite looks healthier than it is. The summary counts every `.ts`/`.js`
under the source dir that's absent from the report as 0%, so an untested module
drags the project number down where you can see it. It doesn't reimplement the
per-file table — nyc/c8 already print that. Copy the fragment's targets into
your own Makefile to adopt it; the bundle never edits your Makefile.

## Mocking Guidelines

### Mock these (external boundaries):
- HTTP clients (`fetch`, `axios`, `ky`) — prefer MSW (see below) over
  client-level mocks where possible.
- File / filesystem APIs (`fs/promises`)
- Time (`vi.useFakeTimers()` + `vi.setSystemTime(...)`)
- Browser globals not covered by jsdom (`navigator.geolocation`,
  `IntersectionObserver`, `ResizeObserver`)
- Third-party SDKs (Stripe client, AWS SDK clients)

### Never mock these (internal domain):
- Your own service, hook, or utility functions
- Type-narrowing helpers (`isFoo`, `assertBar`) — those are the work
- Validation libraries (`zod`, `valibot`) — they're framework, not boundary
- React's render lifecycle, hooks, or context — use RTL to exercise the real thing

If a unit test needs heavy internal mocking, the production code needs
restructuring (see `testing.md` § *If Tests Are Hard to Write, Refactor the
Code*).

## Async Testing

Use native `async`/`await`. Don't wrap in promise chains, don't mix `done`
callbacks:

```ts
it("processOrder resolves with status 'processed'", async () => {
  const result = await processOrder(sampleOrder);
  expect(result.status).toBe("processed");
});
```

For React component tests, use RTL's `findBy*` (auto-waits) over
`waitFor(() => getBy*)` — `findBy*` is purpose-built for the "appears
eventually" case.

## React Testing Library

When testing React components:

- **Query priority**: `getByRole` > `getByLabelText` > `getByPlaceholderText`
  > `getByText`. Reach for `getByTestId` only when the others genuinely don't
  fit. Test like the user — don't query CSS classes or DOM structure.
- **`userEvent` over `fireEvent`**. `userEvent` simulates real interaction
  (focus, keyboard, async). `fireEvent` is a low-level escape hatch.
- **One assertion per behavioral concern**. A test that asserts "button
  renders" + "button submits the form" + "form clears on submit" is three
  tests.
- **Don't snapshot DOM**. Snapshots rot fast and reviewers rubber-stamp them.
  Assert specific attributes and text instead.

## Network Mocking: MSW

For request-level mocking in component and integration tests, use MSW (Mock
Service Worker). It intercepts at the network layer, so the app code is
exercised end-to-end up to the boundary:

```ts
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer(
  http.get("/api/orders/:id", ({ params }) =>
    HttpResponse.json({ id: params.id, status: "shipped" })
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

Prefer MSW over mocking `fetch` or `axios` directly — request mocks survive
client-library swaps; `vi.mock("axios")` doesn't.

## TypeScript-Specific Discipline

- **No `any` in tests.** Tests are documentation; `any` lies about the shape.
  Use `unknown` and narrow, or define the precise type.
- **Prefer `satisfies` over type assertions.** `as` says "trust me"; `satisfies`
  says "verify this conforms" without widening the inferred type.
- **Don't disable strict checks for tests.** Same `tsconfig` as production.
  If a test needs `// @ts-expect-error`, leave a comment explaining the
  invariant being verified.
- **Assertion functions for invariants.** When narrowing via runtime check is
  expressive, write `assertIsFoo(value)` (returns `asserts value is Foo`)
  instead of casting in every test.

## Anti-Patterns (TypeScript-Specific)

- Casting test data with `as Whatever` to silence a type error — fix the
  factory or the type.
- Using `jest.mock` patterns in a Vitest project (or vice versa) — pick one.
- Snapshot-testing JSX trees — brittle, low-signal.
- Testing `useState` / `useReducer` directly via `renderHook` when the same
  behavior is reachable through the component's UI — render the component.
- `expect(x).toBeTruthy()` when you mean `expect(x).toBe(true)` — they are
  different invariants and the looser one masks bugs.