Book companion · MIT

Learn Go with Tests: summary and chapter guide

MIT

Quotations are used under the MIT license. Read it free.

What is Learn Go with Tests about?AI-assisted

Testing pain diagnoses hard-wired dependencies, not flaws in the TDD loop

How do you grow trustworthy Go software test-first? That one question drives the whole book. It opens with deliberately trivial exercises — Hello, Add, Repeat, Sum, Area, Wallet, Dictionary — each teaching one language feature while drilling the same loop: write a failing test, read the compiler, watch it fail for the right reason, write just enough code, refactor. It then climbs through goroutines, channels, select, context, reflection, generics and property-based testing; builds a full HTTP and CLI poker application chapter by chapter; and closes with acceptance testing, specification/driver separation, scaling TDD and a catalogue of anti-patterns. The through-line is that tests are a design tool, not a verification chore: pain in testing diagnoses hard-wired dependencies, so collaborators are injected behind small standard-library interfaces, failures are returned as inspectable values rather than strings, and abstractions are extracted only once concrete tested behaviour reveals them. Every lesson runs on small Go programs, so claims about large systems are argued rather than measured.

A fable for the whole bookAI-assisted

One story that carries the book’s central idea — and an honest note on where the story breaks.

Standard buckets eliminate the need for secret hatches and special access

Once there was a village whose well stood inside the miller's house, bricked into his hearth wall. Only the miller could draw water, and only by firelight. When the villagers asked for a taste of the water before baking day, the miller said: bake first, taste after. So they baked, and the bread came out bitter, and the miller swore the water was sweet, and nobody could prove otherwise without tearing down a wall. One spring a young cooper named Isa said: before I bake anything, I want a cup. She tried to fetch one and found she could not — not because the water was bad, but because the well had been built where no bucket could reach. Her neighbours called this a failure of her cup. Isa called it a failure of the wall. She did not cut a secret hatch for tasters only; she said the well should be where anyone with a bucket can stand, and the miller should be handed his water like everyone else. So the wall came down, the well went to the square, and the only thing a drawer of water now needed was a rope-and-bucket of the standard size — the cooper's bucket, the baker's bucket, the child's tin cup all fit the same rim. Isa drew one cup, tasted it, found it brackish, and the village knew before the ovens were lit. Then came Jorun, who hated walking to the square. He kept a painted bucket in his kitchen that he filled each morning from memory of how the well used to taste, and every evening he declared the water sweet. His bread was bitter too, but now his tasting said otherwise — a worse state than the miller's, who at least had admitted nobody could tell. The village settled on a rule: a bucket in the kitchen is allowed only if it is a real bucket holding real water, and only if the same tasting ritual is run against it and against the square's well, and both must agree. And once a year the whole village baked a loaf start to finish and ate it, because no cup of water, however honest, proves the bread.

What the story stands for

In the storyIn the book
Isa demanding a cup before baking, not afterWriting the test first and watching it fail for the right reason, because tests written afterwards go evergreen or assert irrelevant detail
Her failure to reach the water, and her reading it as a fault of the wall rather than of her cupPain in testing as design feedback about hard-wired dependencies, global state and too many collaborators — the fix belongs in production design
Refusing the secret taster's hatch and moving the well to the square insteadRejecting test-only modes and layers invented for tests in favour of changing the real structure
The standard rim that the cooper's, baker's and child's vessels all fitThe small standard-library interface as a seam — io.Reader, io.Writer, a store — so one body of logic serves a CLI, an HTTP server and a browser
Handing the miller his water from outside like everyone elseDependency injection: collaborators passed in rather than constructed inside or reached for as globals
Jorun's painted bucket filled from memory, declaring sweetness while the bread stays bitterMocks, stubs and spies as scattered unvalidated assumptions that let tests pass while the software is broken
The rule that a kitchen bucket must hold real water and pass the same ritual as the wellA stateful fake trusted because one reusable contract suite runs against both it and the real dependency
The yearly loaf baked and eaten wholeAcceptance tests driving the system from outside — confidence unit tests cannot give, costly enough to complement rather than replace them

Where the fable breaks

The fable distorts by making the redesign a single irreversible act of masonry with an obvious villain. The book's version is iterative and cheap: the wall comes down a brick at a time, in thin end-to-end slices that get out of red fast — hard-coding a return value if need be — with the standard rim extracted only after concrete tested behaviour reveals the pattern, never carved up front. It also cannot hold the parts that live in types and tools rather than in moral geometry: errors as inspectable sentinel or typed values checked with errors.Is/As, generic and constrained types that make a bad bucket unrepresentable at compile time, benchmarks that turn speed claims into measurements, and the whole concurrency register where mutexes, channels and context do the serialising and sleeping is never synchronisation. Nor can it carry the book's own unresolved tensions — that the poker chapters build the HTTP server with exactly the stub and spy the fable condemns in Jorun, that no rule says when hard-coded slime must go, and that splitting specification from driver adds the very indirection the refactoring advice warns against. Finally, every lesson behind this story was demonstrated on deliberately trivial programs, so the village's confidence that one rim fits all vessels is argued rather than measured at scale.

Learn Go with Tests: the core arguments

10 theses the book defends, in the order the argument needs them.

  1. Because software in real use must keep changing and accrues complexity, unit tests are the precondition for continuous behaviour-preserving refactoring — so having no time to refactor is a signal it is overdue, not a reason to skip it.

    “Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

    Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →
  2. Tests must assert externally visible behaviour through a unit's exported API; coupling them to implementation detail or raw output strings produces misleading failures, brittle suites and obstructed redesign.
  3. A test must be written first and watched failing for the right reason, because tests written afterwards tend to be evergreen or to assert irrelevant detail, and example-based cases systematically miss domain truths that stated properties expose.
  4. Pain in testing is design feedback about hard-wired dependencies, global state or too many collaborators — TDD is conceptually simple, and what people blame on it is the difficulty of designing well that the loop merely exposes.
  5. Dependencies should be injected behind small interfaces the code already touches — io.Reader, io.Writer, a service or store — rather than hard-wired or wrapped in layers invented for tests, because the same seam that makes code testable also lets one body of domain logic back a CLI, an HTTP server and a browser.
  6. Code should be grown in thin end-to-end slices that get out of red as fast as possible — hard-coding if need be — with abstractions extracted only after concrete tested behaviour reveals the pattern, never invented up front.
  7. Mocks, stubs and spies are a liability because they scatter unvalidated assumptions that let tests pass while the software is broken, whereas stateful fakes checked by a contract run against the real dependency give integration-level confidence at unit-test speed.
  8. Acceptance tests driving the whole system from outside give confidence unit tests cannot, but cost enough that they must complement rather than replace them — and they stay reusable only when the specification is separated from the driver, so behaviour changes start in the specification and follow compiler errors downward.
  9. Correctness belongs in types and returned values rather than in strings and runtime luck: errors should be sentinel or typed values inspected with errors.Is/As, invalid inputs should be excluded by constrained or generic types, and the compiler should be used as a collaborator that moves failures to compile time.
  10. Concurrency and optimisation must be driven by evidence rather than hope: shared mutable state needs explicit serialisation and sleeping is never synchronisation, while speed claims are only legitimate once the code works, has been refactored, and a benchmark measures the gain.

Chapter by chapter

Every chapter is on this page: how the argument unfolds, its key claims, common misreadings and key concepts. Pick one from the list.

Meta: Why unit tests and how to make them work for you

How do you tell a unit's real boundary from an accidental one?

How Meta unfoldsAI-assisted

The chapter opens with a puzzle framed around the word "software" itself: if code is supposed to be malleable, why do so many projects collapse into legacy systems that must be rewritten? The author credits Go with several properties that help — a tiny keyword set, strong tooling for testing and benchmarking, a rich standard library, fast compilation, and the backward-compatibility promise (contrasted with a painful Scala upgrade) — but insists that no language prevents bad systems. That sets up the historical anchor: Manny Lehman's 1974 laws of software evolution. Two are singled out. The Law of Continuous Change says software must change or grow useless, which the author uses to criticize teams incentivized to ship by a date and hand off, and to argue that even excellent engineers can't foresee future needs. The Law of Increasing Complexity says complexity grows unless work is done to reduce it, warning against treating teams as "blind feature factories."

From there the chapter narrows to its chosen remedy. Among many practices that keep software soft — good separation of concerns, architecture, observability, deployability, tests, feedback loops — the author picks refactoring, noting that the word is widely misused, and quoting Martin Fowler to the effect that a system broken for days is not being refactored. The meaning is recovered through a schoolroom analogy: adding 1/2 and 1/4 by re-expressing 1/2 as 2/4 changes the form but not the value. Refactoring likewise reshapes code to fit your current understanding of the domain without changing behavior. A Go example makes it concrete: a Hello function stacked with per-language if branches becomes a lookup map plus a small greeting helper with a default. The author stresses that the particular rewrite doesn't matter — the one rule is that behavior is untouched, because changing behavior while refactoring means doing two hard things at once, which is where refactoring efforts fail. Since you can't verify equivalence in your head on a non-trivial system, and manual testing doesn't scale, unit tests supply the missing check: confidence, documentation of intended behavior, and fast feedback. A short TestHello illustrates the rhythm of small refactor, run tests, repeat — subsecond feedback as a safety net against Lehman's complexity.

The turn comes when the author confronts the counter-experience: many people find unit tests obstruct refactoring, which is the opposite of the promise. The diagnostic question is how often tests must change during a refactor. The illustration is a square built from two triangles, with tests asserting triangle properties; when a later developer rebuilds the square from rectangles, those tests fail misleadingly, even though the internal shapes were never the point. The lesson is that such tests falsely elevate implementation details. The prescription is to test behavior rather than implementation — the complaints usually trace to tests at the wrong abstraction level, over-spying on collaborators, excessive mocking, and chasing test coverage as a vanity metric. Pure black-box testing isn't the answer either: valuable for key journeys, but expensive, slow, and poor at pinpointing causes, so unhelpful for refactoring.

