The debate between unit tests and integration tests is endless — and misguided. You need both. The real question is: what’s the right ratio, and what should each type cover?

Unit Tests: Fast and Focused

Unit tests verify a single function or class in isolation, replacing all dependencies with test doubles.

// Unit test — tests ONLY the pricing logic, nothing else
describe('PricingService', () => {
  test('applies bulk discount above 10 items', () => {
    const pricing = new PricingService();
    const total = pricing.calculate({
      unitPrice: 25,
      quantity: 15,
      customerTier: 'standard',
    });
    expect(total).toBe(337.50); // 25 * 15 * 0.9
  });

  test('no discount below 10 items', () => {
    const pricing = new PricingService();
    const total = pricing.calculate({
      unitPrice: 25,
      quantity: 5,
      customerTier: 'standard',
    });
    expect(total).toBe(125); // 25 * 5, no discount
  });
});

Strengths: Blazing fast (100s per second), precise failure diagnosis, great for logic-heavy code.

Weaknesses: Can’t catch integration bugs, risk of testing mocks instead of real behavior.

Integration Tests: Realistic and Broad

Integration tests verify that components work together correctly — real database, real HTTP, real services.

// Integration test — tests the full registration flow
describe('POST /api/register', () => {
  beforeEach(async () => {
    await db.query('DELETE FROM users');
  });

  test('creates user and returns 201', async () => {
    const response = await request(app)
      .post('/api/register')
      .send({ name: 'Alice', email: '[email protected]', password: 'SecurePass1!' });

    expect(response.status).toBe(201);
    expect(response.body.email).toBe('[email protected]');
    
    // Verify side effects
    const user = await db.query('SELECT * FROM users WHERE email = $1', ['[email protected]']);
    expect(user.rows).toHaveLength(1);
  });

  test('returns 409 for duplicate email', async () => {
    await request(app)
      .post('/api/register')
      .send({ name: 'Alice', email: '[email protected]', password: 'SecurePass1!' });
    
    const response = await request(app)
      .post('/api/register')
      .send({ name: 'Bob', email: '[email protected]', password: 'OtherPass1!' });

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

Strengths: Catches real bugs (wiring, SQL, serialization), high confidence, tests real behavior.

Weaknesses: Slower, harder to set up, less precise failure messages.

The Testing Strategy

What to Unit Test

  • Pure business logic: Calculations, validations, transformations
  • Complex algorithms: Sorting, filtering, scoring
  • Edge cases: Boundary conditions, error paths
  • Domain entities: Value objects, business rules

What to Integration Test

  • API endpoints: Request → Response flow
  • Database operations: Queries, migrations, constraints
  • Third-party integrations: Payment processing, email delivery
  • Authentication/Authorization: Login flows, permission checks

What to E2E Test

  • Critical user journeys: Sign up → Purchase → Receive confirmation
  • Smoke tests: “Does the app start and display the homepage?”

The Practical Ratio

A well-balanced project might look like:

Unit Tests          ████████████████████  70%   (fast, many)
Integration Tests   ████████              25%   (moderate, some)
E2E Tests           ██                     5%   (slow, few)

The “Write Tests Against Interfaces” Rule

// Write the same test against both unit and integration contexts
interface OrderTestContext {
  service: OrderService;
  seed(orders: Order[]): Promise<void>;
  cleanup(): Promise<void>;
}

function orderServiceTests(getContext: () => OrderTestContext) {
  test('creates order with valid items', async () => {
    const ctx = getContext();
    const order = await ctx.service.create([
      { productId: 'p1', quantity: 2, price: 10 },
    ]);
    expect(order.total).toBe(20);
  });
}

// Run the same tests against different implementations
describe('OrderService (unit)', () => {
  orderServiceTests(() => ({
    service: new OrderService(new InMemoryOrderRepo()),
    seed: async () => {},
    cleanup: async () => {},
  }));
});

describe('OrderService (integration)', () => {
  orderServiceTests(() => ({
    service: new OrderService(new PostgresOrderRepo(pool)),
    seed: async (orders) => { /* insert into DB */ },
    cleanup: async () => { await pool.query('DELETE FROM orders') },
  }));
});

This approach ensures your unit tests and integration tests verify the same behaviors, catching gaps between mocks and reality.

“Write unit tests for logic, integration tests for wiring, and E2E tests for journeys.” — Testing Axiom