187 lines
13 KiB
Markdown
187 lines
13 KiB
Markdown
# 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<TConfig>() 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).
|