The resolution reframes testing as a design question. Aim for self-contained, decoupled units built around domain concepts — Lego bricks with coherent APIs, like an "account" package in a banking system, hiding whatever collection of types and functions makes them work. Test those public APIs and you are necessarily testing useful behavior, leaving the insides free to change. And yes, the author affirms, these count as unit tests; "unit" never meant a single function or class.

The closing section braids the three strands together: refactoring signals whether your tests sit at the right level (manual checks mean too few tests; wrongly failing tests mean wrong abstraction or no value) and manages complexity; unit tests provide the safety net and the documentation; well-designed units make both testing and refactoring easy. This leads into the case for TDD. Rather than the "bad old days" of months of requirements and design documents preceding failure — which, the author notes, still happens — Agile and TDD enforce iterative work with fast feedback: write a small test for a small behavior, watch it fail clearly, write the minimal code to pass, refactor, repeat. With practice this loop feels natural, and sitting outside "green" starts to feel like a warning sign. The wrap-up restates the thesis: change is inevitable but unpredictable, so don't over-engineer; keep software malleable through continuous refactoring, backed by a good test suite and meaningful, composable units, with TDD as the discipline that drives all three.

Key claims, with the lines that support them

Claim 1

A modern language with good tooling does not by itself protect a system from decaying into legacy.

The author lists what makes Go promising — a small keyword set, a strong standard library, fast compilation, first-class test and lint tooling, and a backward-compatibility promise he contrasts with weeks lost upgrading a Scala project — then concedes terrible systems can still be built with all of it, which is why he turns to Lehman's 1974 laws: software in real use must change or lose its value, and its complexity keeps rising unless effort goes into reducing it.

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →
Claim 2

Refactoring means improving the shape of code while leaving its behaviour exactly as it was; changing behaviour at the same time is a different, riskier job.

He borrows school arithmetic: rewriting 1/2 + 1/4 as 2/4 + 1/4 gives the same 3/4 but in a form easier to work with. His Go example does the same to code — a pile of per-language if statements becomes a greetings map plus a lookup helper — with the point being not the technique but that the output is unchanged. He cites Fowler's test too: if a system is broken for days, that work was not refactoring. Doing two things at once is where teams bite off more than they can chew.

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →
Claim 3

Because manual verification does not scale, unit tests are what make refactoring on a non-trivial system safe.

Teams without tests fall back on manual checking, a time sink that stops working past small projects. Tests instead supply three things: confidence to reshape code, documentation of intended behaviour for humans, and feedback far faster and more reliable than hand-checking. Concretely, a single test on the greeting function plus go test tells him in moments whether a change altered behaviour, which is what sustains the small-refactor / run-tests / repeat loop and keeps complexity in check.

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →
3 more claims
Claim 4

Tests should be aimed at behaviour visible through a component's public API rather than at how it is implemented inside.

Imagine a square built by joining two triangles, with tests asserting the triangles' angles sum to 180 degrees. Weeks later a developer decides two rectangles would be better and is met with failing tests that give no answer to the only question that matters — did real behaviour break? The triangle construction was never important, yet the tests had promoted it to the status of a requirement. The author traces this to misunderstanding what a unit is and to chasing coverage as a vanity metric.

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detailRead in context →
Claim 5

Writing useful unit tests is really a design question about how the system is divided up.

He asks the reader to forget tests for a moment: you already want decoupled components centred on domain concepts, which he pictures as Lego bricks with coherent APIs that combine into larger systems while dozens of types and functions collaborate out of sight. A bank's "account" package is his example of an API that leaks no internals and is easy to integrate with. Get those boundaries right and behaviour-level tests come almost for free — which is his answer to the problem the square-and-triangles story exposed.

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detailRead in context →
Claim 6

TDD is the working process that keeps this refactoring loop running, rather than trying to design the perfect extensible system in advance.

Taking Lehman's point that software must change, some people over-correct into elaborate upfront design — the era he describes of six months of requirements documents, six more of architecture, and a failed project years later, something he notes still happens. Since the future cannot be predicted, he follows Agile in working iteratively, and TDD enforces it: a small test for a small behaviour, a clear red failure, the minimum code to go green, then refactor, repeat. Practitioners come to expect that loop to be quick and feel uneasy whenever the system is not green, which signals a rabbit hole.

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →

How it works

The 3 mechanisms the chapter’s argument runs on.

Mechanism 1

Tests as a refactoring safety net

Reshaping code only counts as refactoring if behaviour stays identical, but on any sizeable system a person cannot hold all the behaviours in their head. Fast unit tests check that invariant automatically, so the author can take one small step, re-run them in under a second, and catch a behaviour change the moment it happens instead of hours later.

