CodeOath
← All posts
Testing36 min total · 12 parts

Testing Fundamentals: Unit, Integration, and E2E Tests Done Right

Part 4 of 12 · ~4 min

Test-Driven Development

Test-driven development (TDD) flips the usual order: you write a failing test for behavior that doesn't exist yet, then write just enough code to make it pass, then clean up. The cycle has a name — red, green, refactor — and it repeats in small steps, over and over.

  1. Red — write a test for the next small piece of behavior. Run it. It fails, because the code doesn't exist yet. (If it passes immediately, either the behavior already existed or the test isn't actually testing anything.)
  2. Green — write the minimum code needed to make that test pass. Not the most elegant version, not the general solution — just enough to turn the test green.
  3. Refactor — now that there's a passing test as a safety net, clean the code up: rename things, remove duplication, simplify. Re-run the test after every change; it should stay green throughout.

A walked-through example: a Cart that rejects negative quantities

Say the requirement is: adding an item to a cart with a negative quantity should throw, instead of silently accepting garbage data.

Red — write the test first, against a Cart class that doesn't exist yet:

test("adding an item with a negative quantity throws", () => {
  const cart = new Cart();
  expect(() => cart.addItem("sku-1", -2)).toThrow("Quantity must be positive");
});

Run it: it fails immediately, because Cart doesn't exist. That's expected — this is the "red" the cycle is named for.

Green — write the least code that makes it pass:

class Cart {
  addItem(sku, quantity) {
    if (quantity < 0) throw new Error("Quantity must be positive");
  }
}

This is deliberately incomplete — it doesn't even store the item yet — but it makes the one test that exists pass, and that's the only job of this step.

Red again — add the next test, for the behavior the previous step skipped:

test("adding a valid item stores it in the cart", () => {
  const cart = new Cart();
  cart.addItem("sku-1", 2);
  expect(cart.items).toEqual([{ sku: "sku-1", quantity: 2 }]);
});

Green again — extend the implementation just enough:

class Cart {
  items = [];
  addItem(sku, quantity) {
    if (quantity < 0) throw new Error("Quantity must be positive");
    this.items.push({ sku, quantity });
  }
}

Refactor — with two green tests acting as a safety net, this is a safe moment to clean up naming, extract a validation helper, or restructure — and immediately know, from the tests, if a "harmless" cleanup actually broke something.

Where TDD genuinely helps, and where it's dogma

TDD earns its keep on logic with clear, checkable inputs and outputs — parsers, validation rules, pricing calculations, state machines — where writing the test first forces you to nail down the exact contract (what counts as valid input? what's the expected output for this edge case?) before you've already committed to an implementation that quietly bakes in wrong assumptions. It also leaves you with a real regression suite as a side effect, for free, instead of as a separate chore afterward.

It's applied badly, though, when it turns into a rule followed for its own sake rather than for what it buys you:

  • Exploratory or UI-heavy work, where you genuinely don't know the right shape of the solution yet, doesn't fit red-green-refactor well — you end up writing and rewriting tests as fast as you rewrite the design they were pinned to, which is wasted motion, not rigor.
  • Testing framework internals or trivial getters just to hit a "test-first" rule produces tests that exist purely to satisfy the process, adding maintenance cost without adding any real defect-catching power.
  • Writing the test at too fine a grain, one for every private method instead of every observable behavior, produces exactly the implementation-coupled tests covered above — TDD doesn't protect you from that mistake on its own; it just tells you when to write tests, not what they should actually be watching.

The honest takeaway: TDD is a tool for driving out a clear design under a safety net, not a moral obligation to write every test first no matter the situation. Plenty of excellent, well-tested code is written test-after, and plenty of strictly-by-the-book TDD produces a bloated suite of tests nobody can explain the purpose of six months later.