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
Tip: Integration count should be much smaller than unit tests; each case maps to a risk units cannot prove alone (migration order, pools, cross-version serialization, etc.).

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 localhost that 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.

Dependencies

              

---
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

Back to skills More skills