# testing-integration ## Purpose Core integration test infrastructure for the Suaveplan monorepo. Provides the `ServiceFixture` 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>` interface with `name: string`, `start(): Promise`, `stop(): Promise`, and `healthCheck(): Promise`. `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`, `healthCheck: () => Promise` #### Scenario: InferConfig extracts the TConfig type - **WHEN** a consumer writes `type C = InferConfig` for `MyFixture extends ServiceFixture` - **THEN** `C` resolves to `X` #### Scenario: InferConfig returns never for non-fixture types - **WHEN** `InferConfig` is evaluated - **THEN** the result is `never` **Verified by:** `src/fixture/service-fixture.test.ts::"enforces the ServiceFixture 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`. `setup()` MUST start every fixture in `TFixtures` in parallel via `Promise.allSettled`, return `{ configs: { [K in keyof TFixtures]?: InferConfig }; 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>(fixture: Pick, "name" | "healthCheck">): Promise`. 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(redisFixture)` is called - **THEN** TypeScript accepts the call without widening the fixture to `ServiceFixture>` **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 without widening cast"` --- ### Requirement: skipIfUnavailable beforeAll helper The package SHALL export `skipIfUnavailable(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(fixtures): IntegrationSetupResult` returning `{ globalSetup: string; inject: (key: K) => InferConfig }`. 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`) #### 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).