My Tests Passed, But Production Failed: The Trap of Over-Mocking
1. The Betrayal of Green Lights
As a junior developer, checking failing tests turn Green gave me a dopamine rush. I was obsessed with 100% Test Coverage.
One day, I refactored the legacy UserService, ran the unit tests, and saw the perfect report: "All tests passed."
"Perfect." I deployed to production with confidence.
Five minutes later, Slack exploded.
TypeError: emailService.send is not a function
"What? I specifically tested the email sending logic!"
Sweating, I checked the code.
During refactoring, I had renamed EmailService's method from send to sendEmail.
But why did the tests pass?
Because my UserService test was Mocking EmailService and forcing it to succeed, completely ignoring the reality.
/* The Lie in My Test Code */
// Real code changed to sendEmail(), but my fake object still has send()
const emailService = { send: jest.fn() };
// UserService calls this fake .send()
userService.register(user, emailService);
// Test passes because I called the fake method I created
expect(emailService.send).toHaveBeenCalled();
I wasn't testing my application. I was testing a fantasy object I created.
This incident shocked me into relearning the types and proper usage of Test Doubles.
2. The Genealogy of Test Doubles (Martin Fowler Style)
Many devs call every fake object loop a "Mock".
But Martin Fowler distinguishes them into 5 types of Test Doubles.
If you confuse them, you end up with brittle tests like mine.
1. Dummy (Scarecrow)
- Role: Objects passed around but never actually used. Just to fill parameter lists.
- Example: Filling
null or {} for arguments irrelevant to the current test.
const dummyUser = null;
userService.calculateTax(dummyUser, 10000); // Just preventing "Missing Argument" error
2. Stub (Stunt Double)
- Role: Provides "Canned Answers" to calls made during the test.
- Usage: Simulating scenarios like "Database is down" or "User found".
- Core: Stubs are for State Verification.
/* Stub for Repository */
const repoStub = {
// Always return User 1, no matter what
findById: () => ({ id: 1, name: "Ratia" })
};
const result = service.getUserName(repoStub);
expect(result).toBe("Ratia"); // Verifying the RETURN value
3. Mock (Behavior Monitor)
- Role: Objects pre-programmed with expectations which form a specification of the calls they are expected to receive.
- Usage: Verifying void functions or Side Effects (Email, Log, Payment).
- Core: Mocks are for Behavior Verification. "Was I called?"
const emailMock = jest.fn(); // The spy
service.register(user);
// "Did you call sendEmail exactly once?"
expect(emailMock).toHaveBeenCalledTimes(1);
3. So What Was the Problem? (Stub vs Mock)
My failure stemmed from two mistakes:
-
Using a Mock where I should have used a Stub.
-
Mocking the Implementation Details.
Use Stubs for Queries
For methods that return data (getUser, products.find), use Stubs via Fakes or simple objects.
Verifying "Did you call getUser?" is useless. It ties your test to the specific implementation (Over-specification).
Unless you are testing a caching layer, nobody cares how you got the data, only that you processed it correctly.
Use Mocks for Commands
For methods that perform actions (sendEmail, chargeCreditCard, writeLog), use Mocks.
Since these methods often return void, you can't check the result. You must check "Did the action happen?"
4. A Better Way: Fake (Fake Implementation)
The modern testing trend (championed by Google and Kent C. Dodds) is "Don't Mock What You Don't Own" and "Use Fakes".
Heavy mocking makes tests brittle—they break whenever you rename a function, even if the app works fine.
A Fake is a working implementation, but simplified (e.g., using In-Memory Map instead of a real SQL DB).
/* Fake Database Implementation */
class FakeUserRepository {
constructor() {
this.users = new Map();
}
save(user) {
this.users.set(user.id, user);
}
findById(id) {
return this.users.get(id);
}
}
Use Fakes for Logic-Heavy Dependencies
What if UserRepository has complex logic? Sorting, filtering, validation?
Mocking findById to return a static object won't test that logic.
A FakeUserRepository that actually stores data in a Map will behave exactly like the real DB, allowing you to test complex interactions.
Pro Tip: Don't Mock Types
If you are using TypeScript, you might be tempted to cast objects as any or use as unknown as User.
Don't do it. Use utilities like DeepPartial<T> or build a proper Test Fixture factory.
const user = buildUser({ id: 1 }); gives you a valid object with all required fields filled with dummy data, keeping your tests type-safe.
5. FAQ: When to usage what?
Q: Can I use Mocks for everything?
A: Technically yes, but your tests will become "High Maintenance". Every time you change an internal implementation detail (like a private method name), your Mocks might break, or worse, they might pass when they should fail.
Q: Are Stubs and Mocks mutually exclusive?
A: Not necessarily. A Test Double can be both. For example, a dbMock can return a value (Stub behavior) AND record that it was called (Mock behavior). But conceptually, you should separate your intent. Are you checking the result (Stub) or the action (Mock)?
Q: What about Spies?
A: A Spy wraps a real object and records calls. It's useful when you want the real logic to run but just want to peek at the arguments. Jest's jest.spyOn() is a great tool for this. It's less intrusive than a full Mock.
6. Conclusion: "Simulate, Don't Deceive"
Tests are supposed to be your safety net. They allow you to refactor with confidence.
But when you overuse Mocks (jest.fn()), that net becomes an illusion. You think you are safe, but you are just testing your own assumptions, not the code.
Summary:
- Stub: Use when you need input data. (Verify State/Result). "I need the DB to return User A."
- Mock: Use only for key side effects. (Verify Behavior/Calls). "I need to make sure the email was sent."
- Fake: If possible, write a reusable In-Memory implementation. It's robust, fast, and safe.
If you wrote tests but still feel anxious about hitting "Deploy", ask yourself:
Are you testing your code, or are you just verifying that your imaginary Mock friends are listening?
"Good tests don't break when you refactor implementation details. Bad tests do."
Stop writing bad tests. Start using Fakes.