From tap-testcontainers
ALWAYS consult this skill when the user works with Testcontainers, Docker containers for tests, tap before/after hooks for container lifecycle, or test environment setup with test-env.json and localtest.ts. This skill contains the exact architecture for container-based test infrastructure (Ryuk reaper, before.js executors, container runners, teardown.js, localtest.ts environment loader) that Claude cannot reconstruct correctly without consulting. Without this skill, Claude will produce incorrect container lifecycle management, miss reaper label configuration, and break inter-process environment variable passing. Covers: Testcontainers setup, GenericContainer, Ryuk reaper, before.js/teardown.js executors, container runners (Redis, MySQL, PostgreSQL, LocalStack), test-env.json, localtest.ts, TEST_LOCAL and SKIP_TEST_* environment variables, tap before/after hooks for containers. Does NOT cover test code patterns (see fastify-testing) or app architecture (see fastify-expert). Trigger on ANY mention of: testcontainers, testcontainer, GenericContainer, docker test, container test, before.js, teardown.js, localtest.ts, test-env.json, Ryuk, reaper, TEST_LOCAL, SKIP_TEST, container runner, test infrastructure, test environment setup, tap before hook, tap after hook, container lifecycle.
How this skill is triggered — by the user, by Claude, or both
Slash command
/tap-testcontainers:tap-testcontainersThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill provides patterns for writing integration tests in Node.js using **node-tap** as the test framework and **Testcontainers** for spinning up Docker-based dependencies (Redis, PostgreSQL, etc.).
This skill provides patterns for writing integration tests in Node.js using node-tap as the test framework and Testcontainers for spinning up Docker-based dependencies (Redis, PostgreSQL, etc.).
The architecture uses tap's before/after lifecycle hooks to start and stop containers, a shared test-env.json file to pass connection details between processes, and a localtest.ts helper that each test imports to load the environment.
tap before (before.js)
├── Start Ryuk reaper (container cleanup safety net)
├── Start service containers (Redis, Postgres, etc.)
├── Run bootstrap functions (seed data, migrations)
└── Write test-env.json (connection URLs, reaper info)
│
▼
test files (*.test.ts)
├── import localtest.ts
│ ├── Load .env.test (static config)
│ ├── Load test-env.json (dynamic container info) into process.env
│ └── Connect to Ryuk reaper socket (keep-alive)
└── Run tests using process.env for service URLs
│
▼
tap after (teardown.js)
├── Stop all containers matching reaper-session-id
└── Delete test-env.json
test/
├── helpers/
│ └── localtest.ts # Environment loader, imported by every test file
├── scripts/
│ ├── executors/
│ │ ├── before.js # Tap before hook: start containers
│ │ └── teardown.js # Tap after hook: stop containers
│ └── runners/
│ ├── redis.js # Redis container start + bootstrap
│ └── postgres.js # (example) Postgres container start + bootstrap
├── my-feature.test.ts
└── another.test.ts
npm install --save-dev tap testcontainers dotenv
package.json{
"tap": {
"before": "./test/scripts/executors/before.js",
"after": "./test/scripts/executors/teardown.js",
"exclude": [
"test/helpers/**/*",
"test/scripts/**/*"
]
},
"scripts": {
"test": "tap --timeout=90",
"test:debug": "tap --only --timeout=0",
"test:local": "TEST_LOCAL=true tap",
"test:local:debug": "TEST_LOCAL=true tap --only --timeout=0",
"test:coverage": "tap --coverage-report=lcovonly --coverage-report=text"
}
}
Each service gets its own runner module that exports startContainer() and bootstrap().
Example for Redis — test/scripts/runners/redis.js:
import { GenericContainer, Wait } from "testcontainers";
const startContainer = async () => {
const redis = await new GenericContainer("redis:latest")
.withExposedPorts(6379)
.withLabels({
// Mandatory: links this container to the reaper session for cleanup
"org.testcontainers.reaper-session-id": process.env.REAPER_SESSION_ID
})
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections tcp"))
.start();
const port = redis.getMappedPort(6379);
const host = redis.getHost();
return {
container: redis,
port,
host
};
}
/**
* Optional bootstrap function to seed data or run migrations after container start.
*/
const bootstrap = async (host, port) => {
// e.g. seed Redis with test data
};
export {
startContainer,
bootstrap
}
Example for MySQL — test/scripts/runners/mysql.js:
import { GenericContainer, Wait } from "testcontainers";
const startContainer = async () => {
const mysql = await new GenericContainer("mysql:8")
.withExposedPorts(3306)
.withLabels({
"org.testcontainers.reaper-session-id": process.env.REAPER_SESSION_ID
})
.withEnvironment({
MYSQL_ROOT_PASSWORD: "test",
MYSQL_DATABASE: "testdb",
})
.withWaitStrategy(Wait.forLogMessage("ready for connections"))
.start();
const port = mysql.getMappedPort(3306);
const host = mysql.getHost();
return {
container: mysql,
port,
host
};
}
/**
* Run migrations or seed data after container start.
*/
const bootstrap = async (host, port) => {
// e.g. run Prisma migrations against mysql://root:test@${host}:${port}/testdb
};
export {
startContainer,
bootstrap
}
The pattern is the same for any service: export startContainer() and bootstrap(), then wire them in before.js.
before.js executortest/scripts/executors/before.js — starts the Ryuk reaper, launches containers, and writes test-env.json:
import { bootstrap as bootstrapRedis, startContainer as startContainerRedis } from "../runners/redis.js";
import { writeFile } from "node:fs/promises";
import { getReaper } from "testcontainers/build/reaper/reaper.js";
import { getContainerRuntimeClient } from "testcontainers";
import { randomUUID } from "node:crypto";
const startReaper = async () => {
if (process.env.TESTCONTAINERS_RYUK_DISABLED === "true" || process.env.TESTCONTAINERS_RYUK_DISABLED === "1") {
return {};
}
const containerRuntimeClient = await getContainerRuntimeClient();
await getReaper(containerRuntimeClient);
const runningContainers = await containerRuntimeClient.container.list();
const reaper = runningContainers.find((container) => container.Labels["org.testcontainers.ryuk"] === "true");
const reaperNetwork = reaper.Ports.find((port) => port.PrivatePort == 8080);
const reaperPort = reaperNetwork.PublicPort;
const reaperIp = containerRuntimeClient.info.containerRuntime.host;
const reaperSessionId = reaper.Labels["org.testcontainers.session-id"];
return {
REAPER: `${reaperIp}:${reaperPort}`,
REAPER_SESSION: reaperSessionId,
}
};
const before = async () => {
if (!process.env.TEST_LOCAL) {
return;
}
console.log("Start Reaper");
const reaperEnv = await startReaper();
process.env.REAPER_SESSION_ID = reaperEnv.REAPER_SESSION ?? randomUUID();
if (!process.env.SKIP_TEST_REDIS_SETUP) {
console.log("Start Redis");
const { port: redisPort, host: redisHost } = await startContainerRedis();
process.env.REDIS_URL = `redis://${redisHost}:${redisPort}`;
await bootstrapRedis(redisHost, redisPort);
}
await writeFile("test-env.json", JSON.stringify({
...reaperEnv,
...process.env,
}));
}
export default before();
teardown.js executortest/scripts/executors/teardown.js — stops containers and cleans up test-env.json:
import { unlink } from "node:fs/promises";
import { getContainerRuntimeClient } from "testcontainers";
import fs from "node:fs";
import path from "node:path";
const teardown = async () => {
if (process.env.TEST_LOCAL) {
const jsonString = fs.readFileSync(path.resolve(process.cwd(), "test-env.json"), {
encoding: "utf8"
});
const testEnv = JSON.parse(jsonString);
if (testEnv.REAPER_SESSION_ID !== undefined && testEnv.REAPER_SESSION_ID !== "") {
const reaperSessionId = testEnv.REAPER_SESSION_ID;
const containerRuntimeClient = await getContainerRuntimeClient();
const runningContainers = await containerRuntimeClient.container.list();
const containers = runningContainers.filter((container) => container.Labels["org.testcontainers.reaper-session-id"] === reaperSessionId);
for (const containerInfo of containers) {
const container = containerRuntimeClient.container.getById(containerInfo.Id);
await containerRuntimeClient.container.stop(container);
}
}
await unlink("test-env.json");
}
}
export default teardown();
localtest.tstest/helpers/localtest.ts — imported by every test file to load environment and connect to the reaper:
import fs from "node:fs";
import { Socket } from "node:net";
import path from "node:path";
import { config as dotenvConfig } from "dotenv";
import tap from "tap";
const defaultExport = () => {
dotenvConfig({
path: ".env.test",
});
if (!process.env.TEST_LOCAL) {
const jsonString = fs.readFileSync(
path.resolve(process.cwd(), "test-env.json"),
{
encoding: "utf8",
},
);
try {
const envConfig = JSON.parse(jsonString);
for (const key in envConfig) {
process.env[key] = envConfig[key];
}
} catch (err) {
console.error(err);
}
}
if (process.env.REAPER) {
const [host, port] = process.env.REAPER.split(":");
const socket = new Socket();
socket.connect(Number(port), host, () => {
socket.write(
`label=org.testcontainers.session-id=${process.env.REAPER_SESSION}\r\n`,
);
});
socket.on("error", (error) => {
console.log(error);
});
tap.teardown(() => {
setTimeout(() => {
socket.destroy();
// force kill the process if it doesn't stop on its own
setTimeout(() => {
process.exit(0);
}, 300);
}, 300);
});
}
};
defaultExport();
.env.testAdd a .env.test file with static test configuration (values that don't depend on containers):
APP_ENV=test
# Add any static test config here
# Dynamic values (REDIS_URL, etc.) come from test-env.json
Import the localtest.ts helper as the first import in every test file:
import "../helpers/localtest";
import t from "tap";
import startServer from "../../src/index.js";
t.test("my integration test", async (t) => {
const app = await startServer(/* test config */);
t.teardown(() => app.close());
const response = await app.inject({ method: "GET", url: "/up" });
t.equal(response.statusCode, 200);
});
Speed up test execution by skipping containers via environment variables:
| Variable | Effect |
|---|---|
TEST_LOCAL=true | Skip ALL containers (use existing local services) |
SKIP_TEST_<SERVICE>_SETUP=true | Skip a specific container (e.g. SKIP_TEST_REDIS_SETUP) |
# Run with containers (CI or full integration)
pnpm test
# Run without any containers (fastest, requires local services running)
TEST_LOCAL=true pnpm test
# Run without Redis container only
SKIP_TEST_REDIS_SETUP=true pnpm test
Note: When skipping containers, ensure the required services are either not needed by your tests or are already running locally.
before.js: When conditionally starting containers (if (!process.env.SKIP_TEST_...)), keep the bootstrap() call inside the same if block — the host/port variables are scoped there"org.testcontainers.reaper-session-id": process.env.REAPER_SESSION_ID in its labels, otherwise the teardown cannot find and stop ittest-env.json left behind: If a test run crashes before teardown, test-env.json stays on disk. Add it to .gitignore and handle the case in your CI pipelinebefore.js, test files, and teardown.js run as separate processes — env vars set in before.js do NOT propagate to tests. Always pass data through test-env.jsontap --timeout=90 or higher, and --timeout=0 for debug sessionsnpx claudepluginhub fgiova/claude-marketplace --plugin tap-testcontainersSets up integration tests across databases, APIs, and message queues using Testcontainers, with DB seeding, cleanup strategies, and Docker dependencies.
Provisions and manages isolated test environments with Docker Compose, seed data scripts, .env configs, and startup scripts for reliable specialized testing.
Sets up, writes, reviews, runs, debugs, and optimizes E2E/integration tests for TypeScript/NestJS projects using Jest, supertest, and real Docker infrastructure (Kafka, PostgreSQL, MongoDB, Redis) with the Given-When-Then pattern.