Draft / Scheduled Content
This article is a draft or scheduled for future publication. The content is subject to change.
Your Unit Tests Are Mocking You
The Green Suite Illusion
We have all experienced this scenario:
You join a project, pull the code, and run the test suite.
Tests: 1420 passed, 0 failed (100% success).
It is a beautiful sight. The code coverage report shows 95% coverage. Every file is green. You feel confident.
You deploy a minor change to production, and immediately the error monitoring system starts screaming. The database threw a constraint violation error, or an API call returned a structure your code didn’t expect.
How did the tests pass if the application was broken?
Because the tests were not testing your code. They were testing your mocks.
In the pursuit of “pure” unit testing—where every function is isolated from external systems—the industry has built a culture of mock-driven development. We write tests where we mock the database, mock the HTTP client, mock the file system, and mock other classes.
We have ended up with test suites that are brittle, hard to read, and offer zero guarantee that the application actually works.
The Fraud of Isolated Unit Testing
The classic definition of a unit test is: testing a single unit of code (usually a class or function) in isolation from its dependencies.
To achieve this isolation, if Class A calls Class B, we don’t use a real instance of Class B. We create a “mock” of Class B and define its behavior:
// Brittle mock-based test
test("updates user profile", async () => {
const mockDb = {
updateUser: jest.fn().mockResolvedValue({ id: 1, name: "Bob" })
};
const mockMailer = {
sendEmail: jest.fn().mockResolvedValue(true)
};
const userService = new UserService(mockDb, mockMailer);
const result = await userService.updateProfile(1, { name: "Bob" });
expect(result.name).toBe("Bob");
expect(mockDb.updateUser).toHaveBeenCalledWith(1, { name: "Bob" });
expect(mockMailer.sendEmail).toHaveBeenCalled();
});
Look closely at this test. What is it actually testing?
- It verifies that
UserServicecallsmockDb.updateUser. - It verifies that
UserServicecallsmockMailer.sendEmail.
But does updateUser actually work? Does it match the database schema? What happens if the database is down? What happens if PostgreSQL rejects the name because it violates a unique constraint?
The test cannot tell you.
The test is asserting that UserService behaves correctly assuming the database behaves exactly like our mock. If our mock’s assumptions are incorrect—if the database API changed, or the database throws an error we didn’t mock—the test stays green, but production fails.
We are writing tests that verify our code behaves against our own imagination.
The Refactoring Bottleneck
The promise of tests is that they make refactoring safe. You change the internal implementation of a class, run the tests, and if they are green, you know you didn’t break anything.
Mock-based unit tests do the exact opposite: they make refactoring impossible.
Because mock tests are highly coupled to the implementation details of the code (asserting that a specific method was called with specific arguments), any change to how classes communicate breaks the tests, even if the user-facing behavior remains identical.
Suppose you want to optimize UserService to update the user database and send the email asynchronously via a queue instead of directly.
The application still works. The user still gets updated and emailed.
But your unit test crashes. Why? Because mockMailer.sendEmail was not called directly in the execution flow.
You have to spend hours rewriting the test setups, updating the mocks, and changing the assertions. The tests have become a anchor dragging down your development velocity. They are policing how you write code, instead of what the code does.
+-------------------------------------------------------------+
| The Mock Testing Loop |
| |
| 1. Refactor internal code structure (Behavior is same). |
| 2. Run tests -> 45 tests fail. |
| 3. Stare at mocks -> Rewrite mock setups for 2 hours. |
| 4. Tests pass. |
| |
| Result: Hours wasted updating tests that verified nothing |
| about actual behavior. |
+-------------------------------------------------------------+
The Alternative: Test Real Behavior (Integration First)
We need to stop writing isolated unit tests for code that has external side effects.
Instead, we should write Integration Tests using real, lightweight instances of our dependencies:
1. Use Real Databases (SQLite / Docker Postgres)
Do not mock your database queries. SQL queries are code. They need to be executed against a real database database parser to verify syntax, constraints, and transactions.
With modern tooling, this is incredibly fast:
- SQLite in-memory: For simple databases, you can spin up an in-memory SQLite database for each test file in milliseconds.
- Testcontainers: A library that lets you programmatically spin up a real PostgreSQL, Redis, or Kafka instance in a Docker container for the duration of your test run.
// Integration test with real database
import { TestDb } from './test-helper';
test("updates user profile with real DB", async () => {
const db = await TestDb.setup(); // Spins up clean temp Postgres schema
const mailer = new FakeMailer(); // A simple spy, not a complex mock
const userService = new UserService(db, mailer);
await userService.updateProfile(1, { name: "Bob" });
const updatedUser = await db.user.find(1);
expect(updatedUser.name).toBe("Bob"); // Asserting database state
});
This test is robust. If your SQL query contains a syntax error, or violates a database check constraint, this test will fail. If you refactor the internal SQL structure, the test remains green as long as the data is written correctly.
2. Use Fakes instead of Mocks
If you must isolate a dependency (like a third-party billing gateway or an email provider), use a Fake instead of a Mock.
A Mock is an object where you manually configure behavior for a single test.
A Fake is a simplified, working implementation of the service that behaves like the real thing but runs in memory:
// A fake implementation is reusable across all tests
class FakeMailer implements Mailer {
private sent: Email[] = [];
async sendEmail(email: Email) {
this.sent.push(email);
}
hasSentTo(address: string) {
return this.sent.some(e => e.to === address);
}
}
Fakes are easier to read, reusable, and don’t require you to write complex mock setups inside every single test block.
Test the Boundaries
Before writing a test, ask yourself: “Am I asserting the behavior of my code, or am I asserting that my code calls other code?”
If you are asserting that your code calls other code, delete the test. It is a waste of time.
Focus your testing budget on:
- Pure domain logic: Unit test functions that do calculations, validation, or state changes without external side effects. These don’t require mocks because they have no dependencies.
- System integration: Integration test your database adapters, API routes, and external service layers using real databases and fakes.
Write tests that give you confidence, not tests that give you green metrics.
Related Content
Why We Should Stop Writing 'Smart' Code
Writing clever, concise, one-liner code is a common developer ego trip. In practice, 'smart' code is a maintenance liability that increases cognitive load, slows down debugging, and confuses your colleagues. Readable code is boring, obvious, and explicit.
The Fallacy of DRY: Why You Should Write Duplicated Code First
Don't Repeat Yourself (DRY) is one of the first design principles programmers learn. But applying it too early creates tightly coupled, hyper-flexible abstractions that crumble under the weight of changing requirements. Write duplicated code until the structure reveals itself.
The SPA Obsession Has Ruined the Web
Single Page Applications (SPAs) were supposed to make the web feel like desktop apps. Instead, they gave us megabytes of JavaScript, blank pages during loading, broken back buttons, and over-engineered build steps. It's time to admit SPAs are a failure for 90% of websites.