Integration tests that catch what unit tests consistently miss

By Sven Lindqvist · 21 July 2026150 views
Integration tests that catch what unit tests consistently miss

A developer has 94% unit test coverage. Her CI pipeline runs 2,400 tests in 3 minutes. She ships confidently. Two days after a deployment, a production incident: the order confirmation email is not being sent. The bug is in the interaction between the order service and the email service — specifically, a function signature change in the email service that was correctly unit-tested in isolation, but the order service was not updated to use the new signature. Both unit test suites pass. The integration fails.

Unit test coverage measures how much of the code is executed by tests. It does not measure whether the interactions between components work correctly. Integration tests fill this gap.

This distinction matters most in production. Almost every incident that escapes a comprehensive unit test suite is not a failure within a single component — it is a failure at the boundary between components. The email service function works correctly. The order service works correctly. The contract between them broke, and no test verified the contract.

The root cause: testing in isolation

Unit tests are designed for isolation. A well-written unit test controls all inputs and mocks all dependencies. This isolation is a virtue — it makes unit tests fast, deterministic, and focused on a single behavior. It is also the source of their limitation: by mocking dependencies, unit tests assume those dependencies behave as the mock describes.

When the real dependency's behavior changes — its function signature, its return format, its error conditions — the mock does not update automatically. The unit tests that depend on the mock continue to pass. The real integration fails.

The email service example is common because it is a textbook case of interface drift. The email service adds a templateVersion parameter. The unit tests for the email service test that the new parameter is handled correctly. The unit tests for the order service test that sendEmail is called — but the mock was written against the old signature. Both pass. Production breaks.

Integration tests prevent this by removing the mock and testing against the real (or sufficiently realistic) dependency.

What integration tests cover

Unit tests verify:

  • A function returns the correct value given specific inputs
  • An object's methods update internal state correctly
  • Error handling in a specific component works

Integration tests verify:

  • The database query is correct and the ORM mapping works
  • The API endpoint handles request validation, calls the service, and returns the correct response format
  • The email service receives the expected arguments when an order is placed
  • The Firestore write happens at the correct path with the correct data

The test that would have caught the production bug is an integration test that creates an order and verifies that the email service was called with the new signature:

// Integration test — tests the full flow, not just individual units
describe('Order placement integration', () => {
  let emailService: jest.SpyInstance;

  beforeEach(() => {
    emailService = jest.spyOn(emailClient, 'sendTransactional');
  });

  test('placing an order sends a confirmation email with the correct template', async () => {
    const user = await createTestUser({ email: '[email protected]' });
    const product = await createTestProduct({ price: 29.99 });

    // Call the full order flow — not just the order function
    const order = await orderService.placeOrder({
      userId: user.id,
      items: [{ productId: product.id, quantity: 1 }],
    });

    // Verify the email was sent with the correct data
    expect(emailService).toHaveBeenCalledWith({
      to: '[email protected]',
      templateId: 'order-confirmation-v2',  // Would catch the template name change
      data: {
        orderId: order.id,
        total: 29.99,
        items: expect.arrayContaining([
          expect.objectContaining({ name: product.name, price: 29.99 })
        ]),
      },
    });
  });
});

The integration test calls orderService.placeOrder and verifies the email service contract. If the email service's signature changes, the test fails — not the unit tests for the individual components.

Note the use of jest.spyOn rather than jest.fn(). The spy wraps the real emailClient.sendTransactional — it records calls while still executing the real function (or a realistic stub). This is different from a mock, which replaces the function entirely with a version that only returns what the test expects. The spy fails when the call signature does not match; the mock succeeds because it was configured for the old signature.

What integration tests should not cover

The boundary between integration tests and unit tests is as important as the boundary between integration tests and end-to-end tests.

Integration tests should not:

  • Test every edge case in business logic (that is what unit tests are for — they are faster and more focused)
  • Test the UI rendering or user interactions (that is what end-to-end tests are for)
  • Test third-party service behavior (test that the correct data is sent to the service, not that the service behaves correctly — the service has its own tests)
  • Duplicate every unit test at the integration level (this creates slow, redundant tests)

A common anti-pattern is writing integration tests that essentially repeat unit tests at a higher level. If a unit test verifies that calculateDiscount(price, tier) returns the correct discount for each tier, an integration test does not need to test all tier combinations again. The integration test should verify that the discount calculation is correctly integrated into the checkout flow — called with the right inputs, and its output correctly used in the final total.

Integration testing with real databases vs. mocks

The most controversial integration testing decision: use a real database or mock it?

Arguments for real databases:

  • ORM queries are only proven correct against a real database
  • Query performance issues are only visible with real data volumes
  • Database-level constraints (unique indexes, foreign keys) only enforce at the database level
  • The production bug with a mock database is "tests pass; production fails"

