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

13 KiB

testing-integration

Purpose

Core integration test infrastructure for the Suaveplan monorepo. Provides the ServiceFixture<TConfig> interface, TestEnvironment orchestrator (parallel startup, reverse teardown), HealthPoller, skipIfUnavailable() helper, and createIntegrationSetup() factory with a Vitest global-setup bridge. Enables real-service integration tests across the monorepo without mocks. Depends on @suaveplan/types and testcontainers.

Requirements

Requirement: ServiceFixture contract

The package SHALL export a ServiceFixture<TConfig extends Record<string, unknown>> interface with name: string, start(): Promise<TConfig>, stop(): Promise<void>, and healthCheck(): Promise<boolean>. stop() MUST be idempotent. healthCheck() MUST NOT throw — it returns false to indicate "not yet ready" or "no container to probe".

Scenario: Implementations satisfy the four-method shape

  • WHEN a value is typed as ServiceFixture<{ host: string; port: number }>
  • THEN TypeScript enforces name: string, start: () => Promise<{ host: string; port: number }>, stop: () => Promise<void>, healthCheck: () => Promise<boolean>

Scenario: InferConfig extracts the TConfig type

  • WHEN a consumer writes type C = InferConfig<MyFixture> for MyFixture extends ServiceFixture<X>
  • THEN C resolves to X

Scenario: InferConfig returns never for non-fixture types

  • WHEN InferConfig<string> is evaluated
  • THEN the result is never

Verified by: src/fixture/service-fixture.test.ts::"enforces the ServiceFixture<TConfig> shape" AND src/fixture/service-fixture.test.ts::"InferConfig extracts TConfig from a ServiceFixture implementation" AND src/fixture/service-fixture.test.ts::"InferConfig returns never for non-ServiceFixture types"


Requirement: TestEnvironment generic over fixture map

The package SHALL export TestEnvironment<TFixtures extends DefaultFixtureMap = DefaultFixtureMap>. setup() MUST start every fixture in TFixtures in parallel via Promise.allSettled, return { configs: { [K in keyof TFixtures]?: InferConfig<TFixtures[K]> }; failed: string[] }, and stop fixtures in reverse start order during teardown(). teardown() MUST swallow per-fixture stop() errors so partial failures do not abort the rest of the teardown. The default generic parameter MUST keep pre-existing erased call sites compiling.

Scenario: setup() returns configs keyed by fixture name with precise per-fixture types

  • WHEN new TestEnvironment({ redis, postgres }).setup() is called
  • THEN the result configs.redis is typed as RedisConfig | undefined and configs.postgres is typed as PostgresConfig | undefined

Scenario: setup() records names of fixtures whose start() threw

  • WHEN one fixture's start() rejects
  • THEN failed contains its name and configs does not contain its key

Scenario: teardown() stops fixtures in reverse start order

  • WHEN three fixtures a, b, c start in that order, then teardown() runs
  • THEN their stop() methods are called in order c, b, a

Scenario: teardown() before setup() is a no-op

  • WHEN teardown() is called on a fresh TestEnvironment without prior setup()
  • THEN it resolves without throwing

Scenario: teardown() swallows errors from stop()

  • WHEN a fixture's stop() throws
  • THEN teardown() still resolves and the error is not propagated

Scenario: setup() runs fixtures in parallel

  • WHEN two fixtures both block on a promise during start()
  • THEN both start() invocations begin before either resolves

Scenario: setup() re-runs clear started list to avoid double-stop

  • WHEN setup() is invoked twice in a row, then teardown() runs
  • THEN each fixture's stop() is called exactly once