Mechanism 2

Why detail-bound tests block change

When tests assert how something is built rather than what it does, the build choice becomes frozen. A later developer who picks a different internal construction sees a wall of failures that say nothing about whether real behaviour broke, so they must excavate old tests first — and eventually stop attempting the change at all.

Mechanism 3

Unit boundaries decide test durability

If the system is carved into self-contained components built around domain concepts, each exposing a coherent public API that hides its internals, tests written against that API can only be asserting behaviour worth keeping. Everything beneath the API stays free to be rearranged, so refactoring rarely touches the tests.

Common misreadings

You might think

If the goal is to test behaviour rather than internals, the honest answer is to skip unit tests and rely on end-to-end or black-box tests.

Actually

System-level tests are genuinely valuable for verifying key user journeys, but the author rules them out as the refactoring safety net: they are expensive to write, slow to run, and poor at pointing to root causes, so their feedback loop is too slow to support small steps. Behaviour testing at the right altitude means testing through a unit's public API, which is both behaviour-focused and fast.

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detailRead in context →
You might think

A unit test is by definition a test of one function, class or method, so testing a whole package through its API must be something else — an integration test.

Actually

The author answers this directly: these are unit tests, because the "unit" is the self-contained, domain-centred component with a coherent public API, not a single function or class. Many types and functions may cooperate beneath that API; treating the smallest code element as the unit is exactly what pushes tests down into implementation detail and makes them obstruct refactoring.

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detailRead in context →

Key concepts in Meta

Refactoring
Reshaping code so it is easier to understand while leaving behaviour untouched, like factorising a fraction.
Where it applies: If the system is broken for days, or behaviour shifts, it is not refactoring.
Unit
A self-contained, decoupled piece of the system built around a key domain concept, exposing a coherent public API.
Where it applies: A unit is not a single class or function; many types may collaborate beneath its API.
Lehman's laws of software evolution
Software in real use must change or lose value, and its complexity grows unless work is spent reducing it.
Where it applies: They justify iterative change, not months of upfront over-engineering.
0 more concepts

Reader’s notes on MetaAI reading companion

Why unit tests and how to make them work for you › Software

“Go has only 25 keywords”

Read in context →

The implicit claim — fewer keywords means more durable code — never gets argued. Plenty of unmaintainable C exists, and C is small too. Constraint helps, but legacy comes from accumulated decisions, not vocabulary size.

Why unit tests and how to make them work for you › The Law of Increasing Complexity

“its complexity increases unless work is done to reduce it”

Read in context →

The quiet word is "work". Complexity reduction is labor nobody books a ticket for, which is exactly why it loses to features. The law is less about software than about incentives.

Why unit tests and how to make them work for you › Refactoring

“you can be pretty sure they are not refactoring”

Read in context →

Fowler's line works as a diagnostic because it's falsifiable: if the build is red for two days, the name is wrong. Rare to get a definition that comes with a test attached.

8 more notes
Why unit tests and how to make them work for you › Factorisation

“by changing 1/2 to 2/4 it fits into our "domain" easier”

Read in context →

The analogy is charming but it flatters refactoring. Arithmetic has a decision procedure for equivalence; code doesn't. Tests are a sampling of behaviour, not a proof of it — the metaphor hides exactly where the risk lives.

Why unit tests and how to make them work for you › An example in Go

“The nature of this refactor isn't actually important”

Read in context →

Worth noticing that the example did change behaviour slightly in spirit: a map makes languages data, which invites runtime configuration the if-chain never allowed. Structure is a claim about the future.

Why unit tests and how to make them work for you › An example in Go

“got := Hello(“Chris”, es)”

Read in context →

Smart quotes and a bare es — this snippet wouldn't compile. In a book whose whole argument rests on tight, trustworthy feedback loops, an untested code sample is a small, funny indictment.

Why unit tests and how to make them work for you › Why is this happening?

“our tests have falsely elevated the importance of our implementation details”

Read in context →

This is the chapter's real thesis, and the triangles-to-rectangles parable earns it. Every failing test is a claim that something mattered; write enough careless claims and your suite becomes a monument to decisions you no longer hold.

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail

“chasing vanity metrics (test coverage)”

Read in context →

Fair, but coverage became a vanity metric because "test behaviour, not implementation" can't be measured and coverage can. The critique needs an alternative signal, and the chapter doesn't supply one.

Why unit tests and how to make them work for you › Are these unit tests?

“They were never about only being against a single class/function/whatever”

Read in context →

A quietly radical move: redefining "unit" as a package-level API dissolves the mock-everything school by fiat. Persuasive, but it means two engineers can both say "we write unit tests" and mean incompatible things.

Why unit tests and how to make them work for you › Refactoring

“If tests are wrongly failing then our tests are at the wrong abstraction level”

Read in context →

