first commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# TestEnvironment
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TestEnvironment` is the orchestrator for integration test suites that need several external services running concurrently. It accepts a named map of `ServiceFixture` instances, starts them all in parallel during `setup()`, records which ones failed to start, and stops the successful ones in reverse order during `teardown()`. This reverse-teardown order prevents cascading failures in services that depend on each other (e.g. stopping a connection pool before closing its database).
|
||||
|
||||
## Features
|
||||
|
||||
- **Parallel startup** — all fixtures are started with `Promise.allSettled`, so a slow service does not block faster ones.
|
||||
- **Graceful failure handling** — if a fixture's `start()` throws, its name is added to the `failed` list rather than propagating the error; tests can then use `skipIfUnavailable` to opt out.
|
||||
- **Reverse-order teardown** — fixtures are stopped in the reverse of their start-success order, respecting implicit dependency chains.
|
||||
- **Swallowed stop errors** — `stop()` errors are caught and discarded so a single broken fixture does not prevent the rest from cleaning up.
|
||||
- **Safe empty teardown** — calling `teardown()` before `setup()` (or when all fixtures failed) is a no-op.
|
||||
- **Protected internals** — `fixtures`, `options`, and `started` are `protected` to allow subclassing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TestEnvironment } from "@suaveplan/testing-integration/environment/test-environment";
|
||||
|
||||
const env = new TestEnvironment({
|
||||
redis: redisFixture,
|
||||
postgres: postgresFixture,
|
||||
});
|
||||
|
||||
const { configs, failed } = await env.setup();
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.warn("Some fixtures failed to start:", failed);
|
||||
}
|
||||
|
||||
console.log(configs.redis); // { url: "redis://localhost:6380" }
|
||||
console.log(configs.postgres); // { host: "localhost", port: 5433 }
|
||||
|
||||
await env.teardown();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Using `TestEnvironment` with `skipIfUnavailable` via the global-setup bridge:
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
|
||||
import { redisFixture } from "./fixtures/redis.js";
|
||||
import { postgresFixture } from "./fixtures/postgres.js";
|
||||
|
||||
const { globalSetup, inject } = createIntegrationSetup({
|
||||
redis: redisFixture,
|
||||
postgres: postgresFixture,
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: [globalSetup],
|
||||
},
|
||||
});
|
||||
|
||||
// my.test.ts
|
||||
import { beforeEach } from "vitest";
|
||||
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
|
||||
|
||||
beforeEach(skipIfUnavailable("redis"));
|
||||
|
||||
it("stores a value in redis", async () => {
|
||||
// guaranteed: redis was available when setup ran
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TestEnvironment`
|
||||
|
||||
**Constructor:** `new TestEnvironment(fixtures, options?)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `fixtures` | `Record<string, ServiceFixture<…>>` | Named map of fixtures to manage. |
|
||||
| `options` | `TestEnvironmentOptions` | Optional configuration (currently `timeoutMs` reserved). |
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `setup` | `() => Promise<TestEnvironmentSetupResult>` | Starts all fixtures in parallel. Returns configs and failed list. |
|
||||
| `teardown` | `() => Promise<void>` | Stops all started fixtures in reverse order. |
|
||||
|
||||
### `TestEnvironmentSetupResult`
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `configs` | `Record<string, Record<string, unknown>>` | Config objects keyed by fixture key for successful fixtures. |
|
||||
| `failed` | `string[]` | Names of fixtures whose `start()` threw. |
|
||||
|
||||
### `TestEnvironmentOptions`
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `timeoutMs` | `number?` | Reserved for future per-fixture start timeout. |
|
||||
|
||||
### `TestEnvironmentError`
|
||||
|
||||
Re-exported from `health-poller`. Use `instanceof TestEnvironmentError` to discriminate environment errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Promise.allSettled` is used instead of `Promise.all` so that one failing fixture does not prevent others from starting. The index of each settled result maps 1:1 with the `entries` array, so the correct fixture name can be attributed to each failure.
|
||||
|
||||
The `started` array tracks fixture **names** (not keys) in insertion order. During teardown, names are reversed and matched against `Object.values(this.fixtures)` by name. This means duplicate names across different keys would cause a teardown ambiguity — callers should ensure fixture names are unique within a `TestEnvironment` instance.
|
||||
@@ -0,0 +1,101 @@
|
||||
# ServiceFixture
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ServiceFixture<TConfig>` is the foundational contract for integration test service fixtures in Suaveplan. It defines a three-phase lifecycle — `start`, `healthCheck`, and `stop` — that allows `TestEnvironment` to manage any number of external services (containers, mock servers, in-process stores, etc.) uniformly. By coding against this interface rather than concrete implementations, consumers can swap fixture strategies without changing test setup code.
|
||||
|
||||
## Features
|
||||
|
||||
- **Generic configuration output** — `start()` returns a caller-defined `TConfig` (e.g. `{ url: string; port: number }`) so consuming tests receive strongly-typed connection details.
|
||||
- **Three-phase lifecycle** — explicit `start` / `stop` / `healthCheck` methods map directly to `TestEnvironment`'s parallel startup and reverse-order teardown algorithms.
|
||||
- **`InferConfig<F>` utility type** — extracts the `TConfig` type parameter from any `ServiceFixture` implementation at compile time, enabling generic helper functions.
|
||||
- **Zero runtime code** — the module is types-only; no JavaScript is emitted for this file, so it contributes nothing to bundle size.
|
||||
- **Idempotent stop contract** — implementations are required to tolerate repeated `stop()` calls without throwing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
|
||||
|
||||
interface RedisConfig {
|
||||
url: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
class MemoryRedisFixture implements ServiceFixture<RedisConfig> {
|
||||
readonly name = "memory-redis";
|
||||
|
||||
async start(): Promise<RedisConfig> {
|
||||
// spin up an in-process redis-compatible store
|
||||
return { url: "redis://localhost", port: 6380 };
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// flush and close the store
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
return true; // in-process is always ready
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Using `InferConfig` to write generic helpers that work with any fixture:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
InferConfig,
|
||||
ServiceFixture,
|
||||
} from "@suaveplan/testing-integration/fixture/service-fixture";
|
||||
|
||||
interface PostgresConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
}
|
||||
|
||||
interface PostgresFixture extends ServiceFixture<PostgresConfig> {
|
||||
readonly runMigrations: () => Promise<void>;
|
||||
}
|
||||
|
||||
// InferConfig pulls PostgresConfig out of PostgresFixture at compile time
|
||||
type Config = InferConfig<PostgresFixture>; // { host: string; port: number; database: string }
|
||||
|
||||
function buildConnectionString<F extends ServiceFixture<Record<string, unknown>>>(
|
||||
config: InferConfig<F> & { host: string; port: number },
|
||||
): string {
|
||||
return `${config.host}:${config.port}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ServiceFixture<TConfig>`
|
||||
|
||||
| Member | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | `readonly string` | Human-readable identifier used in error messages and the fixture registry. |
|
||||
| `start()` | `() => Promise<TConfig>` | Starts the service and returns connection config. Called by `TestEnvironment.setup()`. |
|
||||
| `stop()` | `() => Promise<void>` | Stops the service and releases resources. Must be idempotent. |
|
||||
| `healthCheck()` | `() => Promise<boolean>` | Returns `true` when the service is ready; `false` otherwise. Must not throw. |
|
||||
|
||||
### `InferConfig<F>`
|
||||
|
||||
Extracts `TConfig` from `ServiceFixture<TConfig>`. Returns `never` for any type that is not a `ServiceFixture` subtype.
|
||||
|
||||
**Type parameters:**
|
||||
|
||||
- `F` — the concrete fixture type to inspect
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TConfig` is constrained to `Record<string, unknown>` rather than a plain `object` to ensure callers always return a plain serialisable map — not a class instance or primitive. This constraint is enforced at the type level only; no runtime validation is performed here (validation lives in `TestEnvironment`).
|
||||
|
||||
The `healthCheck` method is intentionally separate from `start` to support fixtures that need to start a process first and then wait for it to become healthy (e.g. a Docker container). `HealthPoller` uses this method in a polling loop with configurable timeout and interval.
|
||||
|
||||
`InferConfig` is implemented as a conditional type (`F extends ServiceFixture<infer C> ? C : never`) rather than a mapped type to keep inference simple and to return `never` for non-fixture types — a common pattern when building generic test helpers.
|
||||
@@ -0,0 +1,104 @@
|
||||
# HealthPoller
|
||||
|
||||
## Purpose
|
||||
|
||||
`HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the service reports healthy or a timeout elapses. It is used internally by `TestEnvironment` during the setup phase, but can also be used standalone when you need fine-grained control over the polling strategy for a single fixture. The injectable `sleep` function makes it fully deterministic in unit tests — no real timers are required.
|
||||
|
||||
## Features
|
||||
|
||||
- **Configurable timeout** — `timeoutMs` (default 120 000 ms) controls the maximum wait window.
|
||||
- **Configurable interval** — `intervalMs` (default 1 000 ms) controls the delay between successive probes.
|
||||
- **Injectable sleep** — supply a fake `sleep` function in tests to avoid real delays and assert exact call counts.
|
||||
- **Structured error** — throws `TestEnvironmentError` (not a generic `Error`) so callers can discriminate on the error type.
|
||||
- **Zero dependencies** — relies only on `ServiceFixture` from the same package; no external runtime dependencies.
|
||||
- **Protected members** — `timeoutMs`, `intervalMs`, and `sleep` are `protected` so subclasses can override behavior.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HealthPoller } from "@suaveplan/testing-integration/helpers/health-poller";
|
||||
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
|
||||
|
||||
const poller = new HealthPoller({ timeoutMs: 30_000, intervalMs: 500 });
|
||||
|
||||
// myFixture.healthCheck() is called every 500 ms until true or 30 s elapses
|
||||
await poller.poll(myFixture);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a fake sleep function in tests so polling logic can be verified without real delays:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { HealthPoller, TestEnvironmentError } from "@suaveplan/testing-integration/helpers/health-poller";
|
||||
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
|
||||
|
||||
function makeFixture(responses: boolean[]): ServiceFixture<Record<string, unknown>> {
|
||||
let i = 0;
|
||||
return {
|
||||
name: "db",
|
||||
start: async () => ({}),
|
||||
stop: async () => {},
|
||||
healthCheck: async () => responses[i++] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
it("retries until healthy", async () => {
|
||||
const sleepCalls: number[] = [];
|
||||
const poller = new HealthPoller({
|
||||
timeoutMs: 5_000,
|
||||
intervalMs: 200,
|
||||
sleep: async (ms) => { sleepCalls.push(ms); },
|
||||
});
|
||||
|
||||
await poller.poll(makeFixture([false, false, true]));
|
||||
expect(sleepCalls).toEqual([200, 200]);
|
||||
});
|
||||
|
||||
it("throws TestEnvironmentError on timeout", async () => {
|
||||
const poller = new HealthPoller({
|
||||
timeoutMs: 400,
|
||||
intervalMs: 200,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
await expect(poller.poll(makeFixture([]))).rejects.toBeInstanceOf(TestEnvironmentError);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `HealthPoller`
|
||||
|
||||
**Constructor:** `new HealthPoller(options?: HealthPollerOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `timeoutMs` | `number` | `120_000` | Maximum milliseconds to wait before throwing. |
|
||||
| `intervalMs` | `number` | `1_000` | Milliseconds between `healthCheck()` calls. |
|
||||
| `sleep` | `(ms: number) => Promise<void>` | `setTimeout`-based | Injectable sleep for deterministic testing. |
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `poll` | `(fixture: ServiceFixture<…>) => Promise<void>` | Polls the fixture until healthy or timeout. Throws `TestEnvironmentError` on timeout. |
|
||||
|
||||
### `TestEnvironmentError`
|
||||
|
||||
Extends `Error`. The `name` property is always `"TestEnvironmentError"`. Thrown by `HealthPoller.poll()` when the timeout elapses before the fixture becomes healthy.
|
||||
|
||||
### `HealthPollerOptions`
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `timeoutMs` | `number` | No | Maximum wait time in milliseconds. |
|
||||
| `intervalMs` | `number` | No | Polling interval in milliseconds. |
|
||||
| `sleep` | `(ms: number) => Promise<void>` | No | Sleep implementation (injectable for tests). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The polling loop tracks `elapsed` time by accumulating `intervalMs` on each iteration rather than reading a real wall clock. This makes the poller's behavior fully deterministic when a fake `sleep` is injected — the timeout fires exactly when `elapsed > timeoutMs`, regardless of real time. In production use (default `setTimeout` sleep), elapsed time approximates real time closely enough for integration test purposes.
|
||||
|
||||
The timeout check is performed *after* sleeping rather than before to avoid a race where the first call returns unhealthy but elapsed immediately hits the timeout. With the check after sleep, there is always at least one full `intervalMs` of wait before a timeout is declared. The strict `>` comparison (rather than `>=`) means a fixture whose response latency equals exactly `timeoutMs` still gets one final health check after its sleep window, ensuring the timeout budget is truly exhausted before giving up.
|
||||
@@ -0,0 +1,78 @@
|
||||
# skipIfUnavailable
|
||||
|
||||
## Purpose
|
||||
|
||||
`skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named service fixture failed to start. It bridges the global-setup phase — where fixture health is determined — to individual test files, so a network problem with one service never causes unrelated tests to fail with cryptic connection errors. Instead, those tests are reported as skipped with a clear signal.
|
||||
|
||||
## Features
|
||||
|
||||
- **Vitest-native skip** — calls Vitest's built-in `ctx.skip()` so the test runner reports the test as skipped rather than failed.
|
||||
- **No throw on absent key** — if the `__integration_failed__` provide value is `null` or `undefined` (e.g. when running outside a global-setup context), the helper is a no-op and all tests run normally.
|
||||
- **Injectable test double** — `_createSkipIfUnavailableWithInject` exposes the internal factory with a replaceable `injectFn`, making the behaviour fully unit-testable without Vitest's global `provide`/`inject` infrastructure.
|
||||
- **Zero dependencies** — relies only on `vitest`'s `inject` export, which is already a dev dependency of every test file.
|
||||
- **Single responsibility** — does exactly one thing: check whether a fixture name appears in the failed list, and skip if so.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { beforeAll } from "vitest";
|
||||
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
|
||||
|
||||
// Any test in this file is skipped when the "redis" fixture failed to start.
|
||||
beforeAll(skipIfUnavailable("redis"));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Guarding multiple fixtures in one file, and composing with other `beforeAll` hooks:
|
||||
|
||||
```ts
|
||||
import { beforeAll, describe } from "vitest";
|
||||
import {
|
||||
skipIfUnavailable,
|
||||
_createSkipIfUnavailableWithInject,
|
||||
} from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
|
||||
|
||||
// Skip if either dependency is unavailable
|
||||
beforeAll(skipIfUnavailable("redis"));
|
||||
beforeAll(skipIfUnavailable("postgres"));
|
||||
|
||||
describe("integration suite", () => {
|
||||
// Unit-testing the helper itself:
|
||||
it("skips when fixture is in failed list", { timeout: 5000 }, () => {
|
||||
const cb = _createSkipIfUnavailableWithInject("redis", () => ["redis"]);
|
||||
const skipFn = vi.fn();
|
||||
cb({ skip: skipFn });
|
||||
expect(skipFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `skipIfUnavailable(fixtureName: string)`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fixtureName: string` — the key of the fixture to check against the `__integration_failed__` list.
|
||||
|
||||
**Returns:** `(ctx: { skip: () => void }) => void` — a callback suitable for `beforeAll`.
|
||||
|
||||
**Behaviour:** reads `__integration_failed__` from Vitest's `inject`. If the array includes `fixtureName`, calls `ctx.skip()`. Otherwise does nothing.
|
||||
|
||||
### `_createSkipIfUnavailableWithInject(fixtureName, injectFn)`
|
||||
|
||||
Internal factory exposed for unit testing.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fixtureName: string` — fixture key to check.
|
||||
- `injectFn: (key: string) => string[] | null | undefined` — replacement for `inject`.
|
||||
|
||||
**Returns:** `(ctx: { skip: () => void }) => void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `__integration_failed__` key is a convention shared between `global-setup.ts` (which calls `provide("__integration_failed__", failed)`) and `skipIfUnavailable`. The double-underscore prefix signals that this key is an internal implementation detail of the `@suaveplan/testing-integration` package and should not be read directly by consumer code.
|
||||
|
||||
The `null ?? []` fallback on the injected value ensures the helper is safe to use even when Vitest's global-setup mechanism is not configured — for example, in unit test suites that import test utilities but do not configure `globalSetup`. In that scenario, `inject("__integration_failed__")` returns `undefined`, the fallback produces an empty array, and no tests are skipped.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `internal/create-integration-setup-internal`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegrationSetup()` factory. Splitting the implementation into a wired core plus a thin public facade keeps the `vitest` runtime import isolated to one file (`../setup/create-integration-setup.ts`) and lets the unit tests cover the registration / inject hand-off without booting Vitest's global-setup machinery.
|
||||
|
||||
It is deliberately under `src/internal/` and not listed in `package.json` `exports`. Consumers MUST NOT import it; the public contract is `createIntegrationSetup()` from the package barrel. Tests in this package import this module directly because they own the implementation; downstream packages do not.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over the fixture map; preserves precise per-fixture config types end-to-end via `InferConfig`.
|
||||
- Pluggable `injectFn` so unit tests can verify the indirection through Vitest's `inject` without importing `vitest` from the test file under inspection.
|
||||
- Side-effect: mutates `fixtureRegistry` so the Vitest global-setup module sees the fixtures at boot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// PRIVATE — shown for documentation only. Use createIntegrationSetup() in app code.
|
||||
import { createIntegrationSetupWithInject } from "./internal/create-integration-setup-internal.js";
|
||||
|
||||
const { globalSetup, inject } = createIntegrationSetupWithInject(
|
||||
{ redis: redisFixture },
|
||||
(key) => ({ redis: { url: "redis://injected" } })
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The public `createIntegrationSetup` (in `../setup/create-integration-setup.ts`) is exactly this with Vitest's real `inject` wired in:
|
||||
|
||||
```ts
|
||||
import { inject as vitestInject } from "vitest";
|
||||
import { createIntegrationSetupWithInject } from "../internal/create-integration-setup-internal.js";
|
||||
|
||||
export function createIntegrationSetup<TFixtures>(fixtures: TFixtures) {
|
||||
return createIntegrationSetupWithInject(
|
||||
fixtures,
|
||||
vitestInject as (key: string) => Record<string, Record<string, unknown>>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createIntegrationSetupWithInject<TFixtures>(fixtures, injectFn)` — returns `{ globalSetup: string; inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]> }`. Writes every entry of `fixtures` into the shared `fixtureRegistry`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Resolves the global-setup path via `new URL("../setup/global-setup.js", import.meta.url).pathname`. This path is what consumers feed into `test.globalSetup` in their `vitest.config.ts`; using `import.meta.url` makes the reference robust to workspace layout differences.
|
||||
@@ -0,0 +1,45 @@
|
||||
# `internal/fixture-registry`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that bridges `createIntegrationSetup()` and the Vitest `global-setup.ts` entry point. It exists because Vitest's global-setup hook cannot accept arguments across the configuration boundary — so the registration step (called from `vitest.config.ts`) and the consumption step (called by Vitest before any test file loads) must communicate via a shared, in-process singleton.
|
||||
|
||||
The registry is deliberately placed under `src/internal/` and is **not** listed in `package.json` `exports`. It is private build-system plumbing. Consumers MUST NOT import it; doing so means relying on an unstable surface that the package may rearrange without a SemVer breaking change. The public surface remains `createIntegrationSetup()` + `globalSetup` + `inject()`; everything in `internal/` is implementation detail.
|
||||
|
||||
## Features
|
||||
|
||||
- Singleton `Map` keyed by user-chosen fixture names.
|
||||
- Stores the `ServiceFixture<Record<string, unknown>>` instances; the variant fixtures (`@suaveplan/testing-integration-redis`, etc.) all conform to this contract.
|
||||
- Mutated by `createIntegrationSetup()` (writes) and by `global-setup.ts` (reads + clear).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// PRIVATE — internal/* is not a published subpath. Shown for documentation only.
|
||||
import { fixtureRegistry } from "./internal/fixture-registry.js";
|
||||
|
||||
console.log(fixtureRegistry.size);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
`global-setup.ts` snapshot the registry into a plain object so the `TestEnvironment` constructor receives a stable view, then clears it so re-runs in watch mode start fresh:
|
||||
|
||||
```ts
|
||||
import { fixtureRegistry } from "./internal/fixture-registry.js";
|
||||
import { TestEnvironment } from "../environment/test-environment.js";
|
||||
|
||||
const fixtures = Object.fromEntries(fixtureRegistry);
|
||||
const env = new TestEnvironment(fixtures);
|
||||
const { configs, failed } = await env.setup();
|
||||
context.provide("__integration_configs__", configs);
|
||||
context.provide("__integration_failed__", failed);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `fixtureRegistry: Map<string, ServiceFixture<Record<string, unknown>>>` — mutable singleton, lifetime equal to the Node process. Reads and writes are not synchronised because Vitest serialises setup vs test execution.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `Record<string, unknown>` rather than the precise per-fixture config because the Vitest bridge erases the type at the cross-module boundary. The strongly-typed projection is restored at the `inject()` call site via the `InferConfig<TFixtures[K]>` mapping in `IntegrationSetupResult`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# `internal/skip-if-unavailable-internal`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailable()` helper. Splitting the implementation lets the public façade (`../helpers/skip-if-unavailable.ts`) hold the only `import { inject } from "vitest"` runtime call site, while unit tests exercise this factory with a fake injector and verify the behaviour without booting Vitest's `provide`/`inject` infrastructure.
|
||||
|
||||
It lives under `src/internal/` and is deliberately omitted from `package.json` `exports`. Consumers MUST NOT import it. The public surface is `skipIfUnavailable()` from the package barrel.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `TName extends string` so consumers using a typed `createIntegrationSetup` registry get key auto-completion and compile-time errors when they typo a fixture name.
|
||||
- Pluggable `injectFn` so unit tests can pass a fake without `vitest`.
|
||||
- Treats `null` / `undefined` returns from `injectFn` as the empty failed-list (no skip).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// PRIVATE — shown for documentation only. Use skipIfUnavailable() in app code.
|
||||
import { createSkipIfUnavailableWithInject } from "./internal/skip-if-unavailable-internal.js";
|
||||
|
||||
const cb = createSkipIfUnavailableWithInject("redis", (key) => ["redis"]);
|
||||
cb({ skip: () => console.log("skipped") });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The public `skipIfUnavailable()` uses Vitest's `inject` internally. When paired with a typed `createIntegrationSetup` registry, the generic preserves key safety:
|
||||
|
||||
```ts
|
||||
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
|
||||
beforeAll(skipIfUnavailable<"redis" | "postgres">("redis"));
|
||||
// skipIfUnavailable<"redis" | "postgres">("typeo") // compile error — typo caught
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSkipIfUnavailableWithInject<TName extends string>(fixtureName, injectFn)` — returns `(ctx: { skip: () => void }) => void`. The returned callback reads the failed-fixture list via `injectFn("__integration_failed__")` and calls `ctx.skip()` if `fixtureName` is in it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cast `null as unknown as string[]` in tests verifies the `?? []` guard. Production callers never pass a function that returns `null`, but Vitest's `inject` typing is loose, so the guard is load-bearing in real-world consumers.
|
||||
@@ -0,0 +1,91 @@
|
||||
# createIntegrationSetup
|
||||
|
||||
## Purpose
|
||||
|
||||
`createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup lifecycle. It accepts a named map of `ServiceFixture` instances, registers them in a module-level registry that `global-setup.ts` reads at startup, and returns two things: the absolute path of the Vitest global-setup module (ready to drop into `test.globalSetup`) and a typed `inject` helper that test files use to retrieve fixture configs. This one-call configuration model means consumers never need to import from `global-setup.ts` directly.
|
||||
|
||||
## Features
|
||||
|
||||
- **Typed `inject` helper** — the return type of `inject(key)` is inferred from the fixture's `TConfig` type parameter via `InferConfig`, so no type casts are needed in test files.
|
||||
- **Auto-resolved `globalSetup` path** — the path is derived from `import.meta.url`, so it works regardless of where the consumer's `vitest.config.ts` lives.
|
||||
- **Additive registry** — multiple calls to `createIntegrationSetup` accumulate fixtures into a shared registry; this allows split configuration across multiple config files.
|
||||
- **Injectable test double** — `createIntegrationSetupWithInject` (under the package-private `internal/` directory) exposes the factory with a replaceable `injectFn` for unit testing without real Vitest `provide`/`inject` infrastructure.
|
||||
- **Zero runtime overhead** — the function does no async work; startup cost is deferred entirely to `global-setup.ts`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
|
||||
import { redisFixture } from "./fixtures/redis.js";
|
||||
|
||||
const { globalSetup, inject } = createIntegrationSetup({ redis: redisFixture });
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: [globalSetup],
|
||||
},
|
||||
});
|
||||
|
||||
export { inject };
|
||||
|
||||
// redis.test.ts
|
||||
import { inject } from "../vitest.config.js";
|
||||
import { beforeEach } from "vitest";
|
||||
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
|
||||
|
||||
beforeEach(skipIfUnavailable("redis"));
|
||||
|
||||
it("connects to redis", async () => {
|
||||
const { url } = inject("redis"); // typed as RedisConfig
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Accumulating fixtures across multiple config calls:
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
|
||||
|
||||
// Split fixture definitions across modules
|
||||
const { globalSetup: gsA } = createIntegrationSetup({ redis: redisFixture });
|
||||
const { globalSetup: gsB, inject } = createIntegrationSetup({ postgres: postgresFixture });
|
||||
|
||||
// Both fixture sets are registered in the shared package-private fixtureRegistry
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: [gsA], // only one path needed — registry is shared
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createIntegrationSetup(fixtures)`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fixtures: Record<string, ServiceFixture<…>>` — named map of fixtures to register.
|
||||
|
||||
**Returns:** `IntegrationSetupResult<TFixtures>`
|
||||
|
||||
### `IntegrationSetupResult<TFixtures>`
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `globalSetup` | `string` | Absolute path to `global-setup.js`. Add to `test.globalSetup`. |
|
||||
| `inject` | `<K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]>` | Typed accessor for fixture configs populated by global-setup. |
|
||||
|
||||
### Package-private internals
|
||||
|
||||
`createIntegrationSetupWithInject(fixtures, injectFn)` and the `fixtureRegistry` `Map` live under `src/internal/` and are deliberately NOT exported via `package.json` `exports`. They are reachable only from within this package's own source tree (used by tests and by the public façade above) and consumers MUST NOT rely on them.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `globalSetup` path is computed via `new URL("./global-setup.js", import.meta.url).pathname`. This resolves relative to the compiled output location so the path is always valid after `vite build`. It does not depend on `__dirname` or any Node.js path resolution, making it compatible with ES module environments.
|
||||
|
||||
The `inject` closure captures `injectFn` at creation time and calls it lazily — only when `inject(key)` is invoked inside a test. This lazy evaluation is necessary because Vitest's `inject` only returns values after the global-setup `provide` calls have run.
|
||||
@@ -0,0 +1,73 @@
|
||||
# global-setup
|
||||
|
||||
## Purpose
|
||||
|
||||
`global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSetup` with the test environment. It exports `setup()` and `teardown()` lifecycle hooks that Vitest calls before and after the test suite. During `setup()`, it starts all registered fixtures via `TestEnvironment` and makes their configs and failure list available to every test file through Vitest's `provide`/`inject` mechanism. This design means consumers never need to manage container startup/teardown in individual test files.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic fixture orchestration** — reads from `_fixtureRegistry` (populated by `createIntegrationSetup`) and delegates to `TestEnvironment` for parallel startup and reverse-order teardown.
|
||||
- **Vitest `provide` bridge** — calls `provide("__integration_configs__", configs)` and `provide("__integration_failed__", failed)` so test files can access this data via `inject()` or the `skipIfUnavailable` helper.
|
||||
- **Graceful partial failure** — uses `TestEnvironment`'s `Promise.allSettled`-based startup so a single failing fixture does not abort the entire suite.
|
||||
- **Safe teardown** — wraps `env?.teardown()` so teardown is a no-op if `setup()` never ran (e.g. in degenerate configurations).
|
||||
- **Excluded from library build** — listed in `tsconfig.lib.json`'s `exclude` array and the Vite `external` list so it does not appear in the distributed package.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
|
||||
import { redisFixture } from "./fixtures/redis.js";
|
||||
|
||||
const { globalSetup } = createIntegrationSetup({ redis: redisFixture });
|
||||
|
||||
export default {
|
||||
test: {
|
||||
globalSetup: [globalSetup], // <-- points to global-setup.js in the dist
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The module is used indirectly via `createIntegrationSetup` in almost all cases. However, if you need to import it directly (e.g. for a custom global-setup wrapper), you can do so:
|
||||
|
||||
```ts
|
||||
// custom-global-setup.ts
|
||||
import { setup as integrationSetup, teardown as integrationTeardown } from "@suaveplan/testing-integration/setup/global-setup";
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
|
||||
import { redisFixture } from "./fixtures/redis.js";
|
||||
|
||||
createIntegrationSetup({ redis: redisFixture });
|
||||
|
||||
export async function setup() {
|
||||
await integrationSetup();
|
||||
// additional custom setup...
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
await integrationTeardown();
|
||||
// additional custom teardown...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setup(): Promise<void>`
|
||||
|
||||
Called by Vitest at the start of the test run.
|
||||
|
||||
Reads all fixtures from `_fixtureRegistry`, constructs a `TestEnvironment`, calls `setup()`, then:
|
||||
|
||||
- Calls `provide("__integration_configs__", configs)` — map of fixture key → config object.
|
||||
- Calls `provide("__integration_failed__", failed)` — array of fixture names that failed to start.
|
||||
|
||||
### `teardown(): Promise<void>`
|
||||
|
||||
Called by Vitest at the end of the test run. Delegates to `env.teardown()` if `env` was created. Safe to call when `setup()` was never called.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `env` variable is module-scoped rather than exported because Vitest loads global-setup modules as ES modules and re-uses the module instance between `setup` and `teardown` calls. This ensures `teardown` always references the same `TestEnvironment` that `setup` created.
|
||||
|
||||
This file is intentionally excluded from `tsconfig.lib.json` because it imports `vitest/node` (a server-only Vitest API), which should not appear in a library's type definitions. It is still built by Vite as a separate output module because the Rollup `external` list excludes `vitest/node`, allowing it to be bundled independently and resolved at runtime.
|
||||
Reference in New Issue
Block a user