Skip to main content

Testing Best Practices

Why This Page Exists

Everything else in this section teaches mechanics — matchers, mocks, Supertest. Mechanics are the easy part. Most bad test suites I've inherited were written by people who knew the mechanics perfectly; what they lacked were the habits below. These are the rules I hold my own code (and code reviews) to.

Structure Every Test as Arrange, Act, Assert

Every readable test has three phases in the same order:

test("applies free shipping to orders over Rs 10,000", () => {
// Arrange
const cart = new Cart({ customer: "Rizwan Ashiq" });
cart.add({ name: "Mechanical Keyboard", price: 12500 });

// Act
const invoice = cart.checkout();

// Assert
expect(invoice.shippingFee).toBe(0);
expect(invoice.total).toBe(12500);
});

The value isn't the comments (I usually omit them — blank lines between phases are enough). The value is the discipline:

  • One Act per test. If you act twice, you're testing two behaviors — split it.
  • Assert after acting, never in between. Interleaved setup and assertions force the reader to simulate the whole test in their head.
  • Arrange only what matters. If the customer's email is irrelevant to shipping, don't set it. Every line of setup implies "this affects the outcome" — noise there is misinformation.

Name Tests After Behavior, Not Implementation

The test name is what appears in the failure output at 2 a.m. It should tell whoever is on call what the system failed to do — without opening the file:

// Bad — describes internals; meaningless when it fails
test("calls findById");
test("userService test 3");
test("works");

// Good — describes observable behavior
test("returns 404 when the user does not exist");
test("rejects registration when the email is already taken");
test("retries the payment twice before giving up");

A trick that keeps me honest: the describe + test pair should read as a sentence. describe("POST /api/orders") + test("returns 400 when the cart is empty") reads as a spec. If you can't phrase the name as a behavior, you probably don't know what the test is protecting — which means it's protecting nothing.

The same rule bans asserting on implementation details. A test that checks "the handler called repo.findById once" breaks when someone refactors to a batch query, even though the behavior is identical. Assert on what comes out, not on how it got there.

Mock External Dependencies Only

The single most common way suites rot: mocking your own code. The rule I follow —

Mock what you don't own. Keep what you own real.

Mock these:

  • Third-party HTTP APIs — payment gateways, email, analytics
  • Infrastructure you can't run in the test — external queues, SaaS services
  • Non-determinism — the clock, Math.random, UUID generators

Don't mock these:

  • Your own services, repositories, and utility modules
  • The framework — Express routing, JSON parsing (drive them with Supertest instead)
  • Pure functions — there is never a reason

Here's why. If OrderService is mocked in the controller test and the real OrderService changes its return shape, the controller test still passes — it's now verifying a conversation with a fiction. You end up with hundreds of green tests and a broken app. Mock-heavy tests also cement the current call structure: every refactor breaks a dozen tests whose behavior didn't change, and the team learns to stop refactoring.

When wiring your own modules together feels too heavy for a unit test, that's not a signal to mock — it's a signal this test wants to be an integration test one level up.

Every Bug Fix Ships With a Regression Test

A bug is proof that your existing tests have a hole. Fixing the code without patching the hole means the bug can return silently — and bugs love returning.

The workflow, in order:

  1. Reproduce the bug in a test. Run it. Watch it fail. A regression test you never saw fail proves nothing — it might pass for the wrong reason.
  2. Fix the code.
  3. Watch the test pass. Commit the test and fix together.

Real example: Hafsa reported that a 100% discount code produced a total of -0 and broke the payment gateway. The fix commit contains both the guard and this test:

test("returns exactly 0 for a 100% discount, not negative zero", () => {
const total = applyDiscount(2500, { type: "percent", value: 100 });

expect(Object.is(total, -0)).toBe(false);
expect(total).toBe(0);
});

Delete the fix and this test fails; that's the definition of a regression test. Over a few years these accumulate into the most valuable tests in the suite — each one documents a real failure that really happened, not a hypothetical.

Keep Tests Independent and Deterministic

Each test must pass alone, in any order, on any machine. The moment test B depends on data test A created, you have a suite that fails under --randomize, fails in parallel, and fails in ways that waste afternoons.

  • Reset shared state in beforeEach — fresh store, cleared mocks, jest.clearAllMocks().
  • No real network, no real clock, no Math.random without seeding or faking — see Async Testing for pinning time.
  • A flaky test is worse than no test: it trains the team to re-run CI instead of reading failures. Quarantine it the day it flakes, fix or delete it that week.

Test-Driven Development, Honestly

I don't write every line test-first, but for anything with real logic — pricing, validation, state machines — the red-green-refactor loop pays for itself:

The step people skip is Red. Watching the test fail first is the only proof the test can fail — I've seen "passing" tests that asserted on the wrong function and would have stayed green forever. The step people also skip is Refactor, which is how "simplest code that passes" calcifies into the production design.

Where TDD isn't practical (exploratory work, glue code), write the tests immediately after — same commit. "I'll add tests in a follow-up PR" is where tests go to not exist.

Coverage Is a Flashlight, Not a Score

Coverage tells you which lines your tests executed — not which behaviors they verified. This test produces 100% line coverage of applyDiscount and verifies nothing:

test("runs", () => {
applyDiscount(2500, { type: "percent", value: 100 });
expect(true).toBe(true);
});

So:

  • Use coverage to find untested code, especially unexecuted branches — the error paths, the else clauses. Branch coverage is the honest column in the report; line coverage flatters.
  • Don't set a global 100% target. It gets met by writing assertion-free tests for boilerplate, which is worse than leaving the gap visible.
  • Do hold business-critical paths to a high bar. Money, auth, and data-loss paths in my projects sit near full branch coverage because every branch there is a real scenario someone will hit.

A useful compromise in jest.config.js — enforce thresholds where they matter:

jest.config.js
module.exports = {
coverageThreshold: {
global: { branches: 70 },
"./src/billing/": { branches: 95, functions: 95 },
},
};

The Checklist

Before merging, each test gets sixty seconds of scrutiny:

  • Does the name state a behavior someone could disagree with?
  • One Act, asserts at the end, setup limited to what matters?
  • Would it still pass if I mocked less? Would it survive a refactor that preserves behavior?
  • Does it cover the sad path — the 404, the rejection, the empty list?
  • Have I ever seen it fail?

If the answer to the last one is no, make it fail on purpose once. Tests are code whose entire job is failing at the right moment — verify they can.