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.
functionadd(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.
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 () => {constuser=awaitcreateUser({ name: "Alice", email: "alice@example.com" });constsaved=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.
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.
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 "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);constresult=awaitprocessPayment(amount, card);// Should timeout and handle gracefullyexpect(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(newError("Log service down"));constuser=awaitcreateUser({ name: "Alice" });// User should still be created even though logging failedexpect(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 usersawait db.seed(1000000);constresult=awaitqueryUsers({ active: true });// Should complete in under 1 secondexpect(result).toBeDefined();});
This tests what actually happens. When there is real data.
Test Under Load
test("can handle 1000 concurrent requests", async () => {constrequests= [];for (let i =0; i <1000; i++) { requests.push(makeRequest()); }constresults=awaitPromise.all(requests);// All should succeedexpect(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.