Integration test orchestration
Complete Testcontainers examples (Node.js + Python for PostgreSQL), transaction rollback, supertest/httpx integration tests, fixture and factory data management, and GitHub Actions parallel CI configuration.
Integration tests should exercise real collaboration boundaries (HTTP, gRPC, queues, ORM). The SKILL should declare startup order, health checks, and timeouts to avoid “passes locally, flakes in CI.”
Prefer repeatable data setup: transaction rollback, per-test schema or namespace isolation, and document differences from production (e.g. external webhooks off).
For multi-service repos, ask agents for “dependency topology + smallest verifiable path”—cover the main flow before edge integration cases.
- No shared mutable global state between tests; use unique tenant ID prefixes when needed.
- Async consumers need explicit waits or polling caps; log what to capture on failure.
- Align with pipeline matrix: pin image tags and resource quotas to avoid pull drift.
Test pyramid: integration layer
The integration layer proves multiple real components still meet contracts: slower than unit, narrower than E2E. The SKILL should say which boundaries need real processes/ports (DB protocol, queue protocol) vs in-process fakes, and avoid duplicating browser journeys E2E already covers.
┌─────────┐
╱ E2E ╱│ Key journeys, few full-stack paths
╱────────╱ │
╱ integ ╱ │◀── Middle: real DB/queue/HTTP neighborhood
╱────────╱ │ Assert wiring and collaboration contracts
╱ unit ╱ │
╱────────╱ │ Fast: pure logic + mock out-of-process I/O
────────── └── PR: mostly unit; integration pins fragile seams
Containers and local orchestration
Prefer Testcontainers, Docker Compose, or CI-provided equivalents: in the SKILL pin image digest or minor tags, declare depends_on and health checks before migrations and tests — not raw sleep.
- Network: match production-style ports or service names (compose aliases); avoid hard-coded
localhostthat differs in CI. - Volumes: parallel jobs use separate volumes or TRUNCATE/transactions per test; document cleanup cost.
- Resources: memory/CPU caps and pull retries belong in pipeline comments so agents can generate reproducible workflows.
Complete Testcontainers example:
Node.js (using the testcontainers package):
// tests/integration/setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { runMigrations } from '../../src/db/migrations';
let container: any;
let pool: Pool;
beforeAll(async () => {
// Start PostgreSQL container (pin version, never use latest)
container = await new PostgreSqlContainer('postgres:16.2-alpine')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
pool = new Pool({
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
});
// Run database migrations
await runMigrations(pool);
}, 60_000); // Container startup may take up to 60 seconds
afterAll(async () => {
await pool.end();
await container.stop();
});
Python (using testcontainers-python):
# tests/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from alembic.config import Config
from alembic import command
@pytest.fixture(scope="session")
def pg_container():
with PostgresContainer("postgres:16.2-alpine") as postgres:
engine = create_engine(postgres.get_connection_url())
# Run alembic migrations
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", postgres.get_connection_url())
command.upgrade(alembic_cfg, "head")
yield engine
Transaction rollback per test (isolates data without wiping the DB each time):
// Node.js - per-test transaction rollback
let client: any;
beforeEach(async () => {
client = await pool.connect();
await client.query('BEGIN'); // Start transaction
});
afterEach(async () => {
await client.query('ROLLBACK'); // Roll back all writes
client.release();
});
it('should create order and reduce inventory', async () => {
// All INSERT/UPDATE operations here are rolled back in afterEach
await client.query('INSERT INTO orders (user_id, total) VALUES ($1, $2)', [1, 99.50]);
const result = await client.query('SELECT COUNT(*) FROM orders WHERE user_id = $1', [1]);
expect(parseInt(result.rows[0].count)).toBe(1);
// After afterEach ROLLBACK, this data will not affect other tests
});
Supertest integration test (with auth header and full assertions):
// tests/integration/orders.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { generateTestToken } from '../helpers/auth';
describe('POST /api/orders', () => {
const authToken = generateTestToken({ userId: 'test-user-1', role: 'customer' });
it('should create order with valid payload', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'application/json')
.send({
items: [{ productId: 'prod-1', quantity: 2 }],
couponCode: 'SAVE10'
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
orderId: expect.stringMatching(/^ORD-\d+/),
total: expect.any(Number),
status: 'pending'
});
expect(response.headers['location']).toMatch(/\/api\/orders\/ORD-/);
});
it('should return 401 without auth header', async () => {
const response = await request(app).post('/api/orders').send({});
expect(response.status).toBe(401);
expect(response.body.error).toBe('UNAUTHORIZED');
});
});
Test data management: fixture file format + factory functions:
// tests/fixtures/orders.json
{
"validOrder": {
"userId": "user-001",
"items": [{ "productId": "prod-001", "quantity": 2, "price": 49.75 }],
"total": 99.50,
"status": "pending"
},
"expiredCoupon": {
"code": "OLD10",
"discount": 10,
"expiresAt": "2020-01-01T00:00:00Z"
}
}
// tests/factories/orderFactory.ts
import { v4 as uuidv4 } from 'uuid';
export function createOrder(overrides = {}) {
return {
orderId: `ORD-${uuidv4().slice(0, 8)}`,
userId: 'test-user-1',
items: [{ productId: 'prod-001', quantity: 1, price: 99.00 }],
total: 99.00,
status: 'pending',
createdAt: new Date().toISOString(),
...overrides // Override any field
};
}
// Usage:
const orderWithCoupon = createOrder({ couponCode: 'SAVE10', total: 89.10 });
const cancelledOrder = createOrder({ status: 'cancelled' });
Run integration tests in parallel with GitHub Actions:
name: Integration Tests
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
strategy:
matrix:
# Shard by module and run in parallel (4 workers)
suite: [orders, payments, inventory, auth]
fail-fast: false # One failure does not stop others
services:
postgres:
image: postgres:16.2-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- run: npm run test:integration -- --testPathPattern=${{ matrix.suite }}
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- uses: actions/upload-artifact@v4
if: failure()
with:
name: integration-test-results-${{ matrix.suite }}
path: test-results/
Test lifecycle (orchestration order)
Break one integration run into observable phases in the SKILL so failures separate “won’t start” from “data/assertions.”
[ Resolve env: image tags, connection strings, feature flags ]
│
▼
┌─────────────┐ parallel matrix: note shared-resource conflicts when sharding
│ Start deps │──── healthcheck / wait-for-it; log timeouts
└─────────────┘
│
▼
┌─────────────┐ order: schema migrate → seed (optional) → SUT
│ Migrate & │──── per test: transaction, tenant prefix, or dedicated DB
│ fixtures │
└─────────────┘
│
▼
┌─────────────┐ HTTP/gRPC/queue: sync points + async poll caps
│ Run asserts │──── on fail: keep container logs, topic lag, SQL state
└─────────────┘
│
▼
[ teardown: stop containers / delete volumes / publish junit ]
Mini tool: service dependency list
Tick external deps your integration tests actually use; generate a Markdown snippet for a SKILL or PR description. State is saved in this browser.
---
name: integration-testing
description: Testcontainers orchestration, transaction isolation, supertest assertions, and CI parallel strategy
---
# Step 1: start test containers (Testcontainers)
Node.js:
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const container = await new PostgreSqlContainer('postgres:16.2-alpine').start();
Python:
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:16.2-alpine") as pg:
engine = create_engine(pg.get_connection_url())
# Step 2: run database migrations
Node: await runMigrations(pool) # in beforeAll, after container starts
Python: alembic upgrade head
# Step 3: per-test transaction rollback (isolate data)
beforeEach: await client.query('BEGIN')
afterEach: await client.query('ROLLBACK')
# No TRUNCATE needed; all writes in each test are rolled back
# Step 4: supertest integration test structure
import request from 'supertest';
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${token}`)
.send(payload);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ orderId: expect.stringMatching(/ORD-/) });
# Step 5: test data management
fixture files: tests/fixtures/*.json (static, typical data)
factory functions: tests/factories/*.ts (dynamic generation with uuid)
principle: factory overrides → only pass fields relevant to the test; use defaults for the rest
# Step 6: CI parallel strategy
matrix.suite: [orders, payments, inventory, auth] # shard by module
fail-fast: false # one failure does not affect others
services.postgres: healthcheck pg_isready --health-retries 10
artifact: upload test-results/ directory on failure