Verified by: src/environment/test-environment.test.ts::"setup() starts all fixtures and returns configs + empty failed list" AND src/environment/test-environment.test.ts::"setup() records failed fixture names" AND src/environment/test-environment.test.ts::"teardown() stops fixtures in reverse start order" AND src/environment/test-environment.test.ts::"teardown() is safe when setup() was never called" AND src/environment/test-environment.test.ts::"teardown() swallows errors from stop()" AND src/environment/test-environment.test.ts::"setup() runs fixtures in parallel" AND src/environment/test-environment.test.ts::"setup() clears started list before re-running so teardown does not double-stop" AND src/environment/test-environment.spec-conformance.test.ts::"setup() configs[K] is the precise fixture TConfig" AND src/environment/test-environment.spec-conformance.test.ts::"default generic parameter keeps existing call sites compiling"


Requirement: HealthPoller polls fixtures with shared timeout semantics

The package SHALL export HealthPoller with poll<TConfig extends Record<string, unknown>>(fixture: Pick<ServiceFixture<TConfig>, "name" | "healthCheck">): Promise<void>. Polling MUST sleep intervalMs between probes, MUST throw a TestEnvironmentError (whose name === "TestEnvironmentError" and which extends Error) when wall-clock exceeds timeoutMs, and MUST NOT count the initial probe against the timeout (so timeoutMs === intervalMs permits at least one retry after the first unhealthy response). Default timeoutMs is 120_000; default intervalMs is 1_000. The sleep callback MUST be injectable so unit tests do not wait wall-clock seconds.

Scenario: Resolves immediately when first probe is healthy

  • WHEN healthCheck() returns true on the first call
  • THEN poll() resolves without sleeping

Scenario: Retries on false until healthy

  • WHEN healthCheck() returns false, false, true
  • THEN poll() resolves after exactly two sleep(intervalMs) calls

Scenario: Throws TestEnvironmentError on timeout

  • WHEN healthCheck() keeps returning false past timeoutMs
  • THEN the rejection is a TestEnvironmentError whose message includes "Health check timed out" and the fixture name

Scenario: timeoutMs === intervalMs permits one retry

  • WHEN timeoutMs === intervalMs and healthCheck() returns false then true
  • THEN poll() resolves rather than firing the timeout after the first unhealthy response

Scenario: poll() preserves precise fixture type

  • WHEN HealthPoller.poll<RedisConfig>(redisFixture) is called
  • THEN TypeScript accepts the call without widening the fixture to ServiceFixture<Record<string, unknown>>

Verified by: src/helpers/health-poller.test.ts::"resolves immediately when first healthCheck returns true" AND src/helpers/health-poller.test.ts::"retries on false and resolves when healthCheck eventually returns true" AND src/helpers/health-poller.test.ts::"throws TestEnvironmentError when timeout elapses" AND src/helpers/health-poller.test.ts::"succeeds when fixture is unhealthy on call 1 but healthy on call 2, with timeoutMs === intervalMs" AND src/helpers/health-poller.test.ts::"uses default intervalMs of 1000" AND src/helpers/health-poller.test.ts::"uses default timeoutMs of 120000" AND src/helpers/health-poller.test.ts::"TestEnvironmentError has name TestEnvironmentError" AND src/environment/test-environment.spec-conformance.test.ts::"accepts any ServiceFixture<TConfig> without widening cast"


Requirement: skipIfUnavailable beforeAll helper

The package SHALL export skipIfUnavailable<TName extends string = string>(fixtureName: TName): (ctx: { skip: () => void }) => void. The returned callback MUST read __integration_failed__ via Vitest's inject and MUST call ctx.skip() if fixtureName is in the list. A null/undefined return from inject MUST be treated as the empty list (no skip). The generic parameter MUST allow consumers paired with a typed createIntegrationSetup registry to constrain fixtureName to known keys.

Scenario: Skips when fixture is in failed list

  • WHEN the failed list contains "redis" and skipIfUnavailable("redis")() runs
  • THEN ctx.skip() is invoked exactly once

Scenario: Does not skip when fixture absent

  • WHEN the failed list does not contain the fixture name
  • THEN ctx.skip() is never called

Scenario: Treats null inject result as empty list

  • WHEN the injected list is null
  • THEN ctx.skip() is never called

