Skip to content

Commit b939045

Browse files
authored
test(testcontainers): hoist container boot off the test timer (#4686)
## What The one-off worker container boot is billed to whichever test resolves the fixture first. This moves it into a `beforeAll` with its own timeout. ## Why vitest runs the fixture chain *inside* the test timer: ```js // @vitest/runner 4.1.7 setFn(task, withTimeout(...withFixtures(handler)..., timeout, ...)) ``` There is no `fixtureTimeout`. So booting Postgres (plus `CREATE DATABASE`, schema push, ClickHouse and Redis) lands on the first test and consumes a budget sized for test work. That is why losing the image pre-pull on fork PRs was fatal rather than merely slower: the extra ~10s crossed the 60s cap. Since fork time is roughly internal + 10s and forks exceed 60s, internal runs were already clearing that cap by under 10s — a latent flake regardless of forks. ## How `withWarmup` wraps each fixture family and lazily registers a `beforeAll` on first touch, with its own generous timeout. Registration is lazy so only files that actually use a family pay for it — `@internal/testcontainers` is imported by hundreds of test files, many of which only need Redis. It registers once per file, since `isolate` gives each file a fresh module registry. Eight families are wrapped. `isolatedRedisTest`, `replicationContainerTest` and `postgresAndRedisTest` are deliberately untouched: they use per-test containers by design, so there is no one-off boot to hoist. No test file or CI changes, and it applies to every package using these fixtures. ## Verification Proven by mutation. `src/warmup.test.ts` runs container tests under a deliberately tight cap: | | Result | | --- | --- | | with the warm-up | passes | | warm-up neutered | fails, `Test timed out` | It is kept as a regression test — without it, unwrapping a fixture would break nothing visibly. `triggerFailedTask.call.test.ts`, one of the five shard casualties, passes locally in 20.4s. ## Also here `@internal/testcontainers` had no `test` script, so `turbo run test --filter "@internal/*"` skipped the package and its existing `heteroDedicated.test.ts` never ran in CI. Adding the script (matching the sibling packages') runs both files; verified green through turbo exactly as CI invokes it.
1 parent 7529c33 commit b939045

3 files changed

Lines changed: 156 additions & 50 deletions

File tree

internal-packages/testcontainers/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"tinyexec": "^0.3.0"
2727
},
2828
"scripts": {
29-
"typecheck": "tsc --noEmit"
29+
"typecheck": "tsc --noEmit",
30+
"test": "vitest --sequence.concurrent=false --no-file-parallelism"
3031
}
3132
}

internal-packages/testcontainers/src/index.ts

Lines changed: 121 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,44 @@ const prismaFromContainer = async (
314314
}
315315
};
316316

317-
export const postgresTest = test.extend<PostgresTestContext>({
318-
postgresContainer: clonedPostgresContainer,
319-
prisma: prismaFromContainer,
320-
});
317+
const CONTAINER_WARMUP_TIMEOUT_MS = 300_000;
318+
319+
type WarmableTestApi = {
320+
beforeAll: (fn: (context: any) => Promise<void>, timeout?: number) => void;
321+
};
322+
323+
const withWarmup = <T extends WarmableTestApi>(
324+
api: T,
325+
warmUp: (context: any) => Promise<void>
326+
): T => {
327+
const register = () => {
328+
api.beforeAll(warmUp, CONTAINER_WARMUP_TIMEOUT_MS);
329+
};
330+
331+
return new Proxy(api, {
332+
apply(target, thisArg, args) {
333+
register();
334+
return Reflect.apply(target as unknown as (...a: unknown[]) => unknown, thisArg, args);
335+
},
336+
get(target, prop, receiver) {
337+
if (prop !== "then") {
338+
// awaiting the module is not use
339+
register();
340+
}
341+
return Reflect.get(target, prop, receiver);
342+
},
343+
}) as T;
344+
};
345+
346+
export const postgresTest = withWarmup(
347+
test.extend<PostgresTestContext>({
348+
postgresContainer: clonedPostgresContainer,
349+
prisma: prismaFromContainer,
350+
}),
351+
async () => {
352+
await getWorkerPostgresContainer();
353+
}
354+
);
321355

