Skip to content

Why Mocked Unit Tests Pass But Live App Fails

The Problem

I pushed code to production. All 247 unit tests passed. The app crashed immediately.

After hours of debugging, I found the issue: my mocked API response didn’t match what the real API returned. The tests passed because they verified my assumptions, not reality.

This pattern repeats across teams. Tests go green, deployments break. The culprit is almost always over-reliance on mocked unit tests.

Why Mocks Create False Confidence

Mocked unit tests pass because they test against your assumptions, not actual behavior.

user.test.js
// Your test assumes this API shape
mockApi.getUser.mockReturnValue({ id: 1, name: 'Test' })
// But the real API returns this
// { data: { user: { id: 1, name: 'Test' } } }

The mock passes. Your code works with what you assumed. The live app fails because reality differs from your assumptions.

The Codex Lesson

During a debugging session, an AI coding assistant (Codex) spent hours fixing a bug. The unit tests kept passing after each fix, but the live app still failed. Eventually, the AI pivoted to spinning up a real browser and testing against actual behavior.

The key insight: the AI recognized that “tests were lying” and switched to real verification. Humans should learn from this.

Where Mocked Tests Fail

1. Assumptions Embedded in Mocks

You mock a response based on documentation or previous observation. Then the API changes without notice. Your tests still pass.

2. Missing Integration Points

Mocks skip:

  • Database connections and transactions
  • Network latency and timeouts
  • Serialization quirks
  • Authentication flows
  • Third-party service behavior

3. Timing Issues

timer-mock.test.js
// Test with mock - always instant
mockTimer.delay.mockResolvedValue(undefined)
await processOrder() // Passes
// Real app - race condition
await realTimer.delay(100) // What if API takes 150ms?
await processOrder() // Fails

4. Environment Differences

Test Environment | Production Environment
--------------------------|-------------------------
In-memory database | Real PostgreSQL
Mocked file system | Actual disk I/O
Instant responses | Network latency
Single process | Multiple containers
Dev configurations | Production configs

When Mocks Are Appropriate

Mocked unit tests work well for:

  • Pure business logic with no external dependencies
  • Algorithm isolation
  • Rapid feedback during development
  • Edge case testing
calculator.test.js
// Good use of mocks - testing pure logic
describe('PriceCalculator', () => {
it('applies discount correctly', () => {
const product = { price: 100, category: 'electronics' }
const discount = calculateDiscount(product)
expect(discount).toBe(15)
})
})

When Mocks Become Dangerous

Mocked tests fail when testing:

  • API contracts
  • Database transactions
  • Third-party integrations
  • Authentication flows
dangerous-mock.test.js
// This test proves nothing about real behavior
describe('UserService', () => {
it('fetches user from API', async () => {
mockApi.get.mockResolvedValue({ id: 1 })
const user = await userService.fetchUser(1)
expect(user.id).toBe(1) // Passes even if real API is down
})
})

The Testing Pyramid Problem

/\
/E2E\ <- Slow, Expensive, High Confidence
/------\
/Integration\ <- Balance of speed and realism
/--------------\
/ Unit Tests \ <- Fast, Cheap, Limited Scope
/------------------\

Many teams over-invest in unit tests with mocks and under-invest in integration and E2E tests. The result: a hollow pyramid with false confidence at the bottom.

Solutions

Strategy 1: Contract Testing

Verify mocks match real implementations:

contract.test.js
describe('API Contract', () => {
it('mock matches real API response shape', async () => {
const realResponse = await realApi.getUser(1)
const mockResponse = mockApi.getUser(1)
expect(Object.keys(mockResponse)).toEqual(Object.keys(realResponse))
})
})

Run contract tests periodically against real APIs.

Strategy 2: Integration Tests with Real Dependencies

integration.test.js
describe('UserService Integration', () => {
beforeAll(async () => {
await testDb.connect()
})
afterAll(async () => {
await testDb.disconnect()
})
it('creates and retrieves user', async () => {
const user = await userService.create({ name: 'Test' })
const found = await userService.findById(user.id)
expect(found.name).toBe('Test')
})
})

Use a real test database, not mocks.

Strategy 3: Playwright E2E Tests

checkout.spec.ts
import { test, expect } from '@playwright/test'
test('user can complete checkout', async ({ page }) => {
await page.goto('/products')
await page.click('[data-testid="add-to-cart"]')
await page.click('[data-testid="checkout"]')
await expect(page.locator('.success-message')).toBeVisible()
})

Test real user flows against a staging environment.

Strategy 4: Choose the Right Test Double

  1. Dummies: Objects passed but never used
  2. Stubs: Canned answers to calls
  3. Spies: Record information about calls
  4. Mocks: Pre-programmed expectations
  5. Fakes: Working implementations (in-memory database)

Fakes bridge the gap between mocks and real implementations. An in-memory database is more realistic than a mock but faster than a real database.

Practical Implementation

Step 1: Audit Your Tests

terminal
# Find tests that mock external services
grep -r "mock\|stub\|fake" tests/ --include="*.test.*"

Step 2: Add Integration Tests

api-integration.test.js
describe('API Integration', () => {
const baseUrl = process.env.TEST_API_URL
it('real endpoint matches documented contract', async () => {
const response = await fetch(`${baseUrl}/users/1`)
const data = await response.json()
expect(data).toHaveProperty('id')
expect(data).toHaveProperty('name')
})
})

Step 3: Run Contract Verification in CI

.gitlab-ci.yml
contract-tests:
script:
- npm run test:contracts
rules:
- schedule: "0 6 * * *" # Daily contract verification

Metrics to Track

MetricTargetWhy It Matters
Mock-to-real ratio< 3:1Too many mocks = false confidence
Integration coverage> 60%Critical paths tested for real
E2E coverage> 20%User journeys validated
Flaky test rate< 1%Unreliable tests destroy trust

When to Be Suspicious of Your Tests

Your tests might be lying if:

  • They never fail in CI but production breaks regularly
  • Mock setup takes longer than the test itself
  • You’re testing framework code, not business logic
  • Integration bugs appear in production first
  • The same bug keeps reappearing after “fixing” tests

Summary

Mocked tests verify assumptions, not reality. They pass because your code works with your mock, not because it works with actual dependencies.

The solution isn’t to abandon mocks. It’s to:

  1. Use mocks for pure logic, not integration points
  2. Add integration tests with real databases and services
  3. Implement contract tests to verify mocks match reality
  4. Add E2E tests for critical user journeys

The most valuable tests verify real behavior. Build a strategy that includes mocked unit tests for speed, but back them with integration and E2E tests for confidence.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments