Files
Jon Chery 29ffb42898 docs(milestone): complete v0.4 — Edge + Messaging + Language-Derived Docs
---ci---
project: atelier
phase: 0
milestone: v0.4
status: complete
requirements:
  covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96, ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101, ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105, ATELIER-106, ATELIER-107, ATELIER-108, ATELIER-109, ATELIER-110, ATELIER-111, ATELIER-112, ATELIER-113, ATELIER-114, ATELIER-115, ATELIER-116, ATELIER-117]
  partial: []
---/ci---
2026-08-05 16:23:15 +00:00

6.6 KiB

TypeScript Testing — Derived Application

Applies Atelier's domain principles to TypeScript testing specifically. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/typescript.md for the language first-principles stub.

Vitest and Jest (Testing P1 Tests as Specification, C2 Clarity)

  • Vitest for new TS projects; Jest for legacy: Vitest shares vite's transform pipeline (no separate ts-jest config); Jest's ecosystem is broader. Either is acceptable — pick one per repo, do not mix.
  • Tests co-located with source: user.tsuser.test.ts. A test file far from its subject rots (Documentation P5 Discoverability).
  • describe/it mirror the public API: the test block names read as a specification ("User", "rejects an invalid email", "returns the persisted id"). A reader should understand the unit from test names alone (Testing P1).
  • expect over assert: Vitest/Jest matchers produce readable failure output (expect(x).toBe(y) → "expected 5, received 3"). Raw assert gives a stack trace and nothing else (Testing P6 Failure Specificity).
// user.test.ts
import { describe, it, expect } from 'vitest';
import { createUser } from './user';

describe('createUser', () => {
  it('rejects an invalid email', async () => {
    await expect(createUser({ email: 'not-an-email' })).rejects.toThrow(ValidationError);
  });
  it('returns the persisted id', async () => {
    const u = await createUser({ email: 'a@b.co' });
    expect(u.id).toMatch(/^[a-z0-9]+$/);
  });
});

Mock Discipline (Testing P2 Independence, Testing P7 Realism)

  • Mock at the boundary, not the unit: replace fetch or the DB client, not the function under test. Mocking the unit under test tests the mock, not the code (Testing P7 — realism).
  • No partial mocks of the system under test: if a method must be stubbed, the unit is too large. Extract a collaborator and mock that.
  • Each test sets up and tears down its own state: no shared mutable fixtures. A beforeEach/afterEach resets; a top-level let shared across tests is order-coupling (Testing P2 Independence).
  • vi.useFakeTimers() for time-dependent code: never call Date.now() directly in code under test; inject a Clock port. In tests, fake timers make setTimeout synchronous.
import { vi, beforeEach, afterEach } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  global.fetch = vi.fn();   // boundary mock
});
afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
});

Type-Level Tests (Testing P1 Tests as Specification, Data P7 Type Fidelity)

  • Type-level tests assert the type system, not runtime behavior: expectTypeOf<T>().toMatchTypeOf<U> and tsd/expect-type fail the build when a type assertion is wrong.
  • Negative type tests are required: // @ts-expect-error proves the compiler rejects what it should. A @ts-expect-error that no longer errors is itself an error (the comment must be consumed).
  • Branded types and utility types get type tests: a UserId should not be assignable to string; a Readonly<T> should not allow assignment. These invariants are part of the spec (Testing P1).
  • Applies data/P7 (type fidelity): a type-level test is a regression test for the type checker — if a refactor silently widens a type, the test fails.
import { expectTypeOf } from 'expect-type';
import type { User, UserPatch, UserId } from './user';

test('UserPatch omits id and makes fields optional', () => {
  expectTypeOf<UserPatch>().toMatchTypeOf<{ name?: string; email?: string }>();
  expectTypeOf<UserPatch>().not.toHaveProperty('id');
});

test('UserId is not assignable to bare string', () => {
  // @ts-expect-error — brand prevents widening
  const s: string = {} as UserId;
  expect(s).toBeDefined();
});

Parametrize and Factories (Testing P3 Determinism, Testing P9 Edge Case Coverage)

  • it.each / test.each for parametrized cases: one table drives many runs; each row is an independent test with its own name and failure output.
  • Factories over fixtures: makeUser(overrides) returns a fresh object per call. A shared const user = {...} across tests couples them and breaks determinism when one test mutates it (Testing P3).
  • Edge cases as rows, not special tests: empty array, single element, max int, null, undefined — each a row in a test.each table. An ad-hoc it('handles edge') with multiple asserts hides which case failed (Testing P9 — edge case coverage, P6 failure specificity).
  • Property-style tests via fast-check: for invariants (e.g., "parse(serialize(x)) === x"), fast-check generates hundreds of inputs and shrinks failures to a minimal counterexample.
import { test, expect } from 'vitest';
import { makeUser } from './user.factory';

test.each([
  { input: '', reason: 'empty' },
  { input: 'a'.repeat(1000), reason: 'too long' },
  { input: 'not-an-email', reason: 'no @' },
])('rejects email: $reason', async ({ input }) => {
  await expect(makeUser({ email: input })).rejects.toThrow(ValidationError);
});

Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage)

  • No Date.now(), Math.random(), or crypto.randomUUID() in code under test: inject a Clock, Random, and IdGen port. In tests, provide deterministic fakes.
  • --random test order (Vitest sequence.shuffle: true default) catches order coupling: a test that passes alone but fails in a suite has hidden state. The shuffle makes that state visible (Testing P2).
  • Race-detector parallelism for async tests: run async tests concurrently by default; a test that assumes serial execution breaks under parallelism. Vitest's concurrent flag surfaces the bug.
import { vi, test, expect } from 'vitest';

test.concurrent('parallel fetch does not interleave state', async () => {
  const store = new Store();
  await Promise.all([store.put('a', 1), store.put('b', 2)]);
  expect(store.get('a')).toBe(1);
  expect(store.get('b')).toBe(2);
});

Cross-References

  • domains/testing/pyramid.md — where unit/type/integration tests sit; the type-level tests here are the base layer.
  • domains/testing/fixtures.md — factory-vs-fixture discipline applied via makeUser.
  • domains/testing/first-principles.md — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage.
  • languages/ts-types.md — the branded types and utility types that type-level tests assert.
  • languages/ts-async.md — async tests use the cancellation/timeout patterns from that doc.
  • languages/ts-tooling.mdts-jest/vitest config and the expect-type/tsd toolchain.