322356
type HeteroPostgresTestContext = {
323357
// PG14 (legacy / control-plane DB analog)
@@ -609,11 +643,16 @@ type RedisTestContext = {
609643

610644
// Worker-scoped redis (boots once, FLUSHALL between tests). Use isolatedRedisTest for tests that run
611645
// background redis work (redis-worker Workers, BatchQueue) past the test body - see its note + README.
612-
export const redisTest = test.extend<RedisTestContext>({
613-
redisContainer: [bootWorkerRedis, { scope: "worker" }],
614-
resetRedis: [flushRedis, { auto: true }],
615-
redisOptions,
616-
});
646+
export const redisTest = withWarmup(
647+
test.extend<RedisTestContext>({
648+
redisContainer: [bootWorkerRedis, { scope: "worker" }],
649+
resetRedis: [flushRedis, { auto: true }],
650+
redisOptions,
651+
}),
652+
async ({ redisContainer }) => {
653+
void redisContainer;
654+
}
655+
);
617656

618657
// Per-test redis for tests with background redis work (redis-worker Workers, BatchQueue) that can
619658
// outlive the test body - a shared redis would let leaked work hit a closed connection / next test
@@ -723,11 +762,16 @@ const scopedClickhouseClient = async (
723762
}
724763
};
725764

726-
export const clickhouseTest = test.extend<ClickhouseTestContext>({
727-
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
728-
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
729-
clickhouseClient: scopedClickhouseClient,
730-
});
765+
export const clickhouseTest = withWarmup(
766+
test.extend<ClickhouseTestContext>({
767+
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
768+
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
769+
clickhouseClient: scopedClickhouseClient,
770+
}),
771+
async ({ clickhouseContainer }) => {
772+
void clickhouseContainer;
773+
}
774+
);
731775

732776
// NOTE: per-test containers (not worker-scoped) - the replication package does logical replication
733777
// (slots/publications/REPLICA IDENTITY), which doesn't play nicely with a shared container +
@@ -755,17 +799,24 @@ type ContainerTestContext = {
755799
// The workhorse fixture (~36 files). Postgres (template-clone), Redis (FLUSHALL) and ClickHouse
756800
// (truncate) all boot once per worker - no per-test container boots. Use containerTestWithIsolatedRedis
757801
// for tests that run background redis work (BatchQueue, redis-worker Workers) past the test body.
758-
export const containerTest = test.extend<ContainerTestContext>({
759-
postgresContainer: clonedPostgresContainer,
760-
prisma: prismaFromContainer,
761-
schemaOnlyPrisma: schemaOnlyPrismaFixture,
762-
redisContainer: [bootWorkerRedis, { scope: "worker" }],
763-
resetRedis: [flushRedis, { auto: true }],
764-
redisOptions,
765-
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
766-
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
767-
clickhouseClient: scopedClickhouseClient,
768-
});
802+
export const containerTest = withWarmup(
803+
test.extend<ContainerTestContext>({
804+
postgresContainer: clonedPostgresContainer,
805+
prisma: prismaFromContainer,
806+
schemaOnlyPrisma: schemaOnlyPrismaFixture,
807+
redisContainer: [bootWorkerRedis, { scope: "worker" }],
808+
resetRedis: [flushRedis, { auto: true }],
809+
redisOptions,
810+
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
811+
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
812+
clickhouseClient: scopedClickhouseClient,
813+
}),
814+
async ({ redisContainer, clickhouseContainer }) => {
815+
void redisContainer;
816+
void clickhouseContainer;
817+
await getWorkerPostgresContainer();
818+
}
819+
);
769820

770821
type ContainerWithIsolatedRedisContext = {
771822
network: StartedNetwork;
@@ -780,16 +831,22 @@ type ContainerWithIsolatedRedisContext = {
780831

781832
// Same as containerTest but Redis is PER-TEST - for tests whose background redis work (BatchQueue,
782833
// Workers) outlives the test body and would otherwise hit a closed/shared connection.
783-
export const containerTestWithIsolatedRedis = test.extend<ContainerWithIsolatedRedisContext>({
784-
network,
785-
postgresContainer: clonedPostgresContainer,
786-
prisma: prismaFromContainer,
787-
redisContainer,
788-
redisOptions,
789-
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
790-
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
791-
clickhouseClient: scopedClickhouseClient,
792-
});
834+
export const containerTestWithIsolatedRedis = withWarmup(
835+
test.extend<ContainerWithIsolatedRedisContext>({
836+
network,
837+
postgresContainer: clonedPostgresContainer,
838+
prisma: prismaFromContainer,
839+
redisContainer,
840+
redisOptions,
841+
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
842+
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
843+
clickhouseClient: scopedClickhouseClient,
844+
}),
845+
async ({ clickhouseContainer }) => {
846+
void clickhouseContainer;
847+
await getWorkerPostgresContainer();
848+
}
849+
);
793850

794851
type ContainerWithIsolatedRedisNoClickhouseContext = {
795852
network: StartedNetwork;
@@ -801,14 +858,18 @@ type ContainerWithIsolatedRedisNoClickhouseContext = {
801858

802859
// Like containerTestWithIsolatedRedis (template-clone Postgres + per-test Redis) but with no
803860
// ClickHouse - for suites that touch Postgres + Redis but never ClickHouse, avoiding its boot+migrate.
804-
export const containerTestWithIsolatedRedisNoClickhouse =
861+
export const containerTestWithIsolatedRedisNoClickhouse = withWarmup(
805862
test.extend<ContainerWithIsolatedRedisNoClickhouseContext>({
806863
network,
807864
postgresContainer: clonedPostgresContainer,
808865
prisma: prismaFromContainer,
809866
redisContainer,
810867
redisOptions,
811-
});
868+
}),
869+
async () => {
870+
await getWorkerPostgresContainer();
871+
}
872+
);
812873

813874
// For tests that exercise the Postgres -> ClickHouse logical-replication pipeline (WAL slots,
814875
// publications, REPLICA IDENTITY). These need a dedicated Postgres per test - the worker-scoped +
@@ -887,11 +948,16 @@ type MinioTestContext = {
887948
minioConfig: MinIOConnectionConfig;
888949
};
889950

890-
export const minioTest = test.extend<MinioTestContext>({
891-
minioContainer: [bootWorkerMinio, { scope: "worker" }],
892-
resetMinio: [minioReset, { auto: true }],
893-
minioConfig,
894-
});
951+
export const minioTest = withWarmup(
952+
test.extend<MinioTestContext>({
953+
minioContainer: [bootWorkerMinio, { scope: "worker" }],
954+
resetMinio: [minioReset, { auto: true }],
955+
minioConfig,
956+
}),
957+
async ({ minioContainer }) => {
958+
void minioContainer;
959+
}
960+
);
895961

896962
type PostgresAndMinioTestContext = {
897963
postgresContainer: StartedPostgreSqlContainer;
@@ -901,10 +967,16 @@ type PostgresAndMinioTestContext = {
901967
minioConfig: MinIOConnectionConfig;
902968
};
903969

904-
export const postgresAndMinioTest = test.extend<PostgresAndMinioTestContext>({
905-
postgresContainer: clonedPostgresContainer,
906-
prisma: prismaFromContainer,
907-
minioContainer: [bootWorkerMinio, { scope: "worker" }],
908-
resetMinio: [minioReset, { auto: true }],
909-
minioConfig,
910-
});
970+
export const postgresAndMinioTest = withWarmup(
971+
test.extend<PostgresAndMinioTestContext>({
972+
postgresContainer: clonedPostgresContainer,
973+
prisma: prismaFromContainer,
974+
minioContainer: [bootWorkerMinio, { scope: "worker" }],
975+
resetMinio: [minioReset, { auto: true }],
976+
minioConfig,
977+
}),
978+
async ({ minioContainer }) => {
979+
void minioContainer;
980+
await getWorkerPostgresContainer();
981+
}
982+
);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, vi } from "vitest";
2+
import { clickhouseTest, containerTest } from "./index";
3+
4+
vi.setConfig({ testTimeout: 10_000 });
5+
6+
describe.skip("a skipped suite that touches the fixture first", () => {
7+
containerTest("never runs", async ({ prisma }) => {
8+
expect(prisma).toBeDefined();
9+
});
10+
});
11+
12+
describe("container fixture warmup", () => {
13+
containerTest("the first test is not billed for the container boot", async ({ prisma }) => {
14+
const rows = await prisma.$queryRawUnsafe<Array<{ ok: number }>>("SELECT 1 as ok");
15+
16+
expect(rows[0]?.ok).toBe(1);
17+
});
18+
19+
containerTest("later tests still get a working fixture", async ({ prisma }) => {
20+
const rows = await prisma.$queryRawUnsafe<Array<{ ok: number }>>("SELECT 2 as ok");
21+
22+
expect(rows[0]?.ok).toBe(2);
23+
});
24+
});
25+
26+
describe("worker-scoped fixtures are warmed too", () => {
27+
clickhouseTest("clickhouse is up before the first test", async ({ clickhouseClient }) => {
28+
const rs = await clickhouseClient.query({ query: "SELECT 1 AS ok", format: "JSONEachRow" });
29+
const rows = await rs.json<{ ok: number }>();
30+
31+
expect(rows[0]?.ok).toBe(1);
32+
});
33+
});

0 commit comments

Comments
 (0)