Files
suaveplan/openspec/archive/2026-04-27-testing-generics-hardening/proposal.md
2026-05-27 23:29:53 +01:00

5.1 KiB

Testing generics hardening

Package: @suaveplan/testing Status: PROPOSED Type: feature

Why

Slice 1 of the testing-suite hardening sweep. Four type-level cliffs in the public surface forced consumers into as casts or eroded inference back to unknown:

  1. FixtureManager.get(name) returned FixtureState | undefined — callers who registered FixtureDefinition<MyShape> lost MyShape on read.
  2. RequestBuilder / ResponseBuilder preset factories (get, post, ok, created, …) returned the bare class with body: unknown, defeating the whole point of typed test request/response builders.
  3. assertThrows / assertThrowsAsync returned Error, so callers who matched on TestingError had to follow up with an instanceof narrowing before reading .code.
  4. The typecheck script in package.json invoked bunx tsc — a Rule 1 violation (no direct tsc).

Plus one spec-coverage drift: openspec/specs/testing/spec.md claimed ≥ 95% coverage while vitest.config.ts enforces 100% across all four metrics and CLAUDE.md mandates 100%.

What changes

  • MODIFIED: RequestBuilder is now RequestBuilder<TBody = unknown>. body() accepts TBody | undefined, state returns RequestBuilderState<TBody>, build() returns TestRequest<TBody | undefined>.
  • MODIFIED: ResponseBuilder is now ResponseBuilder<TBody = unknown> with the same body-typed contract.
  • MODIFIED: every preset factory (get, post, put, patch, del, head, options, ok, created, noContent, badRequest, unauthorized, notFound, serverError) takes <TBody = unknown> and returns the typed builder.
  • MODIFIED: TestRequest and TestResponse are now TestRequest<TBody = unknown> and TestResponse<TBody = unknown>.
  • MODIFIED: FixtureManager.get is now get<T = unknown>(name): FixtureState<T> | undefined. The internal cast is confined to the impl.
  • MODIFIED: assertThrows<TError extends Error = Error>(fn, matcher?): TError and the async variant. Internal cast at the boundary.
  • MODIFIED: package.json scripts.typecheck switched from bunx tsc --noEmit --composite false -p tsconfig.lib.json to tsc --project tsconfig.lib.json --noEmit, matching the canonical pattern in non-dev packages and dropping the bunx wrapper.
  • MODIFIED: openspec/specs/testing/spec.md "Coverage and docs gates" requirement: bumped from ≥ 95% to 100% across statements, branches, functions, and lines (matches the wired vitest.config.ts thresholds and CLAUDE.md mandate). Path string also corrected from packages/universal/testing/ to packages/dev/testing/ (folder reorg leftover).
  • MODIFIED: tests/spec-conformance.spec-conformance.test.ts ALLOWED_NONDETERMINISM extended to include src/helpers/test-helpers.ts (measureTime) and src/observability/with-observability.ts (histogram duration recording). Both legitimately read performance.now() at the wrapper boundary; injecting a Clock would defeat the contract (callers ask for real elapsed time). The exemption is documented in-test.
  • ADDED: src/builders/request-builder/request-builder.spec-conformance.test.ts, src/builders/response-builder/response-builder.spec-conformance.test.ts, src/fixtures/fixture-manager/fixture-manager.spec-conformance.test.ts, src/helpers/test-helpers.spec-conformance.test.ts — expectTypeOf pins on every generic claim.

Out of scope

  • No runtime behavior change. All 295 existing tests continue to pass byte-for-byte; the 17-test increase is purely the new type-level conformance pins.
  • Slice 2 (testing-dom, testing-e2e) and Slice 3 (testing-integration*) are owned by sibling agents; no files outside packages/dev/testing/ and openspec/{specs,changes}/testing/ are touched.
  • MANIFEST.md, root bun.lock, and CLAUDE.md are intentionally not edited — orchestrator territory.

Verification approach

  • src/builders/request-builder/request-builder.spec-conformance.test.ts — pins every preset (get/post/put/patch/del/head/options) and the class instance to the new generic shape via expectTypeOf.
  • src/builders/response-builder/response-builder.spec-conformance.test.ts — same pattern for ok/created/noContent/badRequest/unauthorized/notFound/serverError and the class.
  • src/fixtures/fixture-manager/fixture-manager.spec-conformance.test.ts — pins get<T> overload, narrowing of state.value, and the unknown-default fallback.
  • src/helpers/test-helpers.spec-conformance.test.ts — pins assertThrows<TError> and assertThrowsAsync<TError> returning TError / Promise<TError>.
  • Existing runtime tests (request-builder.test.ts, response-builder.test.ts, fixture-manager.test.ts, test-helpers.test.ts) continue to exercise behavior at runtime; type widening means none of them needed editing.

Migration impact

None — additive only. The default type parameters (TBody = unknown, T = unknown, TError extends Error = Error) preserve the exact pre-change inferred types for any caller that does not pass an explicit type argument. Callers who want the new tightening opt in by writing post<MyBody>(…), mgr.get<MyShape>("name"), or assertThrows<MyError>(…).