This is unfalsifiable as stated — any painful test can be blamed on abstraction level rather than on the refactor being genuinely risky. A useful heuristic dressed as a verdict.

Why unit tests and how to make them work for you › Wrapping up

“don't try and over-engineer because it's too hard to predict the future”

Read in context →

The honest tension of the whole chapter: designing good "units" is itself a prediction about which concepts will stay stable. TDD doesn't remove that bet, it just makes it cheaper to lose.

A reader’s takeAI reading companion

Unit redefined as package-level API is radical, not mere clarification

The chapter's architecture is stronger than any of its individual arguments: Lehman's laws establish that change is compulsory, refactoring is named as the response, tests are introduced as the only way to refactor without lying to yourself, and unit design is revealed as the thing that determines whether tests help or obstruct. That chain — and especially the final move, that writing good unit tests is a design problem rather than a testing problem — is the most valuable idea here, and the triangles/rectangles parable delivers it in a form you can actually carry into a code review. Where it's weakest is where it's most confident.

The factorisation analogy quietly borrows mathematics' certainty about equivalence, which tests cannot provide. The claim that a suite failing during refactor proves the tests were at the wrong abstraction level is circular enough to excuse any refactor. The attack on coverage is correct but leaves the reader without a replacement signal, which is precisely why coverage won in the first place. And redefining "unit" as a package-level API is a real contribution that the chapter presents as a mere clarification, when it's actually a contested position. The accidental comedy — a non-compiling test snippet in the chapter arguing for trustworthy feedback loops — is a decent reminder that the discipline is harder than the sermon.

Continue reading

Read it as a persuasive framing rather than a proof, and it's genuinely useful.

Read Meta with these cards beside each passage.

Open in FeedLitra

Where the argument strains

Not a rebuttal — the places a careful reader will want to push.

  1. Stubs and spies are condemned as carriers of unvalidated assumptions, yet the application chapters build the HTTP server with exactly a stub store and a spy to break the GET/POST deadlock.

    “If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”

    Working without mocks, stubs and spies › The problem with stubs and mocksRead in context →
  2. Refactoring is defined as change that never touches tests, yet the discovery-driven TDD the book teaches repeatedly reshapes a design's seams and therefore its tests.

    “You're doing something else if you are "refactoring" some code and having to change tests at the same time.”

    Refactoring step, starting checklist › Refactoring vs other activitiesRead in context →
  3. Hard-coding or sliming to escape red is endorsed while crude implementations are warned to become unmaintainable, and no rule is given for when the slime must go.

    “I know it feels weird just to hard-code the result but with TDD we want to stay out of "red" for as long as possible.”

    Roman Numerals › RefactorRead in context →
  4. Tests are simultaneously the enabler of safe change and a cost to minimise, and acceptance tests are both the north star and expensive and design-coupled, so the right quantity of each is left unresolved.

    “For this reason, it is foolish to only rely on acceptance tests. They do not have many of the qualities unit tests have, and a system with a large number of acceptance tests will tend to suffer in terms of maintenance costs and poor lead time.”

    Introduction to acceptance testing › Potential drawbacks vs unit testsRead in context →
4 more tensions and 8 limits of the evidence
  1. Testing only the public API is urged, yet compiler errors and implementation-level choices such as pointer receivers or strings.Builder repeatedly drive the next test, blurring where the behavioural boundary lies.

    “Favour testing behaviour rather than implementation detail”

    Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detailRead in context →
  2. Reflection and interface{} are taught in detail and then recommended against, and channels, mutexes and atomics are each offered as the fix for shared state with no decision rule beyond single-value versus multi-field.

    “As a user of a function that takes interface{} you lose type safety.”

    Reflection › So why not use interface{} for everything and have really flexible functions?Read in context →
  3. Disciplined minimal TDD let a plausible but wrong cancellation implementation pass until an extra assertion was added, showing the loop does not by itself guarantee a complete suite.

    “By being disciplined it highlighted a flaw in our tests and this is a good thing!”

    Context › Write enough code to make it passRead in context →
  4. Separating specifications from drivers and extracting shared abstractions adds indirection, which sits uneasily with the refactoring advice that clarity comes from removing structure rather than adding it.

    “Decoupling how the specification is executed allows us to reuse it in different scenarios.”

    Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Testing on steroidsRead in context →

