ycle management, dependency injection, and test isolation. The following implementation demonstrates a production-ready pattern using Fastify, PostgreSQL, and Redis, orchestrated through Vitest and the Testcontainers ecosystem.
Architecture Decisions and Rationale
- Framework Selection: Fastify is chosen for its schema-based validation, fast routing, and explicit dependency injection model. AI-generated code often modifies request payloads or response shapes; Fastify's built-in validation catches these drifts early.
- Test Runner: Vitest provides native ESM support, parallel execution, and global setup/teardown hooks. These features are essential for managing container lifecycles without blocking test execution.
- Container Orchestration: The
@testcontainers packages handle image pulling, network configuration, dynamic credential generation, and health checks. This eliminates hardcoded ports and credentials, which are common sources of flaky tests.
- State Management: Each test suite receives a fresh database schema. Redis is flushed between tests to prevent cross-test pollution. This ensures deterministic results without sacrificing execution speed.
Implementation
1. Service Definition
// src/services/InventoryService.ts
import { FastifyInstance } from "fastify";
import { Pool, PoolClient } from "pg";
import { Redis } from "ioredis";
export class InventoryService {
constructor(
private readonly db: Pool,
private readonly cache: Redis,
private readonly server: FastifyInstance
) {}
async register(): Promise<void> {
this.server.get("/inventory/:sku", async (request, reply) => {
const { sku } = request.params as { sku: string };
const cached = await this.cache.get(`inv:${sku}`);
if (cached) {
return reply.send({ source: "cache", quantity: Number(cached) });
}
const result = await this.db.query(
`SELECT quantity FROM stock WHERE sku = $1 FOR UPDATE`,
[sku]
);
if (result.rows.length === 0) {
return reply.status(404).send({ error: "SKU not found" });
}
const quantity = result.rows[0].quantity;
await this.cache.set(`inv:${sku}`, String(quantity), "EX", 300);
return reply.send({ source: "database", quantity });
});
}
}
2. Container Lifecycle Management
// test/helpers/container-lifecycle.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { RedisContainer, StartedRedisContainer } from "@testcontainers/redis";
import { Pool } from "pg";
import { Redis } from "ioredis";
export interface TestEnvironment {
postgres: StartedPostgreSqlContainer;
redis: StartedRedisContainer;
dbPool: Pool;
cacheClient: Redis;
}
export async function provisionTestEnvironment(): Promise<TestEnvironment> {
const postgres = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("inventory_test")
.withUsername("svc_test")
.withPassword("secure_test_pass")
.start();
const redis = await new RedisContainer("redis:7-alpine").start();
const dbPool = new Pool({
host: postgres.getHost(),
port: postgres.getPort(),
database: postgres.getDatabase(),
user: postgres.getUsername(),
password: postgres.getPassword(),
max: 5,
idleTimeoutMillis: 3000,
});
const cacheClient = new Redis(redis.getConnectionUrl(), {
maxRetriesPerRequest: 2,
enableReadyCheck: true,
});
return { postgres, redis, dbPool, cacheClient };
}
export async function teardownEnvironment(env: TestEnvironment): Promise<void> {
await env.cacheClient.quit();
await env.dbPool.end();
await env.redis.stop();
await env.postgres.stop();
}
3. Integration Test Suite
// test/integration/inventory-route.test.ts
import { describe, beforeAll, afterAll, it, expect } from "vitest";
import Fastify from "fastify";
import { InventoryService } from "../../src/services/InventoryService";
import { provisionTestEnvironment, teardownEnvironment, TestEnvironment } from "../helpers/container-lifecycle";
describe("Inventory API Integration", () => {
let env: TestEnvironment;
let app: ReturnType<typeof Fastify>;
let service: InventoryService;
beforeAll(async () => {
env = await provisionTestEnvironment();
await env.dbPool.query(`
CREATE TABLE IF NOT EXISTS stock (
sku VARCHAR(50) PRIMARY KEY,
quantity INTEGER NOT NULL CHECK (quantity >= 0),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
app = Fastify({ logger: false });
service = new InventoryService(env.dbPool, env.cacheClient, app);
await service.register();
await app.ready();
}, 45000);
afterAll(async () => {
await app.close();
await teardownEnvironment(env);
});
it("retrieves stock quantity from database and caches the result", async () => {
await env.dbPool.query(
`INSERT INTO stock (sku, quantity) VALUES ($1, $2)`,
["WIDGET-001", 150]
);
const response = await app.inject({
method: "GET",
url: "/inventory/WIDGET-001",
});
expect(response.statusCode).toBe(200);
const payload = JSON.parse(response.body);
expect(payload.source).toBe("database");
expect(payload.quantity).toBe(150);
const cached = await env.cacheClient.get("inv:WIDGET-001");
expect(cached).toBe("150");
});
it("returns cached value on subsequent requests", async () => {
const response = await app.inject({
method: "GET",
url: "/inventory/WIDGET-001",
});
expect(response.statusCode).toBe(200);
const payload = JSON.parse(response.body);
expect(payload.source).toBe("cache");
expect(payload.quantity).toBe(150);
});
it("handles missing SKU gracefully", async () => {
const response = await app.inject({
method: "GET",
url: "/inventory/NONEXISTENT",
});
expect(response.statusCode).toBe(404);
const payload = JSON.parse(response.body);
expect(payload.error).toBe("SKU not found");
});
});
Why This Architecture Works
The lifecycle hooks (beforeAll/afterAll) ensure containers are provisioned once per suite, minimizing overhead. Dynamic credential generation via postgres.getUsername() and redis.getConnectionUrl() eliminates port conflicts and hardcoded secrets. The FOR UPDATE clause in the query demonstrates how real database locking behavior can be validated, something mocks cannot simulate. Fastify's inject() method allows HTTP-level testing without binding to a network port, keeping the test suite isolated and deterministic.
Pitfall Guide
Ephemeral container testing introduces new failure modes if not implemented carefully. The following pitfalls are commonly encountered in production environments.
| Pitfall | Explanation | Fix |
|---|
| Container Leakage | Tests fail to stop containers after execution, exhausting Docker daemon resources and causing CI runner crashes. | Implement afterAll teardown hooks. Use Vitest's globalTeardown for suite-level cleanup. Monitor Docker disk usage in CI. |
| Hardcoded Credentials & Ports | Developers bypass dynamic credential generation, leading to port collisions and security warnings in CI. | Always use container.getHost(), container.getPort(), and container.getConnectionUrl(). Never assume localhost or static ports. |
| Race Conditions on Startup | Queries execute before the database finishes initialization, resulting in connection refused or relation does not exist errors. | Use container.getWaitStrategy() or implement a retry loop with exponential backoff. Verify readiness by executing a lightweight health query. |
| Mock-Container Hybrid Anti-pattern | Mixing mocked services with real containers creates unpredictable state and invalidates test isolation. | Choose a single strategy per test suite. If testing integration, use real containers for all external dependencies. |
| Ignoring Resource Limits | Containers consume excessive RAM/CPU, causing CI runners to OOM or throttle. | Use lightweight base images (alpine variants). Set Docker memory limits via withStartupTimeout() and CI runner configurations. |
| State Pollution Between Tests | Shared containers retain data across tests, causing flaky assertions and false positives. | Truncate tables or use transaction rollbacks per test. Alternatively, provision fresh containers per suite and accept the slight latency trade-off. |
| Network Resolution Failures | Tests reference localhost instead of the container's mapped host, failing in CI environments with different network namespaces. | Always resolve connection strings dynamically. Use container.getHost() which correctly maps to the Docker bridge or host network. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Local Development | Ephemeral Containers | Fast feedback loop, validates generated code against real schemas | Low (local Docker resources) |
| Pull Request Checks | Ephemeral Containers | Catches integration drift before merge, prevents CI pipeline bloat | Medium (CI runner time) |
| Nightly Regression | Full Staging Environment | Validates complex cross-service interactions and load behavior | High (infrastructure provisioning) |
| Load/Performance Testing | Dedicated Infrastructure | Containers lack persistent storage and network tuning for sustained load | Very High (provisioned resources) |
Configuration Template
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
pool: "threads",
poolOptions: {
threads: {
minThreads: 2,
maxThreads: 4,
},
},
globalSetup: ["./test/setup/global-setup.ts"],
globalTeardown: ["./test/setup/global-teardown.ts"],
testTimeout: 30000,
hookTimeout: 45000,
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/**/types.ts"],
},
},
});
Quick Start Guide
- Install Dependencies: Run
npm install -D vitest @testcontainers/postgresql @testcontainers/redis pg ioredis fastify.
- Create Lifecycle Helpers: Implement
provisionTestEnvironment() and teardownEnvironment() using the container packages.
- Configure Vitest: Add global setup/teardown hooks and set appropriate timeouts for container startup.
- Write Integration Tests: Use
app.inject() for HTTP validation, verify database constraints, and assert cache behavior.
- Execute in CI: Ensure Docker is available in your CI runner. Set memory limits and enable container cleanup jobs to prevent resource exhaustion.
This pattern transforms AI-generated code from a liability into a validated asset. By restoring environmental friction to your test suite, you catch schema mismatches, constraint violations, and driver-specific behavior before they reach production. The result is a development workflow that maintains velocity without sacrificing reliability.