Arguments for database mocks:

  • Tests run faster (no database setup overhead)
  • Tests are self-contained (no external dependency)
  • Parallel test execution is simpler

The practical recommendation: use a real database for integration tests, a real database emulator for Firebase/Firestore tests, and reserve mocks for external services (email, payment processors, third-party APIs) that cannot be run locally.

// Firestore integration test using Firebase Emulator
import { initializeTestEnvironment } from '@firebase/rules-unit-testing';

let testEnv: ReturnType<typeof initializeTestEnvironment>;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: 'test-project',
    firestore: { host: 'localhost', port: 8080 },
  });
});

afterEach(async () => {
  await testEnv.clearFirestore();  // Clean state between tests
});

test('createOrder writes to Firestore with correct structure', async () => {
  const ctx = testEnv.authenticatedContext('user-123');
  const db = ctx.firestore();

  await createOrder(db, {
    userId: 'user-123',
    items: [{ productId: 'prod-1', quantity: 2, price: 19.99 }],
  });

  // Verify the actual Firestore write
  const orders = await db.collection('orders').where('userId', '==', 'user-123').get();
  expect(orders.docs).toHaveLength(1);
  expect(orders.docs[0].data()).toMatchObject({
    userId: 'user-123',
    status: 'pending',
    totalAmount: 39.98,
    itemCount: 1,
  });
});

The Firebase Emulator Suite runs locally and does not require network access. It runs faster than hitting real Firebase services and provides exact production behavior for Firestore queries, Security Rules, and Auth.

For PostgreSQL integration tests with a real database:

// PostgreSQL integration test setup
import { Pool } from 'pg';
import { runMigrations } from '../src/database/migrate';

let pool: Pool;

beforeAll(async () => {
  // Use a test database (separate from development)
  pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
  await runMigrations(pool);  // Run all migrations on the test database
});

beforeEach(async () => {
  // Wrap each test in a transaction — rolled back after the test
  await pool.query('BEGIN');
});

afterEach(async () => {
  await pool.query('ROLLBACK');  // Clean state — no cleanup needed
});

afterAll(async () => {
  await pool.end();
});

test('createOrder persists correctly', async () => {
  const userId = 'user-123';
  await pool.query('INSERT INTO users (id, email) VALUES ($1, $2)', [userId, '[email protected]']);

  const order = await createOrder(pool, { userId, items: [{ productId: 'p1', quantity: 1, price: 29.99 }] });

  const result = await pool.query('SELECT * FROM orders WHERE id = $1', [order.id]);
  expect(result.rows[0]).toMatchObject({
    user_id: userId,
    status: 'pending',
    total_amount: '29.99',
  });
});

The transaction rollback pattern eliminates cleanup code. Each test starts with a clean database state — the transaction is always rolled back, regardless of whether the test passes or fails.

API endpoint integration tests

API endpoint tests verify the full HTTP layer — not just the business logic:

// API integration test using supertest
import request from 'supertest';
import { app } from '../src/app';
import { db } from '../src/database';

describe('POST /api/orders', () => {
  let authToken: string;

  beforeAll(async () => {
    // Create a test user and get an auth token
    const user = await createTestUser();
    authToken = generateTestToken(user.id);
  });

  test('creates an order and returns 201', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
      });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      total: expect.any(Number),
    });

    // Verify the database write
    const order = await db.orders.findUnique({ where: { id: response.body.id } });
    expect(order).not.toBeNull();
    expect(order!.userId).toBe(authToken.userId);
  });

  test('returns 400 for invalid product ID', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [{ productId: 'nonexistent', quantity: 1 }] });

    expect(response.status).toBe(400);
    expect(response.body.error).toMatch(/product/i);
  });

  test('returns 401 without auth token', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [] });

    expect(response.status).toBe(401);
  });
});

The API integration tests cover what unit tests cannot: that the route is registered, that the middleware runs in the correct order, that request parsing works, that the response format is correct. A unit test for the order creation handler does not verify that the route exists or that the authentication middleware is applied. The integration test verifies all of this implicitly.

Testing Cloud Functions and Firebase Security Rules

Firebase Security Rules are declarative code that runs in the Firestore and Storage backends. They cannot be unit tested — they must be tested against the emulator.

// Security Rules integration test
import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';

