Writing My First Real Test Suite: What I Got Wrong With Mocking

By James Nguyen Updated September 24, 2026
Writing My First Real Test Suite: What I Got Wrong With Mocking

I shipped a payment reconciliation bug to production that a genuinely thorough test suite should have caught, and the specific reason it didn't get caught is that every single test around that code mocked the one function that actually contained the bug. Green checkmarks across the board, and a real financial discrepancy in production that a customer had to report to us before we noticed. That incident is what taught me the difference between tests that pass and tests that actually verify anything.

Mocking everything felt productive and wasn't

My early instinct, coming from tutorials that emphasized "unit tests should be fast and isolated," was to mock every external dependency, the database, other services, even other functions within our own codebase, so each test ran in milliseconds with zero setup. This produced a test suite that ran fast and looked comprehensive on a coverage report, while actually verifying almost nothing about whether the pieces worked correctly together.

The specific bug that exposed the problem

We had a function that calculated a refund amount, and a separate function that applied that refund to a customer's account balance. Both were individually tested with mocked inputs and both passed. What nobody's tests verified was that the actual value returned by the first function matched the shape the second function expected, one returned a plain number, the other expected an object with a currency field. In production, that mismatch silently coerced to NaN, and NaN got written to a database column as zero, which is how a customer got refunded nothing while our logs showed a "successful" refund.

// what our mocked tests looked like
test('calculateRefund returns correct amount', () => {
  expect(calculateRefund(order)).toBe(45.00);
});

test('applyRefund updates balance', () => {
  const mockAmount = { amount: 45.00, currency: 'USD' };
  expect(applyRefund(account, mockAmount)).toBe(true);
});
// neither test ever called the real calculateRefund output
// against the real applyRefund input

Integration tests that actually chain real functions together

After that incident, I added a separate category of test that deliberately avoids mocking anything internal to our own codebase, only mocking genuine external boundaries like third-party payment APIs. These tests call the real calculateRefund function and feed its actual output directly into the real applyRefund function, which is exactly the kind of test that would have caught our production bug immediately.

test('refund calculation and application work together', async () => {
  const refund = calculateRefund(order); // real function, real output
  const result = await applyRefund(account, refund); // real function
  expect(result.newBalance).toBe(account.balance - 45.00);
});

Where mocking still genuinely makes sense

I didn't abandon mocking entirely, mocking a third-party payment gateway is still correct, we don't want tests actually hitting Stripe's API or depending on network conditions to pass reliably. The lesson wasn't "stop mocking," it was "stop mocking the boundaries between pieces of our own code that need to actually agree with each other on data shape."

Coverage percentage told me nothing useful, in hindsight

Our coverage report showed over 90 percent before this incident, and that number gave me real, false confidence. Coverage measures whether a line of code executed during a test, not whether the test actually verified correct behavior against realistic data. I've stopped treating coverage percentage as a meaningful quality signal on its own since learning this the expensive way.

What our test suite looks like differently now

We now require at least one integration-style test per feature that chains the real internal functions together end to end, alongside the fast, mocked unit tests for edge cases within a single function. It's a slower test suite than before, our CI run went from around ninety seconds to closer to four minutes, but I'd take that four minutes every single time over another silent production bug a green checkmark told us didn't exist.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles