Step 12 of 24

Test-driven development

Drive design with red–green–refactor, choose the right test doubles, balance the test pyramid, and test behaviour rather than implementation.

19 min 9 sections

Test-driven development (TDD) is a way of writing code where you write a failing test first, make it pass with the simplest code you can, then clean up — in cycles of a few minutes. It sounds like a testing technique, but its real output is design: code written to be tested first ends up with small units, explicit dependencies and clear interfaces, because anything else is painful to test. This lesson walks through a full TDD session in Go, then covers the test pyramid, test doubles, what makes a test good, and when TDD isn't worth it. It assumes you've seen Go's testing package in Testing in Go.

Red, green, refactor

TDD is one loop, repeated:

  1. Red — write a small test for the next bit of behaviour. Run it and watch it fail, for the reason you expect. A test you've never seen fail might not be testing anything.
  2. Green — write the simplest code that makes it pass. Hard-coding a return value is allowed; the next test will force you to generalise.
  3. Refactor — with all tests green, improve the names and structure of both code and tests. Run the tests after every change.

Each cycle should take minutes, not hours. If you're stuck on red for long, the step was too big: delete the test and write a smaller one. The red–green–refactor note summarises the loop and its three rules.

A TDD session: a ride fare calculator

Requirement: a ride costs a base fare of Rp 5,000 plus Rp 2,500 per kilometre, with a minimum fare of Rp 10,000. During surge, the distance part is multiplied by the surge factor.

Cycle 1 — red. Start with the simplest interesting case:

go
func TestFare_ChargesBasePlusDistance(t *testing.T) {
	got := Fare(4, 1.0)
	if got != 15_000 {
		t.Errorf("Fare(4km) = %d; want 15000", got)
	}
}

It doesn't compile — that counts as red. Green, simplest thing that works:

go
func Fare(km float64, surge float64) int64 {
	return 15_000
}

That looks like cheating, and it's deliberate: it proves the test can pass, and it forces the next test to say what generality we actually need.

Cycle 2 — red. A second distance forces the real formula:

go
func TestFare_ScalesWithDistance(t *testing.T) {
	if got := Fare(10, 1.0); got != 30_000 {
		t.Errorf("Fare(10km) = %d; want 30000", got)
	}
}

Green:

go
func Fare(km float64, surge float64) int64 {
	return 5_000 + int64(km*2_500)
}

Cycle 3 — red. The minimum fare:

go
func TestFare_AppliesMinimum(t *testing.T) {
	if got := Fare(1, 1.0); got != 10_000 {
		t.Errorf("Fare(1km) = %d; want 10000", got)
	}
}

Green: wrap the result in max(..., 10_000). Refactor: three near-identical tests and three magic numbers. Tidy both — tests are code too:

go
const (
	baseFare    int64   = 5_000
	ratePerKm   float64 = 2_500
	minimumFare int64   = 10_000
)

func Fare(km float64, surge float64) int64 {
	return max(baseFare+int64(km*ratePerKm), minimumFare)
}

func TestFare(t *testing.T) {
	tests := []struct {
		name  string
		km    float64
		surge float64
		want  int64
	}{
		{"base plus distance", 4, 1.0, 15_000},
		{"scales with distance", 10, 1.0, 30_000},
		{"applies minimum", 1, 1.0, 10_000},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Fare(tt.km, tt.surge); got != tt.want {
				t.Errorf("Fare(%v, %v) = %d; want %d", tt.km, tt.surge, got, tt.want)
			}
		})
	}
}

Cycle 4 — red. Surge applies to the distance part only. Add a row: {"surge multiplies distance", 4, 1.5, 20_000} (5,000 + 4 × 2,500 × 1.5). It fails with 15,000. Green: baseFare + int64(km*ratePerKm*surge). All four rows pass.

Cycle 5 — what about a surge below 1, or negative distance? Asking "what should happen here?" is the moment TDD turns into requirements discovery. You go back to the product owner, learn that surge is floored at 1.0 and negative distance is an error, write those tests, and change the signature to return an error. Each new rule arrives as a failing test first.

At the end you have a working function, a table of examples that doubles as documentation, and confidence to change it later. You also didn't write any code that no test asked for — TDD's built-in defence against YAGNI violations.

The test pyramid

Not every test should be a unit test, and not every test should go through the UI. The test pyramid describes a healthy mix:

text
        /\        End-to-end — few: real browser or client, full stack.
       /  \       Slow, brittle, but prove the whole thing works.
      /----\
     /      \     Integration — some: real database, real HTTP, real queue.
    /        \    Catch wiring and SQL mistakes.
   /----------\
  /            \  Unit — many: one function or type, in memory, milliseconds.
 /______________\ Pin down logic and edge cases.

The shape comes from cost: unit tests are fast and pinpoint failures; end-to-end tests are slow and fail for many reasons. Most logic should be covered by unit tests, with integration tests proving that the pieces — especially SQL and external APIs — actually fit, and a few end-to-end tests covering critical journeys like sign-up and checkout. An inverted pyramid (mostly end-to-end) gives slow, flaky suites that teams stop trusting. The test pyramid note covers alternatives like the "testing trophy" favoured for frontends.

Test doubles

To unit-test code that depends on a database, clock or payment provider, you substitute a test double. The names are precise, and worth knowing:

DoubleWhat it doesExample
DummyFills a parameter that isn't usednil logger
StubReturns canned answersclock that always returns 09:00
SpyStub that also records callsmailer that stores sent messages
MockPre-programmed with expectations; fails if calls don't match"expect Charge(42, 15000) exactly once"
FakeWorking lightweight implementationin-memory repository backed by a map

In Go, hand-written fakes and spies are usually best — small consumer-side interfaces (from the SOLID lesson) make them a few lines each:

go
type spyNotifier struct{ sent []string }

func (s *spyNotifier) Notify(_ context.Context, userID, msg string) error {
	s.sent = append(s.sent, userID+": "+msg)
	return nil
}

Prefer fakes and stubs that let you assert on outcomes ("the order is saved as paid", "one receipt was sent") over mocks that assert on interactions ("Save was called with these arguments, then Notify"). Interaction-heavy tests break whenever you refactor the internals, even when behaviour is unchanged. The test doubles note has more examples.

What makes a good test

  • Tests behaviour, not implementation. Assert on what the code returns or the state it leaves, through its public API. If renaming a private helper breaks a test, the test is too coupled.
  • One reason to fail. Each test checks one behaviour, and its name says which: TestFare_AppliesMinimum, not TestFare2.
  • Arrange, act, assert. Set up, do one thing, check — visibly separated.
  • Fast, isolated, repeatable. No dependency on test order, the current time, the network, or shared state. A flaky test is worse than none: it teaches people to ignore red.
  • Readable failure messages. Fare(4, 1.5) = 15000; want 20000 tells you what broke without opening the file.

Coverage is a useful flashlight — it shows untested code — but a poor target: 100% coverage with weak assertions proves little, a textbook case of Goodhart's law.

Styles: inside-out and outside-in

There are two broad schools:

  • Inside-out (classicist, "Chicago"): start with the core domain logic, test it with real collaborators where possible, and build outwards. Few mocks, robust tests.
  • Outside-in (mockist, "London"): start from the API or UI with a failing acceptance test, then drive each layer down, mocking the layer below until it exists. Good for discovering interfaces, but risks tests coupled to internal calls.

Most teams mix them: an outside-in acceptance test for a feature, inside-out unit tests for the logic it needs.

When TDD isn't worth it

TDD shines for business logic, parsers, calculations, state machines and bug fixes (write the test that reproduces the bug first — then it can never silently return). It pays less for:

  • Exploratory spikes, where you don't yet know what you're building. Spike, learn, throw it away, then TDD the real thing.
  • Thin glue code that only wires libraries together; an integration test covers it better.
  • Visual layout, where a screenshot or a human eye is the real test.

TDD is also a skill, and slow at first. Practise on small katas until the rhythm is natural — it's also exactly what pairing interviews look for (see pairing and take-home interviews).

Practice

Using TDD, implement func ParseDuration(s string) (time.Duration, error) that accepts strings like "90s", "15m", "2h" and "1h30m" (hours, then minutes, then seconds, each optional but at least one present), and rejects anything else. Don't use time.ParseDuration. Write the tests one at a time, and keep a list of the cycles you went through.

Show solution

A possible sequence of cycles, each starting with one new failing table row:

  1. "90s" → 90s. Green: hard-code.
  2. "15m" → 15m. Green: parse a number and a single unit letter.
  3. "2h" → 2h. Green: add h to the unit table. Refactor: a map[byte]time.Duration.
  4. "1h30m" → 90m. Green: loop over number–unit pairs.
  5. "" → error, "10" → error (no unit), "5d" → error (unknown unit).
  6. "30m1h" → error (wrong order), "1h1h" → error (repeat). Green: track the last unit's rank.
go
type unit struct {
	suffix byte
	size   time.Duration
}

// units in the order they must appear.
var units = []unit{{'h', time.Hour}, {'m', time.Minute}, {'s', time.Second}}

func ParseDuration(s string) (time.Duration, error) {
	if s == "" {
		return 0, errors.New("empty duration")
	}
	var total time.Duration
	next := 0 // units before this index are used up
	for rest := s; rest != ""; {
		i := 0
		for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' {
			i++
		}
		if i == 0 || i == len(rest) {
			return 0, fmt.Errorf("invalid duration %q", s)
		}
		n, err := strconv.Atoi(rest[:i])
		if err != nil {
			return 0, fmt.Errorf("invalid duration %q: %w", s, err)
		}
		u := slices.IndexFunc(units[next:], func(u unit) bool { return u.suffix == rest[i] })
		if u < 0 {
			return 0, fmt.Errorf("invalid duration %q: unexpected unit %q", s, rest[i])
		}
		total += time.Duration(n) * units[next+u].size
		next += u + 1
		rest = rest[i+1:]
	}
	return total, nil
}

Searching only units[next:] handles both wrong order ("30m1h") and repeats ("1h1h") with one rule. The tests are a table of {in, want, wantErr} rows, one per cycle above. The important part is the rhythm: each row was written and seen failing before the code that made it pass.

Key takeaways

  • TDD is red (a failing test), green (the simplest passing code), refactor (clean up with tests green), in cycles of minutes.
  • Its main output is design: small units with explicit dependencies, and no code that no test asked for.
  • Keep a pyramid: many fast unit tests, some integration tests, few end-to-end tests.
  • Know the doubles — dummy, stub, spy, mock, fake — and prefer outcome-based fakes and stubs to interaction-checking mocks.
  • Good tests check behaviour through public APIs, fail for one reason, and are fast and deterministic.
  • Use TDD for logic and bug fixes; spike first when you don't yet know what you're building.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why does TDD insist on watching a new test fail before writing the code to pass it?

  2. 2.In the green step, returning a hard-coded value that makes the single test pass is…

  3. 3.An in-memory UserRepository backed by a map, used in unit tests, is which kind of test double?

  4. 4.After renaming a private helper function, 40 tests fail although the program's behaviour is unchanged. What does this most likely indicate?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.