describe('Firestore Security Rules — orders collection', () => {
  let testEnv: any;

  beforeAll(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: 'test-project',
      firestore: {
        host: 'localhost',
        port: 8080,
        rules: fs.readFileSync('firestore.rules', 'utf8'),
      },
    });
  });

  test('authenticated user can create order for themselves', async () => {
    const alice = testEnv.authenticatedContext('alice');
    await assertSucceeds(
      alice.firestore().collection('orders').add({
        userId: 'alice',
        items: [{ productId: 'p1', quantity: 1 }],
        status: 'pending',
      })
    );
  });

  test('user cannot create order for another user', async () => {
    const alice = testEnv.authenticatedContext('alice');
    await assertFails(
      alice.firestore().collection('orders').add({
        userId: 'bob',  // Not alice's ID
        items: [{ productId: 'p1', quantity: 1 }],
        status: 'pending',
      })
    );
  });

  test('unauthenticated user cannot read orders', async () => {
    const unauthed = testEnv.unauthenticatedContext();
    await assertFails(
      unauthed.firestore().collection('orders').get()
    );
  });
});

Security Rules tests are integration tests by necessity — they test the rules against a running Firestore instance. Running them with the emulator makes them fast enough to include in CI without incurring costs or requiring network access.

Where to draw the boundary

Integration tests are slower than unit tests. Not everything needs to be integration tested. A good boundary:

  • Unit test: Pure functions, business logic, data transformations, error handling in isolation
  • Integration test: Database queries, API endpoints, multi-component workflows, external service calls, Security Rules
  • End-to-end test: Critical user journeys (login, purchase, account creation) from a browser perspective

The key heuristic: if the test requires mocking the thing you most want to be sure works, it should be an integration test instead. Mocking the database to test database query logic defeats the purpose of the test. Testing the database query against the real database (or emulator) gives confidence that the query is correct.

A practical target for a new project: write unit tests for all business logic, integration tests for all database operations and API endpoints, and end-to-end tests for the three to five most critical user flows. This distribution catches most bugs at the right level of the test pyramid.

Structuring integration tests for maintainability

Integration tests fail for two reasons: the system behavior changed (legitimate failure) and the test setup is fragile (false failure). Fragile tests — tests that fail because of timing issues, shared state, or environment dependencies — erode trust in the test suite. Developers start ignoring failures or skipping tests to make CI pass.

The patterns that make integration tests maintainable:

Isolated test data: each test creates its own data and does not depend on data from other tests. Use factory functions with unique identifiers:

// Factory function — creates unique test data
let testCounter = 0;

async function createTestUser(overrides: Partial<User> = {}) {
  testCounter++;
  return db.users.create({
    data: {
      id: `test-user-${testCounter}-${Date.now()}`,
      email: `test-${testCounter}@example.com`,
      ...overrides,
    }
  });
}

async function createTestProduct(overrides: Partial<Product> = {}) {
  testCounter++;
  return db.products.create({
    data: {
      id: `test-product-${testCounter}`,
      name: `Test Product ${testCounter}`,
      price: 29.99,
      ...overrides,
    }
  });
}

Cleanup after tests: whether using transactions (rollback) or explicit cleanup, test data should not persist between test runs. Shared data from previous runs causes test interdependence — a test that passes alone fails when run after a different test that left state behind.

Explicit timeouts: integration tests that involve I/O should have explicit timeouts. A test that hangs indefinitely blocks the CI pipeline:

// Jest configuration for integration tests
// jest.integration.config.ts
export default {
  testTimeout: 15000,  // 15 seconds per test — longer than unit tests
  globalSetup: './test/integration-setup.ts',
  globalTeardown: './test/integration-teardown.ts',
  testMatch: ['**/*.integration.test.ts'],
};

Separate integration test configuration from unit tests: run integration tests separately in CI. Unit tests run on every push; integration tests run before merging to main. This preserves the fast feedback loop of unit tests while ensuring integration tests run on every meaningful change.

# .github/workflows/ci.yml
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test  # Runs unit tests only

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:integration  # Runs integration tests with DB

The right test for each bug class

Looking at common bug classes and which level of testing catches them:

Bug classUnit test catches?Integration test catches?
Incorrect business logicYesSometimes
Wrong database queryNoYes
API contract mismatchSometimesYes
Missing middlewareNoYes
Security rule bypassNoYes (with emulator)
Incorrect ORM mappingNoYes
Race condition in concurrent writesNoSometimes
Wrong error response formatNoYes

This table explains why unit tests cannot catch the class of bugs that typically escape to production. The bugs that cause incidents — wrong database queries, API contract mismatches, missing security rules — require the real dependency to be present to manifest.

The goal is a test suite that fails when something that matters breaks, and passes when everything works. A test suite with 100% unit coverage that lets a production incident through was not comprehensive — it was testing at the wrong level for that class of bug. The production incident that started this investigation would have been caught by a single integration test. That test would have taken 20 minutes to write. The incident took four hours to diagnose and fix.

Comments

No comments yet. Be the first!

Sign in to leave a comment.