Limits of the evidence

  1. Every lesson is demonstrated on deliberately trivial exercises — Hello, Sum, Wallet, Dictionary, a clock face, one small poker app — so claims about large systems or team constraints rest on argument rather than demonstration.

    “Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

    Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviourRead in context →
  2. Performance figures come from the author's single machine, a 20ms sleeping stub rather than live HTTP calls, and one template benchmark the author concedes is usually negligible.

    “Strings in Go are immutable, meaning every concatenation, such as in our Repeat function, involves copying memory to accommodate the new string.”

    Iteration › BenchmarkingRead in context →
  3. The arguments that test doubles and bad design cause bad tests rest on anecdote — one six-API story, a database-pool illustration, reader snippets explicitly described as not a survey.

    “If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”

    Working without mocks, stubs and spies › The problem with stubs and mocksRead in context →
  4. Key concurrency evidence is nondeterministic: the concurrent-map-writes failure is intermittent, and the synctest findings depend on specific runtime scheduling that may not generalise.

    “Go maps are not safe for concurrent writes, so the runtime throws a fatal error to prevent memory corruption.”

    Concurrency › ... and we're back.Read in context →
  5. Several taught solutions are left acknowledged as rough: prefix-trimming routing conceded fragile, a timing sleep in the WebSocket test, and binaries run from different directories silently creating two database files.

    “This test has forced us to actually look at the request's URL and make a decision.”

    HTTP Server › Write enough code to make it passRead in context →
  6. Guarantees are partial or circumstantial: Testable Examples only execute with an Output comment, exact float comparison is safe only because the chosen values are whole numbers, and unchecked errors are caught only by the external errcheck linter.

    “Often code examples that can be found outside the codebase, such as a readme file, become out of date and incorrect compared to the actual code because they don't get checked.”

    Integers › Testable ExamplesRead in context →
  7. All guidance is bound to Go's specific surface and toolchain versions — modules, io.Reader, context, errors.As, httptest, editor shortcut lists — leaving transfer to other languages untested.

    “Using Modules is pretty straightforward. Select any directory outside GOPATH as the root of your project, and create a new module with the go mod init command.”

    Install Go, set up environment for productivity › Go ModulesRead in context →
  8. Notable conclusions are judgement calls rather than demonstrated results: that sites need no SPA, that the HTML was not worth testing, that a test folder is the wrong trade, and that context goes first because Google does it.

    “Many websites do not need to be an SPA. HTML and CSS are fantastic ways of delivering content and you can use Go to make a website to deliver HTML.”

    HTML TemplatesRead in context →

Mind map of the whole book

5 clusters of ideas: a plain-English label, a one-line explanation, and the term the text itself uses.

1 · Fail first, pass cheap, tidy up
Write a failing test, pass it crudely, then improve it
Write a failing test, do the least work to pass it — even hard-coding or 'sliming' a return value — then refactor; grown outside-in from a steel thread or acceptance test and kept red for as little time as possible.In the text: TDD cycle
Reshape the code's insides while the tests keep passing
Reshaping code for clarity with behaviour unchanged, so tests ideally need no edits; larger, partly subjective structural change is 'big design' and must itself be driven in small test-backed steps.In the text: Refactoring
One test loops over a list of named cases
One test body looping with subtests over a slice of named input/expected cases, so new scenarios cost almost no boilerplate — but degrading when unrelated cases are forced in as extra flags.In the text: Table driven tests
Check a rule holds for many inputs, not a few picks
Tests that state a domain truth — the Roman subtractor rule, the unit circle — and let a library throw many generated inputs at it, with your own generator keeping those inputs inside the domain.In the text: Property-based tests
2 · Test from outside, pass deps in
If a test is painful to write, the design is off
Difficulty writing a test is read as evidence of poor separation of concerns or awkward ergonomics for callers, so the fix belongs in the production design rather than in test-only modes.In the text: Testability as a design signal
Name only the methods a caller needs, so parts swap out
A named set of just the methods a caller needs, satisfied implicitly, so a dependency can be substituted; keep it small, and invent one only when something else will really be passed.In the text: Interface as a seam
Hand collaborators in from outside instead of building them
Passing collaborators in from outside instead of constructing them inside or reaching for globals, so tests supply fakes while production supplies the real thing.In the text: Dependency injection
Take the standard stream types so any source plugs in
The standard one-method IO abstractions; because files, os.Stdout, http.ResponseWriter, WebSocket connections and strings.NewReader all satisfy them, one body of code serves tests and every front end.In the text: io.Writer and io.Reader
3 · Fakes and whole-system tests
Scripted stand-ins that can lie about the real thing
Any controllable stand-in for a real dependency, from a dummy filling a parameter slot to a spy that records calls, durations and ordering so otherwise invisible behaviour can be asserted.In the text: Test double (mock, stub, spy)
A simple working stand-in, kept honest by a shared test
A genuine, stateful, usually in-memory implementation of a dependency, trusted because one reusable test suite written against the interface runs against both it and the real thing.In the text: Fake verified by a contract
Boot the whole thing and drive it like a real user
A black-box test that boots and drives the whole system from the outside as a user or business would, passing only when the user-visible feature works end to end.In the text: Acceptance test
Keep the rules separate from how you poke the system
Splitting essential domain complexity into an implementation-free specification from accidental complexity — networks, transports, APIs — handled by a driver that adapts it to a particular system.In the text: Specification and driver
4 · Put correctness in the types
Return failures as values you can inspect and match
Failure signalled by returned values — sentinel errors compared with errors.Is, structs carrying data inspected with errors.As, wrapped with %w to add context without losing the cause.In the text: Error as an inspectable value
Shape types so bad values can't be built at all
Structs, names over existing types carrying their own methods, and generic type parameters, so values outside the domain cannot be expressed — the opposite of interface{}'s runtime-only checking.In the text: Types that exclude invalid values
Lean on Go's shipped packages instead of your own
The shipped packages — ServeMux, httptest, JSON, html/template with go:embed, time, os/exec — presented as sufficient for both the application and its tests, needing no framework.In the text: Go standard library
5 · Concurrency and speed need proof
Time the code before and after to prove it got faster
A function whose loop the framework times, used to measure before and after a change so performance claims become measurements rather than assertions.In the text: Benchmark
Run work separately and pass results over channels
Separate processes started with go, often anonymous functions, communicating by sending and receiving values; select waits on several channels and runs whichever is ready first.In the text: Goroutines and channels
Two threads touching one value need a lock
Concurrent conflicting access to shared memory, found with the -race detector and fixed by serialising it: a mutex for multi-step invariants, atomics for one value, a WaitGroup to await completion.In the text: Data race and synchronisation
Pass a cancel signal down so work can be stopped
A value threaded down a call chain that signals cancellation to everything derived from it and carries the reason, with time.After giving a testable timeout.In the text: Context

