Automate the Boring Stuff with Go, Ch. 15: Testing and Debugging
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
New in the 2026 edition.
Every project since chapter 7 has had a main_test.go next to it, and you have been running them with go test ./... without a chapter telling you how they work. This is that chapter: how to write a test that is worth having, how to let Go invent test inputs, how to measure speed, how to log so that a program that ran at 3 a.m. can tell you what happened, and what to do when it panics. The code is under ch15.
A test is a function
A test file ends in _test.go, sits in the same folder as the code, and holds functions named TestSomething that take a *testing.T. go test finds them. Here is a function worth testing, a URL slug maker:
// a function worth testing: turn a title into a URL slug
package main
import (
"fmt"
"strings"
"unicode"
)
// Slugify lower-cases, keeps letters and digits, and joins words with single hyphens.
func Slugify(title string) string {
var b strings.Builder
lastHyphen := true // so we never start with one
for _, r := range strings.ToLower(title) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastHyphen = false
case !lastHyphen:
b.WriteByte('-')
lastHyphen = true
}
}
return strings.TrimSuffix(b.String(), "-")
}
func main() {
fmt.Println(Slugify("Automate the Boring Stuff with Go, Ch. 15: Testing!"))
}
And its test, in the table-driven style nearly every Go test uses:
package main
import "testing"
// A table-driven test: one slice of cases, one loop, one failure message that names the
// case. Adding a case is adding a line.
func TestSlugify(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"simple", "Hello World", "hello-world"},
{"punctuation", "Ch. 15: Testing!", "ch-15-testing"},
{"runs of separators", "a -- b", "a-b"},
{"leading and trailing", " -x- ", "x"},
{"unicode letters", "Café Olé", "café-olé"},
{"empty", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Slugify(c.in); got != c.want {
t.Errorf("Slugify(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
$ go test -v ./ch15/examples/table-test
=== RUN TestSlugify
=== RUN TestSlugify/simple
=== RUN TestSlugify/punctuation
=== RUN TestSlugify/runs_of_separators
=== RUN TestSlugify/leading_and_trailing
=== RUN TestSlugify/unicode_letters
=== RUN TestSlugify/empty
--- PASS: TestSlugify (0.00s)
--- PASS: TestSlugify/simple (0.00s)
...
PASS
ok github.com/t0ul/automate-the-boring-stuff-with-go/ch15/examples/table-test 0.193s
One slice of cases, one loop, t.Run so each case has a name in the output, and a failure message that shows input, got and want. Adding a case is adding a line, which is what makes people actually add them. t.Errorf records a failure and carries on; t.Fatalf stops the test, for when continuing makes no sense.
The projects in this book follow two more habits worth copying. They test behaviour through the function a user would call, not the private helpers. And they test against real things: a real temp folder (t.TempDir() cleans up after itself), a real workbook written by excelize, a real local web server from net/http/httptest. A test that fakes the thing it is testing proves nothing.
Fuzzing: let Go invent the inputs
A table test checks the cases you thought of. A fuzz test states a property that must hold for any input and lets Go generate inputs for as long as you give it:
// Reverse a string by characters, not bytes. The fuzz test next door tries to break it.
package main
import "fmt"
// Reverse returns s with its runes in reverse order.
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
fmt.Println(Reverse("Hello, 世界"))
}
package main
import (
"testing"
"unicode/utf8"
)
// A fuzz test states a property that must hold for ANY input, and lets Go invent inputs.
// Run the seeds only: go test ./ch15/examples/fuzz
// Fuzz for ten seconds: go test -fuzz=FuzzReverse -fuzztime=10s ./ch15/examples/fuzz
func FuzzReverse(f *testing.F) {
for _, seed := range []string{"", "a", "Hello, 世界", "!12345"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip("not valid UTF-8; reversing bytes of a broken string is undefined")
}
twice := Reverse(Reverse(s))
if twice != s {
t.Errorf("Reverse(Reverse(%q)) = %q", s, twice)
}
if utf8.RuneCountInString(Reverse(s)) != utf8.RuneCountInString(s) {
t.Errorf("Reverse(%q) changed the character count", s)
}
})
}
$ go test -fuzz=FuzzReverse -fuzztime=3s ./ch15/examples/fuzz
fuzz: elapsed: 4s, execs: 95438 (0/sec), new interesting: 27 (total: 31)
PASS
Ninety-five thousand strings in three seconds, including ones with emoji, control characters and invalid bytes, none of which you would have typed into a table. When fuzzing finds a failure it saves the input under testdata/fuzz/ and it becomes a permanent regression test. Fuzz anything that parses: file names, dates, sizes, user input.
Benchmarks: measure before you optimise
// two ways to build a big string; the benchmark says which is faster and by how much
package main
import (
"fmt"
"strings"
)
func joinWithPlus(parts []string) string {
s := ""
for _, p := range parts {
s += p + "\n"
}
return s
}
func joinWithBuilder(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
b.WriteByte('\n')
}
return b.String()
}
func main() {
parts := []string{"one", "two", "three"}
fmt.Print(joinWithPlus(parts), joinWithBuilder(parts))
}
package main
import (
"fmt"
"testing"
)
// go test -bench=. -benchmem ./ch15/examples/benchmark
var parts = func() []string {
p := make([]string, 2000)
for i := range p {
p[i] = fmt.Sprintf("line number %d of the report", i)
}
return p
}()
func BenchmarkJoinWithPlus(b *testing.B) {
for b.Loop() {
joinWithPlus(parts)
}
}
func BenchmarkJoinWithBuilder(b *testing.B) {
for b.Loop() {
joinWithBuilder(parts)
}
}
func TestBothAgree(t *testing.T) {
if joinWithPlus(parts) != joinWithBuilder(parts) {
t.Fatal("the two implementations differ")
}
}
$ go test -bench=. -benchmem ./ch15/examples/benchmark
BenchmarkJoinWithPlus-12 61 3742096 ns/op 65249314 B/op 2003 allocs/op
BenchmarkJoinWithBuilder-12 10000 26020 ns/op 285408 B/op 20 allocs/op
A benchmark is a BenchmarkX function with a b.Loop() loop; Go runs it enough times to get a stable number. Here += on two thousand lines takes 3.7 ms and 65 MB of allocation per call; strings.Builder takes 26 µs and 285 KB. That is the chapter 7 advice with numbers attached. The habit: when something feels slow, write the benchmark first, then change the code, then run it again. Guessing is usually wrong.
Coverage, vet, and the race detector
Three flags and one command you should know:
go test -cover ./... # what share of lines the tests ran
go test -coverprofile=cover.out ./ch8/... && go tool cover -html=cover.out # which lines, in your browser
go test -race ./... # chapter 14's data-race detector
go vet ./... # mistakes the compiler accepts
Coverage is a map of what your tests did not touch, not a score to maximise. go vet catches a Printf with the wrong arguments or an unused error result before a user does; it runs in the repo’s CI on every push. staticcheck (go install honnef.co/go/tools/cmd/staticcheck@latest) finds another layer of the same kind of thing.
Logging with log/slog
A scheduled program’s terminal is not there when it fails. log/slog writes lines with named fields that a person can read and a program can search:
// log/slog: log lines with fields a program (or a grep) can read later
package main
import (
"errors"
"log/slog"
"os"
"time"
)
func main() {
// the default logger writes readable text to stderr
slog.Info("backup started", "folder", "/Users/you/Documents", "files", 1284)
slog.Warn("skipping unreadable file", "path", "/Users/you/Documents/locked.xlsx")
// a JSON logger to a file is what you want for a scheduled job
f, err := os.CreateTemp("", "run-*.log")
if err != nil {
slog.Error("cannot open log", "err", err)
os.Exit(1)
}
defer os.Remove(f.Name())
logger := slog.New(slog.NewJSONHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug}))
logger = logger.With("job", "nightly-backup", "run_id", time.Now().Format("20060102-150405"))
logger.Debug("walking tree")
logger.Info("done", "took", 4200*time.Millisecond, "bytes", 93_123_440)
logger.Error("upload failed", "err", errors.New("connection reset"), "attempt", 3)
f.Close()
data, _ := os.ReadFile(f.Name())
os.Stdout.Write(data)
}
2026/10/17 11:03:06 INFO backup started folder=/Users/you/Documents files=1284
2026/10/17 11:03:06 WARN skipping unreadable file path=/Users/you/Documents/locked.xlsx
{"time":"2026-10-17T11:03:06.790718-04:00","level":"DEBUG","msg":"walking tree","job":"nightly-backup","run_id":"20261017-110306"}
Key-value pairs instead of a formatted sentence, so grep run_id= or a JSON tool finds every line from one run. Levels (Debug, Info, Warn, Error) let you turn the noise down in production and up when hunting a bug. logger.With(...) attaches fields to every line that follows, which is how the run id gets everywhere without repeating it.
Panics, and the one place to recover
An index out of range, a nil map write, a nil pointer: these panic. A panic unwinds the program and prints a stack trace, which is the right outcome for a bug. Read the trace from the top: the first line that names your own file is where to look.
// what a panic is, what it prints, and the one place recover belongs
package main
import (
"fmt"
"os"
"runtime/debug"
)
// process stands in for work on one item that might blow up on bad data.
func process(item []int) int {
return item[0] * 2 // panics when item is empty: index out of range
}
// safely runs fn and turns a panic into an error, so one bad item does not take down
// the loop that is processing a thousand of them. This is the legitimate use of recover.
func safely(fn func() int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v\n%s", r, debug.Stack())
}
}()
return fn(), nil
}
func main() {
items := [][]int{{1}, {}, {3}}
for i, item := range items {
result, err := safely(func() int { return process(item) })
if err != nil {
fmt.Fprintf(os.Stderr, "item %d failed: %v\n", i, err)
continue
}
fmt.Printf("item %d -> %d\n", i, result)
}
fmt.Println("the loop survived")
}
item 0 -> 2
item 1 failed: recovered: runtime error: index out of range [0] with length 0
goroutine 1 [running]:
...
item 2 -> 6
the loop survived
recover is not Go’s try/catch, and using it that way hides bugs. It belongs in exactly one place: a worker that processes many independent items, where one bad item should be logged with its stack and skipped rather than take the other 999 with it. Note it only works inside a deferred function, and it turns the panic into an ordinary error for the caller.
Debugging with delve
When print statements are not enough, delve is Go’s debugger, and the Go extension for VS Code uses it when you press F5. From a terminal:
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug ./ch13/projects/every -- -times 1 -- date
(dlv) break main.RunOnce
(dlv) continue
(dlv) print timeout
(dlv) next
break stops at a function or file.go:line, continue runs to it, print shows a variable, next steps one line, step goes into a call. Ten minutes with it beats an hour of fmt.Println, and the breakpoints in VS Code are the same thing with a mouse.
Project: retry with backoff
Networks fail for a second and come back. A script that quits at the first error is a script you babysit. The project is a small retry helper with exponential backoff, tested without ever sleeping:
// retry runs an operation again when it fails, waiting longer each time, up to a limit.
// Networks fail for a second and come back; a script that gives up at the first error is
// a script you have to babysit. The demo talks to a local server that fails twice first.
//
// go run ./ch15/projects/retry
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"time"
)
// Policy says how many attempts to make and how long to wait after the first failure;
// each later wait doubles, up to MaxWait.
type Policy struct {
Attempts int
Wait time.Duration
MaxWait time.Duration
Sleep func(context.Context, time.Duration) error // swapped out in tests
}
// Do runs op until it returns nil, the attempts run out, or the context ends. The error
// returned is the last one op produced, wrapped with the attempt count.
func (p Policy) Do(ctx context.Context, op func() error) error {
sleep := p.Sleep
if sleep == nil {
sleep = sleepCtx
}
wait := p.Wait
var last error
for attempt := 1; attempt <= p.Attempts; attempt++ {
last = op()
if last == nil {
return nil
}
if attempt == p.Attempts {
break
}
slog.Warn("attempt failed, retrying", "attempt", attempt, "wait", wait, "err", last)
if err := sleep(ctx, wait); err != nil {
return fmt.Errorf("gave up after %d attempt(s): %w (last error: %v)", attempt, err, last)
}
wait = min(wait*2, p.MaxWait)
}
return fmt.Errorf("gave up after %d attempt(s): %w", p.Attempts, last)
}
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// flakyServer answers 503 to the first n requests and 200 afterwards.
func flakyServer(n int32) *httptest.Server {
var calls atomic.Int32
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) <= n {
http.Error(w, "warming up", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok, here is your data")
}))
}
func run() error {
srv := flakyServer(2)
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
policy := Policy{Attempts: 5, Wait: 200 * time.Millisecond, MaxWait: 2 * time.Second}
var body []byte
err := policy.Do(context.Background(), func() error {
resp, err := client.Get(srv.URL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(resp.Status)
}
body = make([]byte, 64)
n, _ := resp.Body.Read(body)
body = body[:n]
return nil
})
if err != nil {
return err
}
fmt.Printf("got: %s", body)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch15/projects/retry
2026/10/17 11:03:07 WARN attempt failed, retrying attempt=1 wait=200ms err="503 Service Unavailable"
2026/10/17 11:03:07 WARN attempt failed, retrying attempt=2 wait=400ms err="503 Service Unavailable"
got: ok, here is your data
The demo starts a local server that fails twice and then succeeds, so you can watch the backoff work without a flaky internet. The part that matters is in Policy: Sleep is a field. The real program leaves it nil and gets a sleep that respects the context; the test swaps in a function that records what it was asked to wait and returns at once. That is how the test checks the exact sequence 100 ms, 200 ms, 250 ms (capped) in a millisecond, and how it checks that a cancelled context stops the retries after one attempt without waiting an hour. Whenever code touches a clock, the network or the disk, hand it that dependency as a parameter and tests become easy.
Conclusion
Table tests for what you know, fuzzing for what you do not, benchmarks before optimising, -race and vet on every run, slog for programs that run while you sleep, recover in one place only, delve when you are stuck. Testing is not a separate skill from programming; it is how you find out whether the program you wrote is the one you meant.