DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your Tests Are Green and Prove Nothing: 4 Signs You're Testing the Mock

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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);
});
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

You cannot write that test against a mock that always resolves.

4. Snapshots Approved Without Reading

expect(renderInvoice(order)).toMatchSnapshot();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)