29ffb42898
---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---
6.6 KiB
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). Seelanguages/typescript.mdfor 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 separatets-jestconfig); Jest's ecosystem is broader. Either is acceptable — pick one per repo, do not mix. - Tests co-located with source:
user.ts→user.test.ts. A test file far from its subject rots (Documentation P5 Discoverability). describe/itmirror 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).expectoverassert: Vitest/Jest matchers produce readable failure output (expect(x).toBe(y)→ "expected 5, received 3"). Rawassertgives 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
fetchor 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/afterEachresets; a top-levelletshared across tests is order-coupling (Testing P2 Independence). vi.useFakeTimers()for time-dependent code: never callDate.now()directly in code under test; inject aClockport. In tests, fake timers makesetTimeoutsynchronous.
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>andtsd/expect-typefail the build when a type assertion is wrong. - Negative type tests are required:
// @ts-expect-errorproves the compiler rejects what it should. A@ts-expect-errorthat no longer errors is itself an error (the comment must be consumed). - Branded types and utility types get type tests: a
UserIdshould not be assignable tostring; aReadonly<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.eachfor 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 sharedconst 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.eachtable. An ad-hocit('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-checkgenerates 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(), orcrypto.randomUUID()in code under test: inject aClock,Random, andIdGenport. In tests, provide deterministic fakes. --randomtest order (Vitestsequence.shuffle: truedefault) 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
concurrentflag 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 viamakeUser.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.md—ts-jest/vitestconfig and theexpect-type/tsdtoolchain.