Scenario: Generic narrows fixtureName

  • WHEN skipIfUnavailable<"redis" | "postgres">("redis") compiles
  • THEN TypeScript would reject skipIfUnavailable<"redis" | "postgres">("typeo")

Verified by: src/helpers/skip-if-unavailable.test.ts::"calls skip() when fixture name is in failed list" AND src/helpers/skip-if-unavailable.test.ts::"does not call skip() when fixture name absent from failed list" AND src/helpers/skip-if-unavailable.test.ts::"does not call skip() when inject returns null" AND src/helpers/skip-if-unavailable.test.ts::"public skipIfUnavailable() returns a function" AND src/helpers/skip-if-unavailable.test.ts::"typed generic narrows fixtureName to known keys" AND src/environment/test-environment.spec-conformance.test.ts::"constrains fixtureName via TName generic"


Requirement: createIntegrationSetup wires fixtures + typed inject()

The package SHALL export createIntegrationSetup<TFixtures>(fixtures): IntegrationSetupResult<TFixtures> returning { globalSetup: string; inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]> }. Calling MUST register every entry in the package-private fixtureRegistry. globalSetup MUST be an absolute path ending in global-setup.js. Multiple calls MUST accumulate into the shared registry. The implementation MAY share the underlying logic via internal/createIntegrationSetupWithInject for unit testing, but the internal/ subpath MUST NOT be listed in package.json exports.

Scenario: Populates registry with provided fixtures

  • WHEN createIntegrationSetup({ redis, postgres }) is called
  • THEN the package-private fixtureRegistry contains both entries

Scenario: globalSetup path ends with global-setup.js

  • WHEN createIntegrationSetup({}) returns
  • THEN result.globalSetup is an absolute path ending with global-setup.js

Scenario: inject(key) returns the precise per-fixture config type

  • WHEN createIntegrationSetup({ redis, postgres }).inject("redis") is called
  • THEN the return type is RedisConfig (not unknown or Record<string, unknown>)

Scenario: Multiple calls accumulate into registry

  • WHEN createIntegrationSetup({ redis }) then createIntegrationSetup({ postgres }) are called
  • THEN the registry has both entries

Verified by: src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures" AND src/setup/create-integration-setup.test.ts::"returns globalSetup as absolute path ending with global-setup.js" AND src/setup/create-integration-setup.test.ts::"inject() reads from __integration_configs__ via injectable injectFn" AND src/setup/create-integration-setup.test.ts::"multiple calls accumulate into registry" AND src/environment/test-environment.spec-conformance.test.ts::"inject(key) returns the precise per-fixture config type"


Requirement: Internal subpath is not part of the public surface

The package's package.json exports map MUST NOT contain "./internal" or "./internal/*". The src/internal/* modules (fixtureRegistry, createIntegrationSetupWithInject, createSkipIfUnavailableWithInject) are reachable only from within the package's own source tree.

Scenario: Public barrel only re-exports the supported surface

  • WHEN the package barrel src/index.ts is read
  • THEN it exports TestEnvironment, TestEnvironmentError, HealthPoller, skipIfUnavailable, createIntegrationSetup, types, AND nothing whose name starts with _ or lives under internal/

Verified by: src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures" (the test imports fixtureRegistry directly from ../internal/fixture-registry.js — proving consumers MUST do likewise to reach it, which they cannot since internal/ is not exported).


Requirement: vitest is a peer dependency

The core package.json MUST list vitest in peerDependencies, NOT only in devDependencies. The runtime import in src/setup/create-integration-setup.ts (import { inject } from "vitest") requires the consumer to have a satisfying vitest install at runtime.

Scenario: peer declaration exists

  • WHEN the published package.json is inspected
  • THEN peerDependencies.vitest is set to >=3.0.0

Verified by: src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures" (this test imports createIntegrationSetup, which transitively imports vitest; the test's success in CI proves the peer is resolvable).