Annotated passagesAI reading companion

Lines worth stopping on, each with a note on why it matters to the whole book.

Go fundamentals · Hello, World › How to test

“It is good to separate your "domain" code from the outside world \(side-effects”

Read in context →

Read at book scale, this aside is the architectural thesis every later chapter re-derives: the seam between domain and side effects exists because of testability, not elegance.

Questions and answers · Revisiting HTTP Handlers

“If your tests are causing you pain, listen to that signal and think about the design of your code.”

Read in context →

The single sentence the whole book is an argument for — test pain is a design signal, which retroactively explains DI, mocking, context and the HTTP chapters as one idea.

Testing fundamentals · Refactoring step, starting checklist › Refactoring vs other activities

“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”

Read in context →

Gives the book's most-used word a falsifiable definition, turning 'refactor' from a mood into a claim the test suite can adjudicate.

9 more notes
Go fundamentals · Mocking › Still some problems

“just break it! (make sure you have committed your changes to source control first though)”

Read in context →

The epistemic floor of the entire method: green means nothing until you have seen the test fail for the right reason — the move that separates TDD from test-after theater.

Go fundamentals · Context › Write enough code to make it pass

“By being disciplined it highlighted a flaw in our tests and this is a good thing!”

Read in context →

Names the book's quiet reversal: tests are specification, not verification, which is why so many chapters find missing constraints rather than bugs.

Go fundamentals · Revisiting arrays and slices with generics › A generic refactor

“A mistake people often make with shiny new language features is they start by using them without having a concrete use-case.”

Read in context →

The strongest discipline claim in the book — abstraction is only safe when extracted from a concrete case, which indicts premature generics, premature interfaces, and premature packages alike.

Build an application · HTTP Server › Red, green, refactor

“Make the test work quickly, committing whatever sins necessary in process.”

Read in context →

Licenses the 'commit sins' half of TDD that the whole application section runs on, and is exactly the half teams quote while skipping the non-negotiable refactor.

Go fundamentals · Dependency Injection

“What we need to do is to be able to inject \(which is just a fancy word for pass in\) the dependency of printing.”

Read in context →

Deflates the book's most-feared enterprise concept into function arguments, and that deflation is what makes every later testable design look obvious in hindsight.

Build an application · WebSockets › How do we test we return the correct markup?

“So in the book "Learn Go with Tests" for the first time, we're not going to write a test.”

Read in context →

A TDD book marking where TDD stops paying is the credibility purchase for all forty other chapters — the method is presented as judgment, not creed.

Go fundamentals · Reading files › Iterative, test-driven development

“Optimism is an occupational hazard of programming. Feedback is the treatment.”

Read in context →

The cleanest statement of why the book's loop exists at all: TDD is treatment for a character flaw in programmers, not a quality process bolted onto code.

Testing fundamentals · Working without mocks, stubs and spies › tl;dr

“These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness”

Read in context →

Reframes the mocking chapters in retrospect — the hazard was never test doubles but unvalidated assumptions duplicated across a suite.

Meta · TDD Anti-patterns

“Don't mistake this for TDD being hard, it's design that's hard!”

Read in context →

Worth keeping as the book's self-sealing move: reassigning every TDD failure to design skill makes the method hard to falsify even where it is mostly right.

A reader’s verdictAI reading companion

Pretends to teach Go while stress testing a single design claim

Read end to end, this book is not really a Go tutorial and barely pretends to be one by the halfway mark. Its actual subject is a single claim, stated in the “Why unit tests and how to make them work for you” chapter and then stress-tested for forty chapters: software must change, change requires refactoring, refactoring requires tests, and therefore the quality of your tests is the quality of your design. Everything else — syntax, goroutines, templates, generics, WebSockets — is a series of excuses to apply pressure to that claim and see whether it holds. The spine runs in three movements. First, the primer chapters (5–24), where trivial problems are deliberately chosen so that the loop itself is the lesson: Hello World exists to make you watch failures and even sabotage t.

Helper() to see line numbers degrade; Integers lets return 4 stand as a legitimate objection rather than waving it away; Iteration smuggles in benchmarks as if measurement were ordinary; Pointers and Maps hand you a contradiction (two addresses, a nil-write panic) instead of a rule to memorise. Second, the design chapters (12–18, 25–28, 41), where the book stops teaching Go and starts arguing: Dependency Injection and Mocking reframe test pain as design feedback; Context stages a two-act betrayal in which a passing, faithfully TDD'd server is thrown away for being unidiomatic; the “Working without mocks, stubs and spies” chapter indicts the entire stub-and-spy tradition for scattering unvalidated assumptions, and answers with stateful fakes disciplined by contracts run against both fake and real; the “Scaling Acceptance Tests (and light intro to gRPC)” chapter splits specification from transport so convincingly that a whole new protocol lands without touching a test.

Continue reading

Third, the application chapters (29–40), where the method is made to survive a real codebase — a scoreboard server discovered rather than planned, persistence arriving as a sequence of concessions rather than a design, a CLI whose real subject is package boundaries, and a poker game whose dependency bloat is read as a signal that a Game domain type wants to exist. The sharpest judgements are the ones that cut against the reader's instincts. Untestable code is not code that needs a test flag; it is badly designed code (37, 40). A passing test suite is not evidence of good design (18, 35, where two habitual -race runs undo tests that looked finished).

Abstraction belongs in the refactor step, discovered from code you already wrote, not designed up front (21, 24) — and should be reverted without shame when it fails to earn its keep (16). Reflection is taught thoroughly and then withdrawn as a caution, with the author admitting the implementation still crashes on pointer cycles. Property-based tests are shown to surface truths examples missed, then immediately corrected: guarding inside the assertion hides the mismatch, you must narrow generation instead (19). Templates should stay logic-less, because logic inside them can only be tested by comparing rendered HTML (22).

Refactoring is defined by one mechanical test — did your tests have to change? — and the book then follows its own rule, refactoring production code first, then tests, never both at once (28, 34). The honesty is the pedagogy: acceptance tests get a candid ledger of drawbacks (25), the author confesses he didn't believe his own passing library, the "maths is relevant today!" pitch is never actually cashed, and the “Build an application” chapter's fictional product owner is admitted as a convenience. What I'd say to a reader: do not skim the small chapters and do not read this for Go. The toy problems are where the loop becomes muscle memory, and the later chapters are unreadable as mere tutorials — the “Working without mocks, stubs and spies” chapter only lands if you have felt the mocking chapter's unease, and the “Scaling Acceptance Tests (and light intro to gRPC)” chapter only lands if you have watched tests couple to implementation and rot.

Type the code, let the compiler talk, keep the steps smaller than feels dignified, and treat every awkward test as a message about your design rather than a chore to finish. The book's final gift, delivered in the “TDD Anti-patterns” chapter, is permission to stop numbing that pain: tests are not a coverage obligation, they are the cheapest honest feedback you will ever get about the thing you are actually building.

About Chris James

Go TDD author and engineering manager based in London

  • Engineering Manager, per his DEV Community profile, based in London2
  • Guest on six Go Time podcast episodes4
  • Learn Go with Tests (this book)1
  1. Book Review: Learn Go with Tests by Chris James - DEV Community
  2. Chris James — DEV Community Profile
  3. Podcast episodes featuring Chris James

What reviewers say

Reviewers in the excerpts praise this free, open-source book for teaching Go interactively through tests: fast feedback, running examples instead of abstract words, coverage from types and pointers to context and concurrency, plus web and CLI applications. Several frame it as a TDD and software-design book written in Go, recommended for newcomers. One complaint: later chapters make snippet placement unclear.

A synthesis of published reviews. Ratings are not shown; follow the links for each review in full.

  1. Learn Go with Tests - Book Review - DEV Community
  2. [Book Review] Learn Go with Tests by Chris James - DEV Community
  3. learn go with tests - Simon Willison
  4. Learning Go - DEV Community

Sources and license

MITThe text

  • Learn Go with Tests by Chris James (quii). Copyright (c) 2025 Christopher James. Released under the MIT License (https://github.com/quii/learn-go-with-tests/blob/main/LICENSE.md). Read it free · License
  • Summaries, claims, notes and essays are AI-assisted: generated by FeedLitra from the full text, with quotations checked against the source. Last updated October 22, 2026.

Read Learn Go with Tests with these cards beside the text.

Open in FeedLitra