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>forMyFixture extends ServiceFixture<X> - THEN
Cresolves toX
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.redisis typed asRedisConfig | undefinedandconfigs.postgresis typed asPostgresConfig | undefined
Scenario: setup() records names of fixtures whose start() threw
- WHEN one fixture's
start()rejects - THEN
failedcontains itsnameandconfigsdoes not contain its key
Scenario: teardown() stops fixtures in reverse start order
- WHEN three fixtures
a,b,cstart in that order, thenteardown()runs - THEN their
stop()methods are called in orderc,b,a
Scenario: teardown() before setup() is a no-op
- WHEN
teardown()is called on a freshTestEnvironmentwithout priorsetup() - 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, thenteardown()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()returnstrueon the first call - THEN
poll()resolves without sleeping
Scenario: Retries on false until healthy
- WHEN
healthCheck()returnsfalse, false, true - THEN
poll()resolves after exactly twosleep(intervalMs)calls
Scenario: Throws TestEnvironmentError on timeout
- WHEN
healthCheck()keeps returningfalsepasttimeoutMs - THEN the rejection is a
TestEnvironmentErrorwhose message includes"Health check timed out"and the fixture name
Scenario: timeoutMs === intervalMs permits one retry
- WHEN
timeoutMs === intervalMsandhealthCheck()returnsfalsethentrue - 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"andskipIfUnavailable("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
fixtureRegistrycontains both entries
Scenario: globalSetup path ends with global-setup.js
- WHEN
createIntegrationSetup({})returns - THEN
result.globalSetupis an absolute path ending withglobal-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(notunknownorRecord<string, unknown>)
Scenario: Multiple calls accumulate into registry
- WHEN
createIntegrationSetup({ redis })thencreateIntegrationSetup({ 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.tsis read - THEN it exports
TestEnvironment,TestEnvironmentError,HealthPoller,skipIfUnavailable,createIntegrationSetup, types, AND nothing whose name starts with_or lives underinternal/
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.jsonis inspected - THEN
peerDependencies.vitestis 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).