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.
// Your test assumes this API shapemockApi.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
// Test with mock - always instantmockTimer.delay.mockResolvedValue(undefined)await processOrder() // Passes
// Real app - race conditionawait realTimer.delay(100) // What if API takes 150ms?await processOrder() // Fails4. Environment Differences
Test Environment | Production Environment--------------------------|-------------------------In-memory database | Real PostgreSQLMocked file system | Actual disk I/OInstant responses | Network latencySingle process | Multiple containersDev configurations | Production configsWhen 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
// Good use of mocks - testing pure logicdescribe('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
// This test proves nothing about real behaviordescribe('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:
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
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
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
- Dummies: Objects passed but never used
- Stubs: Canned answers to calls
- Spies: Record information about calls
- Mocks: Pre-programmed expectations
- 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
# Find tests that mock external servicesgrep -r "mock\|stub\|fake" tests/ --include="*.test.*"Step 2: Add Integration Tests
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
contract-tests: script: - npm run test:contracts rules: - schedule: "0 6 * * *" # Daily contract verificationMetrics to Track
| Metric | Target | Why It Matters |
|---|---|---|
| Mock-to-real ratio | < 3:1 | Too 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:
- Use mocks for pure logic, not integration points
- Add integration tests with real databases and services
- Implement contract tests to verify mocks match reality
- 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:
- 👨💻 Testing Without Mocks - Martin Fowler
- 👨💻 Playwright Documentation
- 👨💻 Unit Testing Principles by Kent Beck
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments