liable, maintainable testing architecture.
Step 1: Establish Infrastructure-Ready Test Environments
Replace static mocks with ephemeral, real dependencies. Testcontainers spins up isolated Docker containers for databases, caches, and message brokers per test suite or test file. This eliminates state leakage and ensures schema compatibility.
// testcontainers.setup.ts
import { GenericContainer, Wait } from "testcontainers";
import { Pool } from "pg";
export async function setupPostgres() {
const container = await new GenericContainer("postgres:15-alpine")
.withEnvironment({
POSTGRES_USER: "test_user",
POSTGRES_PASSWORD: "test_pass",
POSTGRES_DB: "test_db",
})
.withExposedPorts(5432)
.withWaitStrategy(Wait.forLogMessage("database system is ready to accept connections"))
.start();
const host = container.getHost();
const port = container.getMappedPort(5432);
const pool = new Pool({
host,
port,
user: "test_user",
password: "test_pass",
database: "test_db",
});
return { container, pool, host, port };
}
Step 2: Implement Real-Dependency Integration Tests
Test business logic against actual database connections, transaction boundaries, and query performance characteristics. Use transaction rollbacks to maintain isolation without dropping/recreating schemas.
// user.service.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { setupPostgres } from "./testcontainers.setup";
import { UserService } from "../src/user.service";
import { UserRepo } from "../src/user.repo";
describe("UserService Integration", () => {
let pool: any;
let userService: UserService;
beforeAll(async () => {
const { pool: dbPool } = await setupPostgres();
pool = dbPool;
const repo = new UserRepo(pool);
userService = new UserService(repo);
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'active'
)
`);
});
afterAll(async () => {
await pool.end();
});
it("should create user and enforce unique email constraint", async () => {
const user = await userService.create({ email: "dev@codcompass.io" });
expect(user.email).toBe("dev@codcompass.io");
expect(user.status).toBe("active");
await expect(
userService.create({ email: "dev@codcompass.io" })
).rejects.toThrow(/duplicate key value/);
});
});
Step 3: Apply Contract Testing for External Dependencies
Backend services rarely own third-party APIs. Contract testing validates that your service's expectations match the provider's actual response structure. Use tools like Pact or simple snapshot-based contract validation.
// payment.provider.contract.test.ts
import { describe, it, expect } from "vitest";
import { PaymentProvider } from "../src/payment.provider";
describe("PaymentProvider Contract Validation", () => {
it("should handle provider response shape correctly", async () => {
const mockResponse = {
transaction_id: "txn_8f3a2c",
amount: 1500,
currency: "USD",
status: "succeeded",
metadata: { source: "web" },
};
const provider = new PaymentProvider();
const result = provider.parseResponse(mockResponse);
expect(result).toMatchObject({
id: "txn_8f3a2c",
amountCents: 1500,
currency: "USD",
isSuccess: true,
});
});
});
Step 4: Introduce Property-Based Testing for Complex Logic
Deterministic tests cover happy paths and known edge cases. Property-based tests generate thousands of random inputs to validate invariants, preventing boundary condition failures.
// pricing.validator.test.ts
import { describe, it } from "vitest";
import fc from "fast-check";
import { validateDiscount } from "../src/pricing.validator";
describe("Discount Validation Properties", () => {
it("should never return negative discount percentages", () => {
fc.assert(
fc.property(fc.integer({ min: -1000, max: 1000 }), (input) => {
const result = validateDiscount(input);
return result >= 0 && result <= 100;
})
);
});
it("should clamp values outside valid range", () => {
fc.assert(
fc.property(fc.integer(), (input) => {
const result = validateDiscount(input);
if (input < 0) return result === 0;
if (input > 100) return result === 100;
return result === input;
})
);
});
});
Architecture Decisions and Rationale
- Testcontainers over Mocks: Mocks validate interfaces, not behavior. Real containers expose schema mismatches, connection pooling limits, and transaction isolation anomalies that mocks hide. The slight CI overhead is justified by runtime accuracy.
- Transaction Rollbacks for Isolation: Dropping and recreating databases per test is slow and fragile. Wrapping each test in a transaction and rolling back guarantees clean state without schema drift.
- Contract Testing at Boundaries: Third-party APIs change without warning. Contract tests fail fast when response shapes diverge, preventing silent data corruption in downstream services.
- Property-Based for Invariants: Backend logic often contains mathematical or state constraints. Property-based testing exhaustively validates these invariants, catching edge cases that deterministic tests miss.
- Vitest over Jest: Vitest offers native ESM support, faster cold starts, and better TypeScript integration. Its architecture aligns with modern Node.js toolchains and reduces configuration overhead.
Pitfall Guide
1. Over-Mocking Domain Logic
Mocking internal services or repositories defeats the purpose of testing. If you mock the repository, you are testing the mock, not the service. Only mock external boundaries (third-party APIs, hardware, time). Internal dependencies should be wired to real implementations or lightweight fakes that preserve behavior.
2. Ignoring Data State Leakage
Tests that leave rows in the database, cache entries, or message queue items corrupt subsequent test runs. This causes intermittent failures that appear flaky but are actually deterministic. Always wrap tests in transactions, use unique test prefixes, or truncate tables in afterEach.
3. Testing HTTP Transport Instead of Business Logic
Integration tests that only hit /api/users and check status codes validate the framework, not the domain. Extract business logic into services and test those directly. HTTP tests should be reserved for routing, middleware, and serialization validation.
4. Running Integration Tests Without Parallel Isolation
Sharing a single database across parallel test workers causes constraint violations and race conditions. Use schema-per-worker, database-per-worker, or transaction isolation. Testcontainers supports dynamic port mapping, making parallel execution safe.
5. Skipping Contract Validation for Idempotency and Retries
Backend systems must handle duplicate requests, partial failures, and network timeouts. Tests that assume clean, single-pass execution miss idempotency bugs. Validate retry logic, duplicate key handling, and state reconciliation explicitly.
6. Treating Test Data as Static
Hardcoded fixtures create brittle tests that break when validation rules change. Generate test data using factories that respect current schema constraints. Use libraries like factory-girl or custom builders that enforce type safety.
7. Optimizing CI Speed Over Fidelity
Disabling integration tests in CI to shave minutes off pipeline duration is a false economy. Defects discovered in production cost 100x more to fix than those caught in CI. Run integration tests on every merge, and reserve full regression suites for nightly or staging deployments.
Production Best Practices
- Schema Migration Testing: Run migrations against test containers to verify they execute cleanly and preserve data integrity.
- Deterministic Time: Use time-faking libraries (
@sinonjs/fake-timers, vitest vi.useFakeTimers) to test scheduled jobs, TTLs, and timeout logic.
- Coverage Gates with Quality Thresholds: Enforce minimum branch coverage (80%+) and require property-based tests for complex validators.
- Test Observability: Log test execution metrics (duration, flakiness rate, container startup time) to detect degradation before it impacts CI.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Monolithic backend with single database | Real-dependency integration tests + transaction isolation | Simplifies state management; eliminates mock drift | Low CI overhead; high defect reduction |
| Microservices with external APIs | Contract testing + service virtualization for non-critical deps | Prevents silent API breakage; isolates service boundaries | Moderate setup cost; prevents production rollbacks |
| Event-driven architecture (Kafka/RabbitMQ) | In-memory message brokers + consumer integration tests | Validates message schema, retry logic, and dead-letter handling | High initial complexity; prevents message loss incidents |
| Legacy system with tight coupling | Strangler pattern + property-based tests for extracted logic | Enables safe refactoring without full rewrite | High maintenance initially; reduces regression risk over time |
Configuration Template
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
poolOptions: {
threads: {
singleThread: false,
maxThreads: 4,
},
},
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
thresholds: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
},
setupFiles: ["./test/setup.ts"],
testTimeout: 30000,
hookTimeout: 30000,
},
});
# docker-compose.test.yml
version: "3.8"
services:
test-postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
test-redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
Quick Start Guide
- Install dependencies:
npm i -D vitest testcontainers fast-check @types/pg pg
- Create
test/setup.ts with container initialization and global beforeAll/afterAll hooks for database and cache spin-up.
- Write your first integration test using
setupPostgres() from the template, asserting against real queries and constraint violations.
- Run
npx vitest run to execute tests against ephemeral containers. Verify isolation by running the suite twice; results must be identical.
- Add coverage and flakiness monitoring to your CI pipeline. Enforce thresholds before allowing merges.