Coverage says 94%. Every test is green. You deploy on Friday afternoon, and by Monday a customer has found a bug in the exact function you "tested".
When you open the test, the reason is obvious in hindsight. The test never ran your code. It ran a fake version of everything around it and then asserted that the fake behaved like a fake.
Here are four ways this happens, and a check you can do in a minute to see how many of your own tests are guilty.
1. You Mocked the Thing You Are Testing
// pricing.ts
export function applyDiscount(total: number, code: string) {
return code === 'SAVE10' ? total * 0.9 : total;
}
// checkout.test.ts
import * as pricing from './pricing';
vi.spyOn(pricing, 'applyDiscount').mockReturnValue(90);
test('applies discount at checkout', () => {
expect(checkout(100, 'SAVE10')).toBe(90);
});
This passes if applyDiscount is correct. It also passes if applyDiscount returns NaN, because the test replaced it with a hard-coded 90. You are testing that checkout can return the number you handed it.
Fix: mock what is outside your code, such as network, clock and payment providers. Run your own logic for real.
2. You Assert the Calls, Not the Outcome
test('saves the user', async () => {
await createUser({ email: 'a@b.com' });
expect(db.insert).toHaveBeenCalledWith('users', { email: 'a@b.com' });
});
This test proves the function called insert with those arguments. It does not prove a user exists. If someone renames the table, adds a required field or changes the query, this test keeps passing against the mock while real users stop being saved.
It also fails for the wrong reason. Refactor the internals without changing behavior, and it turns red, so people learn that failing tests are noise.
Fix: assert what a user or caller would observe.
test('saves the user', async () => {
await createUser({ email: 'a@b.com' });
const user = await findUserByEmail('a@b.com');
expect(user?.email).toBe('a@b.com');
});
3. The Fake Database Only Says Yes
A mocked database returns whatever you tell it to. A real one has rules:
- A unique index rejects duplicate emails.
- A required field rejects a missing value.
- A foreign key rejects an ID that does not exist.
- A query with a typo in a field name returns nothing.
None of these exist in a mock, so none of these bugs can appear in your tests. They show up in production, on the first real duplicate signup.
Fix: for anything that touches storage, test against a real database, either a throwaway local instance or an in-memory one with the same engine, reset between tests. It is slower than a mock, but it is the only thing that can fail the way production fails.
test('rejects a duplicate email', async () => {
await createUser({ email: 'a@b.com' });
await expect(createUser({ email: 'a@b.com' })).rejects.toThrow();
});
You cannot write that test against a mock that always resolves.
4. Snapshots Approved Without Reading
expect(renderInvoice(order)).toMatchSnapshot();
A snapshot records whatever the output is today and fails when it changes. It does not know whether today's output is correct. When it fails, the usual reaction is to press "update" and move on, so the test stores bugs as the expected answer.
Fix: use snapshots for small, stable things, and assert the fields that matter for anything involving money, permissions or dates:
const invoice = renderInvoice(order);
expect(invoice.total).toBe(118.0);
expect(invoice.lines).toHaveLength(2);
The 60-Second Sabotage Check
Here is how to find out whether a test protects you. Take a function you care about, break it on purpose, and run its tests.
export function applyDiscount(total: number, code: string) {
return total; // sabotage: discount never applies
}
Then ask one question: did a test fail?
- If yes, that test is doing its job.
- If no, you have found a test that would have stayed green while the feature was broken in production.
Revert the change, and fix or delete the test that did not notice. Try this on three important functions and you will learn more about your suite than coverage ever told you. The formal version of this idea is called mutation testing, and tools exist that automate it, but doing it by hand on your most critical code is a good start.
What to Mock, and What Not To
| Mock it | Run it for real |
|---|---|
| Network calls to third parties | Your own business logic |
| The clock and random numbers | Your database queries |
| Email and SMS sending | Validation and permission rules |
| Payment providers | Anything that can fail on real data |
The rule is that you mock the edges, the places where your code talks to someone else, and keep everything in the middle real.
The Rule
A test that cannot fail when the code is wrong is not a test. Mock the world outside your app, assert outcomes instead of calls, and use sabotage to check whether your tests notice.
Quick audit: pick the three functions that would hurt most if they broke, such as pricing, permissions and sign-up. Break each one on purpose. Count how many tests turn red. If the answer is zero for any of them, that is your next ticket.
If you want a Next.js starting point to build and test on, you can browse my templates on pixelanas.com.
Get the templates: https://pixelanas.gumroad.com
What is the most misleading green test you have ever found? Share it in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)