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

6.4 KiB

Capability Spec Delta: testing (testing-generics-hardening)

Package: @suaveplan/testing Status: PROPOSED

ADDED Requirements

Requirement: Builder presets thread <TBody> through .build()

Every RequestBuilder and ResponseBuilder preset factory SHALL accept an optional <TBody = unknown> type parameter. The returned builder MUST be RequestBuilder<TBody> / ResponseBuilder<TBody> and .build() MUST return TestRequest<TBody | undefined> / TestResponse<TBody | undefined>. Callers who omit the parameter MUST observe the same unknown defaults that pre-dated this change.

Scenario: post threads body type

  • WHEN post<{ name: string }>("/x", { name: "a" }).build() is called
  • THEN the inferred return type is TestRequest<{ name: string } | undefined>

Scenario: ok threads body type

  • WHEN ok<{ id: string }>({ id: "1" }).build() is called
  • THEN the inferred return type is TestResponse<{ id: string } | undefined>

Scenario: zero-arg preset preserves unknown default

  • WHEN get("/x").build() or ok().build() is called without a type argument
  • THEN the inferred return types are TestRequest<unknown> and TestResponse<unknown> respectively

Verified by: src/builders/request-builder/request-builder.spec-conformance.test.ts::"post<TBody>(url, body).build() returns TestRequest<TBody | undefined>" AND src/builders/request-builder/request-builder.spec-conformance.test.ts::"get<TBody>(url).build() returns TestRequest<TBody | undefined>" AND src/builders/request-builder/request-builder.spec-conformance.test.ts::"put<TBody>, patch<TBody>, del<TBody>, head<TBody>, options<TBody> all thread TBody" AND src/builders/request-builder/request-builder.spec-conformance.test.ts::"zero-arg presets default TBody to unknown" AND src/builders/request-builder/request-builder.spec-conformance.test.ts::"RequestBuilder<TBody> exposes typed body field on state and build" AND src/builders/response-builder/response-builder.spec-conformance.test.ts::"ok<TBody>(body).build() returns TestResponse<TBody | undefined>" AND src/builders/response-builder/response-builder.spec-conformance.test.ts::"created<TBody>, badRequest<TBody>, unauthorized<TBody>, notFound<TBody>, serverError<TBody> thread TBody" AND src/builders/response-builder/response-builder.spec-conformance.test.ts::"noContent<TBody>() returns ResponseBuilder<TBody>" AND src/builders/response-builder/response-builder.spec-conformance.test.ts::"zero-arg presets default TBody to unknown" AND src/builders/response-builder/response-builder.spec-conformance.test.ts::"ResponseBuilder<TBody> exposes typed body on state and build"


Requirement: FixtureManager.get accepts a typed value parameter

FixtureManager.get SHALL accept an optional <T = unknown> type parameter and return FixtureState<T> | undefined. The narrowing SHALL extend through state.value so callers see T | null without a follow-up cast. Internally the manager MAY perform a single boundary cast; consumers MUST NOT need any.

Scenario: typed get returns typed state

  • WHEN mgr.get<DbFixture>("db") is called
  • THEN the inferred return type is FixtureState<DbFixture> | undefined AND the inferred type of state.value (after state !== undefined) is DbFixture | null

Scenario: untyped get preserves unknown default

  • WHEN mgr.get("db") is called without a type argument
  • THEN the inferred return type is FixtureState<unknown> | undefined

Verified by: src/fixtures/fixture-manager/fixture-manager.spec-conformance.test.ts::"get<T>(name) returns FixtureState<T> | undefined" AND src/fixtures/fixture-manager/fixture-manager.spec-conformance.test.ts::"get<T>(name) narrows state.value to T | null" AND src/fixtures/fixture-manager/fixture-manager.spec-conformance.test.ts::"get(name) without explicit T defaults to FixtureState<unknown>"


Requirement: assertThrows returns the caller-narrowed error type

assertThrows and assertThrowsAsync SHALL accept an optional <TError extends Error = Error> type parameter and return TError / Promise<TError> respectively. Internally the helper MAY cast at the boundary; the runtime fallback (wrap a non-Error throw in new Error(String(value))) MUST remain.

Scenario: typed assertThrows returns typed error

  • WHEN assertThrows<TestingError>(fn) is called and fn throws a TestingError
  • THEN the inferred return type is TestingError AND the caller can read .code without a follow-up instanceof narrowing

Scenario: untyped assertThrows defaults to Error

  • WHEN assertThrows(fn) is called without a type argument
  • THEN the inferred return type is Error

Scenario: async variant matches sync contract

  • WHEN assertThrowsAsync<TestingError>(asyncFn) is awaited
  • THEN the inferred awaited type is TestingError

Verified by: src/helpers/test-helpers.spec-conformance.test.ts::"assertThrows<TError>() returns TError" AND src/helpers/test-helpers.spec-conformance.test.ts::"assertThrows() without explicit TError defaults to Error" AND src/helpers/test-helpers.spec-conformance.test.ts::"assertThrowsAsync<TError>() returns Promise<TError>" AND src/helpers/test-helpers.spec-conformance.test.ts::"assertThrowsAsync() without explicit TError defaults to Error"

MODIFIED Requirements

Requirement: Coverage and docs gates

Istanbul statement + branch + function + line coverage across packages/dev/testing/src/ (excluding *.bench.ts and *.schemas.ts) MUST be 100%. Every implementation file (*.ts other than *.test.ts, *.bench.ts, *.schemas.ts, inner-dir *.types.ts, barrel index.ts) MUST have a sibling .md file of ≥ 200 words (excluding code fences) with a simple and an advanced usage example.

Scenario: Coverage gate in CI

  • WHEN bunx turbo test:coverage --filter=@suaveplan/testing runs
  • THEN reported statement, branch, function, and line coverage are each 100%

Scenario: Docs coverage enforced

  • WHEN a script enumerates implementation files under src/ (excluding the stated exclusions) and checks for sibling .md files
  • THEN every implementation file has a sibling .md AND every .md is ≥ 200 non-code words

Verified by: vitest.config.ts (coverage thresholds set to 100/100/100/100) AND bunx turbo test:coverage --filter=@suaveplan/testing (CI run) AND bun run docs:check (sibling-.md enumeration script)