Unit Tests vs. Integration Tests vs. E2E: What Actually Matters (And What's Cargo Cult)
๐Ÿ’ป code

Unit Tests vs. Integration Tests vs. E2E: What Actually Matters (And What's Cargo Cult)

Devesh Korde

Devesh Korde

September 17, 2026

๐Ÿ“– 8 min read
#Testing#Unit Tests#Integration Tests#E2E#Test Strategy#Code Quality
โšก TL;DR
  • Unit tests pass because they test isolated code with perfect mocks. Integration tests pass because they test happy paths with test data. Neither tests what actually happens in production with real data under real load
  • The testing pyramid (many unit tests, fewer integration tests, few E2E tests) is inverted in most companies. You need more integration and E2E tests than unit tests because that's where real bugs hide
  • 100% code coverage is cargo cult. You can have perfect coverage and ship code that breaks immediately. Coverage measures which lines ran, not whether your code works when things go wrong
  • You should test: the integration points (database, API calls, message queues), the error paths (what happens when things fail), and the edge cases (what happens at the boundaries of your assumptions)

I had 95% code coverage. Every line tested. Every branch covered. The tests were beautiful.

Production broke.

A database call that was tested locally took 5 seconds in production. The timeout I tested locally (2 seconds) was not enough. The code followed the happy path. The test passed. The production path was different.

My tests had lied to me. Not intentionally. They just tested the wrong thing.

Here is the uncomfortable truth about testing: you can have great tests and still ship broken code. Because tests and reality are different places.

The Three Types of Tests (And Why They All Fail)

Unit Tests

A unit test tests a single function in isolation.

function add(a, b) {
  return a + b;
}

test("add(2, 3) returns 5", () => {
  expect(add(2, 3)).toBe(5);
});

This passes. The function works. Great.

But this is not a unit test. This is a tautology. You wrote the code, then wrote a test that confirms the code works exactly how you wrote it.

A real unit test would test:

function processPayment(amount, card) {
  if (amount < 0) throw new Error("Negative amount");
  const result = chargeCard(card, amount);
  logTransaction(amount);
  return result;
}

test("processPayment charges the card", () => {
  const mockCard = { number: "4111111111111111" };
  const mockCharge = jest.fn().mockResolvedValue({ success: true });
  const mockLog = jest.fn();
  
  // Replace chargeCard with mockCharge
  // Replace logTransaction with mockLog
  
  const result = processPayment(100, mockCard);
  
  expect(mockCharge).toHaveBeenCalledWith(mockCard, 100);
  expect(mockLog).toHaveBeenCalledWith(100);
});

This test passes. But it does not test reality.

In reality:

  • chargeCard might fail
  • The card might be declined
  • The API might timeout
  • The timeout might cause logTransaction to not run
  • The payment might succeed but the logging might fail

The test assumes the mocks work perfectly. Reality does not.

Integration Tests

An integration test tests multiple components working together.

test("creating a user saves to database", async () => {
  const user = await createUser({ name: "Alice", email: "alice@example.com" });
  
  const saved = await db.query("SELECT * FROM users WHERE id = ?", [user.id]);
  
  expect(saved.name).toBe("Alice");
  expect(saved.email).toBe("alice@example.com");
});

This test passes. The user was created. The database saved it. Great.

But this test uses test data. The database is empty (or reset). The network is fast. There is no competing load.

In production:

  • The database might have 100 million users
  • The insert might take longer than expected
  • The index might be locked
  • The query might timeout
  • Other tests might be running against the same test database

The test passes in test isolation. Production fails under load.

E2E Tests

An E2E test tests the entire flow from user action to result.

test("user can create account and log in", async () => {
  await browser.goto("http://localhost:3000/signup");
  await browser.type('input[name="email"]', "bob@example.com");
  await browser.type('input[name="password"]', "password123");
  await browser.click('button[type="submit"]');
  
  await browser.goto("http://localhost:3000/login");
  await browser.type('input[name="email"]', "bob@example.com");
  await browser.type('input[name="password"]', "password123");
  await browser.click('button[type="submit"]');
  
  expect(await browser.url()).toBe("http://localhost:3000/dashboard");
});

This test passes. The user signed up. The user logged in. Great.

But the test is against localhost with perfect network latency. The signup process takes 200ms. In production, with a slow API, it takes 2 seconds. The test timeout is 5 seconds. It passes. Production times out and fails.

The test passes in isolation. Production fails under realistic conditions.

Why All Three Test Types Lie

The common thread: tests operate in a protected environment. Reality does not.

Tests assume:

  • Fast networks
  • No competing load
  • Small datasets
  • No failures
  • No timeouts
  • No race conditions

Reality has:

  • Slow networks (50-200ms latency)
  • Competing load (1000 concurrent requests)
  • Large datasets (millions of rows)
  • Constant failures (services down, timeouts, errors)
  • Timeouts (actually happening)
  • Race conditions (from concurrency)

Your tests pass because they test the happy path. Production fails because it is not the happy path.

The Testing Pyramid Is Upside Down

The conventional testing pyramid says:

E2E Tests (few)
    Integration Tests (some)
  Unit Tests (many)

The reasoning: unit tests are fast, so write many. Integration tests are slow, so write fewer. E2E tests are very slow, so write very few.

This makes sense for speed. It makes no sense for catching bugs.

Where bugs actually hide:

  • Integration points (database, API calls, message queues) โ€” integration tests
  • Error paths (what happens when things fail) โ€” E2E tests
  • Edge cases (boundaries of assumptions) โ€” integration tests
  • Load conditions (what happens under real load) โ€” E2E tests

Unit tests catch syntax errors and logic errors in isolated functions. But most bugs are not isolated. Most bugs are in how components interact.

The pyramid should be inverted:

Unit Tests (some)
    Integration Tests (many)
  E2E Tests (many)

Write E2E tests for critical paths. Write integration tests for all the places where components talk to each other. Write unit tests for complex logic.

But most companies do the opposite. They have 1000 unit tests and 5 E2E tests. Then they are surprised when the system fails at scale.

A minimal split diagram showing two pyramids. Left side labeled
A minimal split diagram showing two pyramids. Left side labeled "Conventional Pyramid": wide base (many unit tests, happy), narrow top (few E2E tests, sad). Right side labeled "Actual Bug Distribution": narrow base (unit tests catch few bugs), wide middle (integration tests catch many), tall top (E2E tests under load catch most). Below: "Unit tests catch ~5% of production bugs. Integration/E2E tests catch ~95%." Use sketch style, simple shapes, minimal text.

What Actually Matters in Tests

Stop worrying about coverage. Start worrying about reality.

Test the Integration Points

test("payment fails when Stripe API is slow", async () => {
  // Mock Stripe to delay 10 seconds
  mockStripe.delay(10000);
  
  const result = await processPayment(amount, card);
  
  // Should timeout and handle gracefully
  expect(result.error).toBe("Payment timeout");
});

This tests what actually happens in production. When the payment API is slow.

Test the Error Paths

test("user creation succeeds even if logging fails", async () => {
  mockLog.mockRejectedValue(new Error("Log service down"));
  
  const user = await createUser({ name: "Alice" });
  
  // User should still be created even though logging failed
  expect(user.id).toBeDefined();
  expect(mockLog).toHaveBeenCalled(); // Log was attempted
});

This tests what actually happens. When a dependency fails but the main operation should still work.

Test the Edge Cases

test("handles 1 million users without timeout", async () => {
  // Create 1 million test users
  await db.seed(1000000);
  
  const result = await queryUsers({ active: true });
  
  // Should complete in under 1 second
  expect(result).toBeDefined();
});

This tests what actually happens. When there is real data.

Test Under Load

test("can handle 1000 concurrent requests", async () => {
  const requests = [];
  for (let i = 0; i < 1000; i++) {
    requests.push(makeRequest());
  }
  
  const results = await Promise.all(requests);
  
  // All should succeed
  expect(results.every(r => r.success)).toBe(true);
});

This tests what actually happens. When there is real concurrent load.

The Uncomfortable Truth

Your tests are probably not testing what you think they are testing.

You write tests that pass locally. You deploy to production. It fails.

The test was not bad. The test was just not realistic.

Most developers spend 80% of their testing time on unit tests (because they are fast to write and run). They spend 20% on integration and E2E tests (because they are slow and annoying).

The result: 95% of bugs are in the 20% they barely tested.

It is backwards.

What You Should Do

Step 1: Write fewer unit tests

Only unit test complex logic. Not every function. Not happy paths.

Step 2: Write more integration tests

Test the actual integrations. Database calls. API calls. Message queue interactions.

Step 3: Write more E2E tests

Test critical user flows end-to-end. Not every flow. But the important ones.

Step 4: Test realistically

  • Use production-like data (real data volume, real complexity)
  • Test with realistic latency (add network delays)
  • Test under load (concurrent requests)
  • Test error cases (what happens when things fail)
  • Test timeouts (what happens when things take too long)

Step 5: Stop worrying about coverage

100% coverage is cargo cult. Aim for 70-80% coverage. Focus on testing the critical paths and error cases.

The Question to Ask

Before writing a test, ask: "Does this test check if my code works in production?"

If the answer is no, do not write it. Or write a different test.

Most unit tests are testing that your code does what you wrote it to do. That is not valuable. That is a tautology.

Valuable tests are tests that check if your code works when things go wrong.


Your tests passing does not mean your code works. It means your tests are not realistic.

Write tests that challenge your code. Tests that assume things go wrong. Tests that use real data and real load.

Then you will actually catch the bugs before production does.


slug: testing-unit-integration-e2e-what-matters

Why Your Microservices Are Just a Distributed Monolith (And That's the Problem)

Why Your Microservices Are Just a Distributed Monolith (And That's the Problem)

โ† Back to all articles

Related Articles