Category files under development/ replace the decision prose that was scattered through README.md and CONTRIBUTING.md. Each decision is a block with Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records the public API contract and its limitations. AGENTS.md and backlog.tasks now point at the new home.
3.4 KiB
Testing
For this library the types are the feature — narrowing, exhaustive()
returns, the Matcher<T> contract — so a runtime-only test loop would verify
the wrong thing. The contributor-facing commands are in
CONTRIBUTING.md; this file records why the loop is shaped
the way it is.
Type-driven development
New behavior follows type-driven development (in Edwin Brady's sense):
treat the type as the plan for a program, and use the compiler and type checker
as your assistant, guiding you to a complete program that satisfies the type
(idris-lang.org). Here that plan is the
expectTypeOf assertion, written first. The loop is type → red → green →
refactor:
- Type — write the compile-time expectation first
(
expectTypeOf(...).toEqualTypeOf<…>()) and letnpm run check:tscfail on the type. The type error is the spec you want to hit before the runtime logic exists. - Red — add the matching runtime assertion (
assert.*) sonpm run test:unitnow fails on behavior. - Green — implement in
src/*.tsuntil both the type check and the test pass. - Refactor — with the type system and the tests as the safety net, then
npm run verifyas the definition-of-done gate.
This is why every test in the suite pairs an expectTypeOf(...) with an
assert.* — keep them together.
Decision (2026-09)
Test-driven development is type-driven here: the compile-time expectation is written before the runtime assertion, and both before the implementation.
Why
- A runtime-only test can pass while the type is wrong, and a type-level library would then ship a broken feature that its tests bless.
- The type error is a more precise spec than a failing assertion, because it states the exact expected type before the logic exists.
Rejected
- Runtime-first (classic red/green): it verifies the value, not the contract, and the contract is the product.
- Testing the type only: it would not catch handler wiring,
exhaustive()throwing, or theotherwisefallback (seesrc/index.test.ts).
Enforcement
Type-first is also enforced structurally: npm test runs check:tsc before the
test runner, so a wrong type can never be papered over by a passing assertion.
Per AGENTS.md § Never do, reach green honestly — fix the
types so both the type check and the runtime assertion pass, never suppress the
ones you can't make pass.
Test tiers
npm run test:unit— the test runner alone, for the fast local loop.npm test—check:tsc+ the unit suite; this is what pre-push and the baseline check run.npm run test:ci— adds c8 coverage; used by CI.c8uses V8 coverage, so the--strip-typessource is instrumented without a build step.
The runner is node --test --strip-types "src/**/*.test.ts". It relies on the
.ts import-extension convention (see tooling.md).
Known issue
- The type-aware linter misidentifies
expectTypeOf()as a floating promise, sosrc/index.test.tscarries a file-leveloxlint-disable typescript/no-floating-promiseswith an explanatory comment. This is a known false positive, not a rule we want off project-wide (see tooling.md § oxlint-disable directives live next to the code).