Loading learning content...
Individual integration tests are valuable, but their true power emerges from a deliberate strategy—a coherent approach that determines which tests to write, how to organize them, when to run them, and how to maintain them over time.
Without a strategy, integration tests become a liability: slow test suites that nobody trusts, flaky tests that cry wolf, and expensive infrastructure that provides little confidence. With a well-designed strategy, integration tests become a powerful safety net that enables rapid, confident delivery.
By the end of this page, you will understand how to design an integration testing strategy that balances coverage, speed, and maintainability. You'll learn to structure tests using the testing pyramid, manage test environments effectively, integrate tests into CI/CD pipelines, and address cross-cutting concerns like flakiness, debugging, and parallelization.
The testing pyramid is the foundational model for a balanced testing strategy. It suggests that you should have many unit tests at the base, fewer integration tests in the middle, and even fewer end-to-end tests at the top.
THE TESTING PYRAMID══════════════════════════════════════════════════════════════ ╱╲ ╱ ╲ ╱ E2E╲ Few (~5-10%) ╱──────╲ • Slowest ╱ ╲ • Most brittle ╱Integration╲ • Highest confidence ╱────────────╲ ╱ ╲ Some (~20-30%) ╱ Service ╲ • Medium speed ╱ Integration ╲ • Some brittleness ╱────────────────────╲ • Good coverage ╱ ╲ ╱ Component / Pair ╲ ╱ Integration ╲ More (~10-20%) ╱────────────────────────────╲ ╱ ╲ ╱ Unit Tests ╲ Many (~50-60%) ╱ ╲ ╱────────────────────────────────────╲ • Fastest • Very stable • Limited scope COSTS & BENEFITS BY LEVEL:─────────────────────────────────────────────────────────────Level Speed Setup Maintenance Realism─────────────────────────────────────────────────────────────Unit ★★★★★ ★★★★★ ★★★★★ ★☆☆☆☆Component Int. ★★★★☆ ★★★★☆ ★★★★☆ ★★☆☆☆Service Int. ★★★☆☆ ★★★☆☆ ★★★☆☆ ★★★☆☆E2E ★☆☆☆☆ ★☆☆☆☆ ★☆☆☆☆ ★★★★★Why the pyramid shape?
The pyramid reflects the tradeoffs between test types:
Unit tests are cheap — Fast to run, easy to write, easy to maintain. A failure pinpoints the exact problem.
Integration tests cost more — Slower, require setup, failures may need investigation. But they catch bugs unit tests miss.
E2E tests are expensive — Slowest, most fragile, hardest to debug. But they test the complete user experience.
The pyramid suggests inverting the intuitive approach (which would be to write mostly E2E tests because they're 'more realistic'). Instead, use expensive tests sparingly and cheap tests liberally.
Kent C. Dodds proposes the 'testing trophy' model, which emphasizes integration tests more heavily than the traditional pyramid. The argument: integration tests offer the best balance of confidence and cost. Neither model is universally correct—choose based on your system's architecture and team capabilities.
Not all integration tests serve the same purpose. Categorizing tests helps you understand what each provides and when each should run.
| Category | Purpose | When to Run | Typical Count |
|---|---|---|---|
| Smoke Tests | Verify basic system connectivity | Every commit, deployment | 5-10 tests |
| Contract Tests | Verify interface compatibility | Every commit | 10-50 tests |
| Component Tests | Verify component behavior | Every commit | 50-200 tests |
| Service Tests | Verify service integrations | Pull requests, nightly | 100-500 tests |
| Data Tests | Verify data integrity, migrations | Pre-deployment, nightly | 20-100 tests |
| Performance Tests | Verify response times, throughput | Nightly, release | 10-50 tests |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// Using Jest test tags for categorization// jest.config.js: modulePathIgnorePatterns based on tags // ===== SMOKE TESTS: Critical path only =====describe.each(['smoke'])('Order System Smoke Tests', () => { it('can connect to database', async () => { await expect(db.query('SELECT 1')).resolves.toBeDefined(); }); it('can authenticate with auth service', async () => { const token = await authService.getToken(testCredentials); expect(token).toBeDefined(); }); it('can create and retrieve order', async () => { const order = await orderService.create(minimalValidOrder); const retrieved = await orderService.findById(order.id); expect(retrieved).toBeDefined(); });}); // ===== CONTRACT TESTS: API shape verification =====describe.each(['contract'])('Order API Contract Tests', () => { it('POST /orders returns expected shape', async () => { const response = await api.post('/orders', validOrderRequest); expect(response.status).toBe(201); expect(response.body).toMatchObject({ id: expect.any(String), status: 'PENDING', items: expect.arrayContaining([ expect.objectContaining({ sku: expect.any(String), quantity: expect.any(Number), }), ]), createdAt: expect.any(String), }); }); it('GET /orders/:id returns 404 for non-existent', async () => { const response = await api.get('/orders/non-existent-id'); expect(response.status).toBe(404); expect(response.body.error).toBe('Order not found'); });}); // ===== COMPONENT TESTS: Full component behavior =====describe.each(['component'])('OrderService Component Tests', () => { // Uses real repository, real validator, mocked external services let orderService: OrderService; beforeEach(() => { orderService = new OrderService( new PostgresOrderRepository(testDb.pool), new OrderValidator(), createMockPaymentGateway(), createMockNotificationService(), ); }); describe('order lifecycle', () => { it('should transition through valid states', async () => { const order = await orderService.create(validRequest); expect(order.status).toBe('PENDING'); await orderService.confirm(order.id); const confirmed = await orderService.findById(order.id); expect(confirmed?.status).toBe('CONFIRMED'); await orderService.ship(order.id, trackingInfo); const shipped = await orderService.findById(order.id); expect(shipped?.status).toBe('SHIPPED'); }); });}); // ===== SERVICE TESTS: Cross-service integration =====describe.each(['service'])('Order-Inventory Integration', () => { // Tests with real Order and Inventory services let orderService: OrderService; let inventoryService: InventoryService; beforeEach(async () => { // Wire real services together orderService = await serviceContainer.resolve(OrderService); inventoryService = await serviceContainer.resolve(InventoryService); }); it('should reserve inventory when order created', async () => { await inventoryService.stock('SKU-001', 100); const order = await orderService.create({ items: [{ sku: 'SKU-001', quantity: 5 }], }); const availability = await inventoryService.getAvailable('SKU-001'); expect(availability).toBe(95); // 100 - 5 reserved });});Structure your CI pipeline to run smoke tests first, then contract tests, then component tests. If smoke tests fail, there's no point running slower tests. This 'fail fast' approach saves CI time and provides quicker feedback.
Integration tests require external resources: databases, message queues, caches, external services. Managing these environments is critical for test reliability.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// ===== PATTERN: Test Environment Builder =====class TestEnvironment { private containers: StartedTestContainer[] = []; private services: Map<string, unknown> = new Map(); async setup(): Promise<void> { // Start containers in parallel const [postgres, redis, rabbitmq] = await Promise.all([ new PostgreSqlContainer('postgres:15').start(), new GenericContainer('redis:7').withExposedPorts(6379).start(), new GenericContainer('rabbitmq:3-management') .withExposedPorts(5672, 15672) .start(), ]); this.containers.push(postgres, redis, rabbitmq); // Configure services with container endpoints this.services.set('db', { host: postgres.getHost(), port: postgres.getPort(), database: postgres.getDatabase(), username: postgres.getUsername(), password: postgres.getPassword(), }); this.services.set('redis', { host: redis.getHost(), port: redis.getMappedPort(6379), }); this.services.set('rabbitmq', { host: rabbitmq.getHost(), port: rabbitmq.getMappedPort(5672), }); // Run migrations await this.runMigrations(); } async teardown(): Promise<void> { await Promise.all(this.containers.map(c => c.stop())); } getConfig(service: string): unknown { return this.services.get(service); } private async runMigrations(): Promise<void> { const dbConfig = this.getConfig('db') as DatabaseConfig; const migrator = new Migrator(dbConfig); await migrator.runAll(); }} // ===== USAGE IN TEST SUITE =====describe('Order System Integration', () => { let env: TestEnvironment; let orderService: OrderService; beforeAll(async () => { env = new TestEnvironment(); await env.setup(); // Initialize services with test environment config const container = new ServiceContainer(); container.register('dbConfig', env.getConfig('db')); container.register('redisConfig', env.getConfig('redis')); container.register('rabbitmqConfig', env.getConfig('rabbitmq')); await container.initialize(); orderService = container.resolve(OrderService); }, 120000); // Allow 2 minutes for container startup afterAll(async () => { await env.teardown(); }); it('should process order through entire system', async () => { // Test runs against isolated, containerized infrastructure const order = await orderService.create(validOrder); expect(order.status).toBe('PENDING'); });}); // ===== PATTERN: Shared Test Environment (for speed) =====// globalSetup.ts - runs once before all test filesexport default async function globalSetup() { const env = new TestEnvironment(); await env.setup(); // Store config for test files to access process.env.TEST_DB_HOST = env.getConfig('db').host; process.env.TEST_DB_PORT = env.getConfig('db').port.toString(); // ... other configs // Store containers for teardown (global as any).__TEST_ENV__ = env;} // globalTeardown.tsexport default async function globalTeardown() { const env = (global as any).__TEST_ENV__; await env?.teardown();}Spinning up containers per test file provides maximum isolation but is slow. Using shared containers with table truncation between tests is faster but risks test pollution. Choose based on your test suite size and reliability requirements.
Integration tests in CI/CD must balance thoroughness with speed. A 30-minute test suite blocks deployments and frustrates developers. A strategy that skips important tests may let bugs through.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
# Example GitHub Actions workflow with staged testingname: CI Pipeline on: push: branches: [main, develop] pull_request: branches: [main] jobs: # STAGE 1: Fast feedback (< 2 minutes) quick-checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Type check run: npm run typecheck - name: Unit tests run: npm run test:unit - name: Smoke tests (SQLite) run: npm run test:smoke # STAGE 2: Component tests (5-10 minutes) component-tests: runs-on: ubuntu-latest needs: quick-checks services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v4 - run: npm ci - name: Run migrations run: npm run db:migrate env: DATABASE_URL: postgres://postgres:test@localhost:5432/test - name: Component integration tests run: npm run test:component env: DATABASE_URL: postgres://postgres:test@localhost:5432/test # STAGE 3: Full service tests (10-20 minutes) service-tests: runs-on: ubuntu-latest needs: component-tests # Only run on main branches, not every PR if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' services: postgres: image: postgres:15 env: { POSTGRES_PASSWORD: test } ports: ['5432:5432'] redis: image: redis:7 ports: ['6379:6379'] rabbitmq: image: rabbitmq:3-management ports: ['5672:5672'] steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run db:migrate - name: Service integration tests run: npm run test:service timeout-minutes: 20 # STAGE 4: E2E tests (20-30 minutes) e2e-tests: runs-on: ubuntu-latest needs: service-tests if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run build - name: Start application run: npm run start:test & - name: Wait for app ready run: npx wait-on http://localhost:3000/health - name: E2E tests run: npm run test:e2e timeout-minutes: 30 # Parallel nightly: Full suite + performance nightly-full: runs-on: ubuntu-latest if: github.event_name == 'schedule' strategy: matrix: shard: [1, 2, 3, 4] # Parallelize across 4 machines steps: - uses: actions/checkout@v4 - run: npm ci - name: Run all integration tests (shard ${{ matrix.shard }}) run: npm run test:integration -- --shard=${{ matrix.shard }}/4Flaky tests—tests that sometimes pass and sometimes fail without code changes—are the plague of integration testing. They erode trust in the test suite and waste engineering time investigating false failures.
| Cause | Symptoms | Solution |
|---|---|---|
| Race conditions | Fails when system is under load | Add explicit waits, use async/await properly |
| Shared test data | Depends on test execution order | Isolate data per test, use unique IDs |
| Time dependencies | Fails near midnight or year boundaries | Mock time providers, use relative time |
| Resource exhaustion | Fails after many tests run | Clean up resources, increase pool sizes |
| Network instability | Fails with timeout errors | Use retries with backoff, local services |
| Order-dependent assertions | Array comparisons fail randomly | Sort before comparing, use set comparisons |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// ===== ANTI-PATTERN: Race Condition =====// ❌ BAD: Assumes async operation completes immediatelyit('should send notification after order created', async () => { await orderService.create(validOrder); // Notification is sent async - may not be complete yet! const notifications = await notificationService.getRecent(); expect(notifications).toHaveLength(1); // FLAKY!}); // ✅ GOOD: Wait for the async operation to completeit('should send notification after order created', async () => { const order = await orderService.create(validOrder); // Wait for notification to appear await waitForCondition( async () => { const notifications = await notificationService .findByOrderId(order.id); return notifications.length > 0; }, { timeout: 5000, interval: 100 } ); const notifications = await notificationService.findByOrderId(order.id); expect(notifications).toHaveLength(1); expect(notifications[0].orderId).toBe(order.id);}); // ===== ANTI-PATTERN: Shared Test Data =====// ❌ BAD: Tests share data, execution order mattersbeforeAll(async () => { await seedTestCustomer({ id: 'customer-1' });}); it('should find customer', async () => { const customer = await service.findById('customer-1'); expect(customer).toBeDefined();}); it('should delete customer', async () => { await service.delete('customer-1'); // Now the previous test would fail if run after this one!}); // ✅ GOOD: Each test owns its datait('should find customer', async () => { const customer = await factory.createCustomer(); const found = await service.findById(customer.id); expect(found?.id).toBe(customer.id);}); it('should delete customer', async () => { const customer = await factory.createCustomer(); await service.delete(customer.id); const found = await service.findById(customer.id); expect(found).toBeNull();}); // ===== ANTI-PATTERN: Time Dependency =====// ❌ BAD: Depends on actual system timeit('should expire old orders', async () => { const order = await orderService.create({ ...validOrder, createdAt: new Date(), // "Now" }); // This test might fail at 23:59:59! const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const expired = await orderService.findExpiredBefore(tomorrow); expect(expired).toContain(order.id);}); // ✅ GOOD: Use controllable timeit('should expire old orders', async () => { const fixedTime = new Date('2024-01-15T12:00:00Z'); timeProvider.freeze(fixedTime); const order = await orderService.create(validOrder); // Advance time by 2 days timeProvider.advanceBy({ days: 2 }); const expiredBefore = timeProvider.now(); const expired = await orderService.findExpiredBefore(expiredBefore); expect(expired.map(o => o.id)).toContain(order.id); timeProvider.unfreeze();}); // ===== PATTERN: Retry Wrapper for Known Flaky Operations =====async function withRetry<T>( operation: () => Promise<T>, options: { retries: number; delay: number }): Promise<T> { let lastError: Error; for (let attempt = 1; attempt <= options.retries; attempt++) { try { return await operation(); } catch (error) { lastError = error as Error; if (attempt < options.retries) { await sleep(options.delay * attempt); // Exponential backoff } } } throw lastError!;} // Use for external service calls that may have transient failuresit('should sync with external inventory', async () => { const result = await withRetry( () => inventorySync.syncFromWarehouse(), { retries: 3, delay: 1000 } ); expect(result.success).toBe(true);});When a test becomes flaky, quarantine it immediately—move it to a separate test suite that doesn't block CI. Fix it promptly, but don't let flaky tests undermine trust in the entire suite. A test suite that's 'usually green' teaches developers to ignore failures.
Integration test failures are harder to debug than unit test failures because the problem could be anywhere in the interaction chain. Systematic debugging approaches are essential.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
// ===== STRATEGY 1: Comprehensive Logging =====class IntegrationTestLogger { private logs: LogEntry[] = []; capture(level: string, message: string, context?: object): void { this.logs.push({ timestamp: new Date().toISOString(), level, message, context, }); } dumpOnFailure(): void { console.log('\n=== Integration Test Logs ==='); for (const entry of this.logs) { console.log(`[${entry.timestamp}] ${entry.level}: ${entry.message}`); if (entry.context) { console.log(JSON.stringify(entry.context, null, 2)); } } } clear(): void { this.logs = []; }} // Usage in testslet testLogger: IntegrationTestLogger; beforeEach(() => { testLogger = new IntegrationTestLogger(); // Inject logger into services orderService.setLogger(testLogger); inventoryService.setLogger(testLogger);}); afterEach(function() { // 'this' in Jest/Mocha contains test state if (this.currentTest?.state === 'failed') { testLogger.dumpOnFailure(); } testLogger.clear();}); // ===== STRATEGY 2: Database State Snapshots =====class DatabaseDebugger { async snapshotState(label: string): Promise<void> { const tables = await this.getAllTables(); const snapshot: Record<string, unknown[]> = {}; for (const table of tables) { snapshot[table] = await this.db.query( `SELECT * FROM ${table} ORDER BY id` ); } console.log(`\n=== DB Snapshot: ${label} ===`); console.log(JSON.stringify(snapshot, null, 2)); } async diffStates(before: string, after: string): Promise<void> { // Compare snapshots to see what changed }} // Usage in failing test investigationit('should update inventory correctly', async () => { const debugger = new DatabaseDebugger(testDb); await debugger.snapshotState('Before order creation'); const order = await orderService.create(validOrder); await debugger.snapshotState('After order creation'); expect(inventory.quantity).toBe(95); // Why is this failing?}); // ===== STRATEGY 3: Request/Response Recording =====class HttpRecorder { private recordings: HttpExchange[] = []; recordRequest(req: Request, res: Response): void { this.recordings.push({ request: { method: req.method, url: req.url, headers: req.headers, body: req.body, }, response: { status: res.status, headers: res.headers, body: res.body, }, duration: res.timing, }); } dump(): void { console.log('\n=== HTTP Exchanges ==='); for (const exchange of this.recordings) { console.log(`${exchange.request.method} ${exchange.request.url}`); console.log(` Status: ${exchange.response.status}`); console.log(` Duration: ${exchange.duration}ms`); } }} // ===== STRATEGY 4: Minimal Reproduction =====// When a test fails, create the smallest possible test that reproduces it describe('REPRO: Order inventory not decremented', () => { // Strip out everything except what's needed to reproduce it('minimal reproduction', async () => { // 1. Minimal data setup await inventoryRepo.save({ sku: 'TEST', quantity: 10 }); // 2. The operation await orderService.create({ items: [{ sku: 'TEST', quantity: 1 }] }); // 3. The assertion const inv = await inventoryRepo.findBySku('TEST'); console.log('Inventory after:', inv); expect(inv?.quantity).toBe(9); });});Integration tests are inherently slower than unit tests. Parallelization is essential for keeping test suite execution time manageable as the suite grows.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// ===== STRATEGY 1: Parallel Test Files, Isolated Databases =====// Each test file gets its own database schema or database instance // jest.config.jsmodule.exports = { maxWorkers: 4, // Run 4 test files in parallel globalSetup: './test/globalSetup.ts', globalTeardown: './test/globalTeardown.ts',}; // globalSetup.ts - Create worker-specific databasesexport default async function() { const workerCount = parseInt(process.env.JEST_WORKERS || '4'); for (let i = 1; i <= workerCount; i++) { await createDatabase(`testdb_worker_${i}`); await runMigrations(`testdb_worker_${i}`); }} // testSetup.ts - Each worker uses its own databaseconst workerId = process.env.JEST_WORKER_ID || '1';const databaseUrl = `postgres://localhost/testdb_worker_${workerId}`; export const testDb = new TestDatabase(databaseUrl); // ===== STRATEGY 2: Schema-Per-Test Isolation =====// Each test creates its own schema within a shared database describe('Order Tests', () => { let schema: string; beforeAll(async () => { schema = `test_${Date.now()}_${randomId()}`; await db.query(`CREATE SCHEMA ${schema}`); await db.query(`SET search_path TO ${schema}`); await runMigrations(); }); afterAll(async () => { await db.query(`DROP SCHEMA ${schema} CASCADE`); }); // Tests run in isolated schema}); // ===== STRATEGY 3: Data Namespacing =====// Tests use prefixed IDs to avoid collisions without separate databases const testPrefix = `test-${Date.now()}-${workerId}`; function createTestId(entity: string): string { return `${testPrefix}-${entity}-${randomId()}`;} // Each test's data is namespacedit('should process order', async () => { const customerId = createTestId('customer'); const orderId = createTestId('order'); await customerRepo.save({ id: customerId, name: 'Test' }); const order = await orderService.create({ id: orderId, customerId, }); // Cleanup only our namespaced data await orderRepo.deleteByPrefix(testPrefix); await customerRepo.deleteByPrefix(testPrefix);}); // ===== STRATEGY 4: Sharding Across CI Runners =====// Distribute tests across multiple CI machines // package.json{ "scripts": { "test:shard": "jest --shard=$SHARD_INDEX/$TOTAL_SHARDS" }} // GitHub Actionsjobs: test: strategy: matrix: shard: [1, 2, 3, 4] steps: - run: npm run test:shard env: SHARD_INDEX: ${{ matrix.shard }} TOTAL_SHARDS: 4Before enabling parallelization, run your tests with --runInBand (serial) and compare results to parallel runs. If results differ, you have hidden test dependencies to fix first. Tests must be truly independent before parallelization works reliably.
We've explored the strategic aspects of integration testing—how to design, organize, run, and maintain integration tests at scale. Let's consolidate the key principles:
Module Complete
You've now mastered integration testing at the LLD level. You understand what integration tests are, how to test component interactions, how to test database integrations, and how to design integration test strategies that scale. These skills enable you to build robust, well-tested object-oriented systems with confidence.
Congratulations! You've completed the Integration Testing at LLD Level module. You now have a comprehensive understanding of integration testing in object-oriented systems—from fundamental concepts through practical implementation to strategic organization. Apply these principles to build test suites that provide genuine confidence without becoming maintenance burdens.