Files
2026-05-27 23:29:53 +01:00

48 lines
5.1 KiB
Markdown

# 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>(…)`.