NodeJSTest-Driven Development

Test-Driven Development (TDD)

Test-Driven Development inverts the usual order: you write a failing test that describes the behaviour you want, then write the minimal code to make it pass, then clean up — repeating in tight loops of a few minutes each. The discipline isn't really about testing; it's about design. Writing the test first forces you to use your code before it exists, which surfaces awkward APIs and unclear requirements early. This page covers the red-green-refactor cycle, a worked example, the benefits and costs, and the honest answer to when TDD helps and when it doesn't.

The red-green-refactor cycle

Text
   ┌─────────────────────────────────────────────┐
   │                                               │
   ▼                                               │
┌──────┐      ┌────────┐      ┌──────────┐        │
│ RED  │ ───▶ │ GREEN  │ ───▶ │ REFACTOR │ ───────┘
└──────┘      └────────┘      └──────────┘
Write a       Write the       Improve the
failing       minimal code    code while
test for      to make the     tests stay
the next      test pass       green — no new
behaviour     (even ugly)     behaviour

Phase

Goal

Rule

Red

Write a test that fails for the right reason

Run it — confirm it fails before writing code

Green

Make the test pass as fast as possible

Write the simplest thing that works — even hard-coded

Refactor

Remove duplication, clarify names, improve design

Change structure, not behaviour — tests stay green

Run the test and watch it FAIL first — a test that never failed proves nothing
The most-skipped step is confirming the test fails *before* you write the implementation. A test you never saw fail might be testing the wrong thing, asserting nothing, or not running at all (wrong file name, forgotten `await`). Seeing the expected red — "expected 5, got undefined" — proves the test actually exercises the behaviour and will catch a regression later. Only then do you write code to turn it green. This "fail first" habit is what makes the test trustworthy.
A worked example — building a discount calculator

Step 1 — RED: write the failing test first

JS
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyDiscount } from './pricing.js'   // doesn't exist yet!

test('applies a percentage discount', () => {
  assert.equal(applyDiscount(100, 0.2), 80)
})
✖ applies a percentage discount
  Error: Cannot find module './pricing.js'   ← fails for the right reason

Step 2 — GREEN: the minimal code to pass

JS
// pricing.js — simplest thing that works:
export function applyDiscount(price, rate) {
  return price - price * rate
}
✔ applies a percentage discount ← green

Step 3 — RED again: add the next behaviour (an edge case)

JS
test('rejects a discount rate above 100%', () => {
  assert.throws(() => applyDiscount(100, 1.5), /rate must be between 0 and 1/)
})

Step 4 — GREEN: extend the code to satisfy both tests

JS
export function applyDiscount(price, rate) {
  if (rate < 0 || rate > 1) {
    throw new RangeError('rate must be between 0 and 1')
  }
  return price - price * rate
}
Each cycle adds ONE behaviour — the tests accumulate into a precise spec
Notice the rhythm: one small test, make it pass, one more test, make it pass. Each test pins down a single behaviour, and they accumulate into an executable specification of exactly what `applyDiscount` does — the happy path *and* the validation rule. You never wrote code that no test demanded, so there's no untested branch and no speculative "might need it later" logic. The edge cases (negative rate, rate over 1) get tested because you *thought of them as tests*, not as afterthoughts.
Refactoring under green

The tests are your safety net — restructure freely while they stay green

JS
// Refactor: extract validation, no behaviour change.
function assertValidRate(rate) {
  if (rate < 0 || rate > 1) {
    throw new RangeError('rate must be between 0 and 1')
  }
}

export function applyDiscount(price, rate) {
  assertValidRate(rate)
  return price - price * rate
}
// Run the suite — still green. The refactor is proven safe.
Refactoring means changing structure WITHOUT changing behaviour — and the tests prove it
The refactor step is where TDD pays back: because you have a passing test suite, you can rename, extract functions, eliminate duplication, and reshape the design with confidence — if a change breaks behaviour, a test goes red immediately. Without tests, every refactor is a gamble. TDD makes "improve the code continuously" safe rather than scary, which keeps the codebase from rotting as it grows.
Why write the test first?
  • Design pressure — using your API in a test before it exists exposes awkward signatures and hidden dependencies early, when they're cheap to change.

  • Built-in regression safety — every behaviour ships with a test, so the suite catches breakage the moment it happens.

  • Living documentation — the tests describe what the code is supposed to do, and they can't drift out of date like comments.

  • Forces small steps — you can't over-build; you only write code a failing test demands, avoiding speculative complexity (YAGNI).

  • Faster feedback — you find bugs seconds after writing the line, not days later in QA.

  • Better testability — code written test-first is naturally decoupled, because hard-to-test code is painful to write tests for first.

The costs and common objections
TDD is a skill with real costs — done badly it produces brittle tests that fight every change
TDD isn't free. It has a learning curve, it feels slower at first (the payoff is fewer debugging hours later), and done poorly it backfires: testing implementation details instead of behaviour creates **brittle tests** that break on every refactor — the opposite of the safety net you wanted. The fix is to test *observable behaviour* (inputs and outputs, side effects at boundaries), not private internals. TDD also doesn't replace integration and end-to-end tests; unit tests written test-first can all pass while the pieces fail to work together.
When TDD fits — and when it doesn't

TDD shines when…

TDD struggles when…

Logic is well-defined (parsers, calculations, state machines)

Requirements are unknown — you're exploring/prototyping

Bugs are expensive (payments, auth, data integrity)

The output is hard to assert (UI layout, visual design)

You're fixing a bug — write a failing test that reproduces it first

You're spiking to learn an API and will throw the code away

Refactoring legacy code — pin behaviour with tests, then change

The "unit" is mostly glue with no logic of its own

Reproduce every bug with a failing test before you fix it
Even teams that don't practise full TDD benefit from one habit: when a bug is reported, write a test that *reproduces* it (red), then fix the code (green). This proves you actually fixed the reported problem — not something adjacent — and the test stays in the suite forever as a guard against the bug returning. It's the highest-value, lowest-friction entry point to test-first thinking, and a great place to start if pure TDD feels like too big a leap.
Guidelines
  • Keep cycles short — minutes, not hours. If you're writing lots of code between green bars, take smaller steps.

  • One behaviour per test — a focused test names exactly what broke when it fails.

  • Test behaviour, not implementation — assert on outputs and effects, not private helpers.

  • Don't skip the refactor — green-then-move-on lets duplication and mess accumulate.

  • Watch the test fail first — a test you never saw red is a test you can't trust.

  • Be pragmatic — TDD is a tool, not a religion; apply it where logic is rich and bugs are costly.

Next
See what your code is doing in production: [Logging in Node.js](/nodejs/logging-intro).