Test Driven Development in Go and Whether It Is Worth It
Walk through test driven development in Go with real output: a compile failure, a red test, the cheapest green, a second case that forces real code.

Test driven development has a reputation for being either essential or a waste, and both camps are describing a method they have not run on something small enough to see. The method is short. You write a test for a behaviour that does not exist yet and watch it fail. You write the smallest code that makes it pass. You improve the code while the tests stay green. Then you do it again for the next behaviour.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article runs the loop on one function on Go 1.25.6, with the real output at each step, and then asks when the method earns its time.
The loop, with output
F='s/\t[0-9.]+s$//; s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #'
cat > slugify_test.go <<'EOF'
package slug
import "testing"
func TestSlugify(t *testing.T) {
cases := map[string]string{"Hello World": "hello-world"}
for in, want := range cases {
if got := Slugify(in); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
EOF
echo "# red: the test does not even compile"
go test -count=1 . 2>&1 | head -3 | sed -E "$F"
cat > slugify.go <<'EOF'
package slug
func Slugify(s string) string { return "" }
EOF
echo; echo "# red: it compiles and fails for the right reason"
go test -count=1 . 2>&1 | grep -E 'Slugify|FAIL' | head -2 | sed -E "$F"
cat > slugify.go <<'EOF'
package slug
func Slugify(s string) string { return "hello-world" }
EOF
echo; echo "# green, with the cheapest code"
go test -count=1 . 2>&1 | sed -E "$F"
cat > slugify_test.go <<'EOF'
package slug
import "testing"
func TestSlugify(t *testing.T) {
cases := map[string]string{
"Hello World": "hello-world",
" Go Tests ": "go-tests",
"Already-Slug": "already-slug",
"Ünïcode Café": "ünïcode-café",
"punct, here!": "punct-here",
}
for in, want := range cases {
if got := Slugify(in); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
EOF
echo; echo "# red again: a second case forces the real code"
go test -count=1 . 2>&1 | grep -E 'Slugify\(' | sort | sed -E "$F"
cat > slugify.go <<'EOF'
package slug
import (
"strings"
"unicode"
)
// Slugify lower-cases s and joins runs of letters and digits with single hyphens.
func Slugify(s string) string {
parts := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-'
})
return strings.Join(parts, "-")
}
EOF
echo; echo "# green again, then refactor with the tests as a net"
go test -count=1 . 2>&1 | sed -E "$F"# red: the test does not even compile
# example.test/lab [example.test/lab.test]
./slugify_test.go:8:13: undefined: Slugify
FAIL example.test/lab [build failed]
# red: it compiles and fails for the right reason
--- FAIL: TestSlugify
Slugify("Hello World") = "", want "hello-world"
# green, with the cheapest code
ok example.test/lab
# red again: a second case forces the real code
Slugify(" Go Tests ") = "hello-world", want "go-tests"
Slugify("Already-Slug") = "hello-world", want "already-slug"
Slugify("punct, here!") = "hello-world", want "punct-here"
Slugify("Ünïcode Café") = "hello-world", want "ünïcode-café"
# green again, then refactor with the tests as a net
ok example.test/labThe script writes the files for each step and runs go test. Read the output in order.
Red, step one: it does not compile. The test calls Slugify, and nothing defines it. Go's report is undefined: Slugify and [build failed]. In Go the first red is often a compile error, because the test is written against an API that does not exist. That is not a failure of the method: it forces you to decide the function's name, parameters and return type, in a test, from a caller's point of view, before any code exists. Many people find that the most valuable thing TDD does.
Red, step two: it fails for the right reason. The script adds the minimal declaration, a function returning the empty string. Now the test compiles and fails with Slugify("Hello World") = "", want "hello-world". This step matters. A test that has never failed has never been shown to be able to fail. Seeing it fail with a message that describes the missing behaviour is the check that the test checks something.
Green with the cheapest code. The script makes the function return the constant "hello-world". The test passes. That looks absurd, and it is on purpose. The code is exactly as good as the test, and the test has one case. The constant is the simplest thing that is correct for what has been specified so far.
Red again: a second case forces the real code. The test grows to five inputs: leading and trailing spaces, an already hyphenated slug, a string with accented letters, punctuation. Four of the five fail, each printing its input and the wrong result, hello-world where go-tests was wanted. The failures say what the code must now do. Notice that I picked the cases from the behaviour I wanted. The accented one is a decision, that letters in any script are kept and lower-cased, and writing it down in a test is how the decision gets made.
Green for real. The implementation lower-cases the string, splits it into runs of letters, digits and hyphens with strings.FieldsFunc, and joins them with a hyphen. All five cases pass. The refactor step is where you would reshape this with the tests protecting you, for example extracting the character test into a named function, or replacing the closure with a package-level rune check. The tests make the change low risk. The Go team's own testing documentation describes the tooling the loop uses.
What the loop gives you
- A design pressure. Writing the call before the function makes you the first user of your API. Awkward signatures hurt in the test, not six months later.
- A test you know can fail. Every test in the loop was seen red first.
- Small steps. The loop has no step longer than a few minutes, so a bug is always in the last few lines you wrote.
- A regression net that grows as a side effect.
What it costs
- Time at the start. For a function you could write correctly in two minutes, the loop takes five.
- Tests that follow the implementation. Writing the cheapest code to pass invites tests that mirror internals. The cure is to assert behaviour at the public edge of the unit.
- A poor fit for exploration. When you do not know what you want, as in a spike for a new protocol, tests written first get thrown away with the spike. Explore first, then write the tests for what you kept.
- Overhead for glue. A function that wires two libraries together has no logic to drive out; its tests are integration tests, written after.
- False confidence. Green tests mean the cases you thought of pass. TDD does not find the case you did not think of; use fuzzing and property tests for that.
Where it pays
The method earns its time on logic that has clear inputs and outputs and enough branches to get wrong: parsers, validators, pricing and tax rules, state machines, text and data transformations, protocol handling. Table tests make the loop cheap in Go, since a new behaviour is a new row. It also pays when fixing bugs: write the failing test first, from the report, watch it fail, fix, and the test remains. That version of TDD, test first for every bug, is the cheapest and the one with the clearest return.
It pays less on code that is mostly plumbing, on user interface layout, and on code whose behaviour you are discovering as you go. A reasonable position for a team: test first for logic and for bug fixes, test after for glue and spikes, and never ship code with no test at all. Strictness about the order matters less than the habit of seeing each test fail once.
Is it worth it
For the kinds of code listed above, yes, with a modest claim: it produces smaller functions, clearer signatures and a regression net as a by-product, at a time cost that shrinks with practice. I would not claim it eliminates bugs or that it is the only route to good design, and I have no measurement of defect rates to quote. The test of whether it is worth it for you is to apply it to the next bug fix and the next pure function you write, and see whether the code you end up with is better than what you would have written. That is a week of evidence, not a debate. The loop's tools are the ones in the testing guide, and the tests it produces are the kind described in table driven tests. The Go documentation has an official tutorial on adding tests that follows the same rhythm.


