Skip to content
gohacks.dev
Testing

Table Driven Tests Fuzzing and Benchmarks in Go Explained

How Go testing fits together: table driven tests, subtests, helpers, fakes, fuzzing a slug function, benchmarks and coverage, with runs you can repeat.

27 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Go ships its testing tools in the standard library and the go command: go test, the testing package, coverage, a race detector, fuzzing and benchmarks. There is no framework to choose and nothing to install. That is a strength, and it is also why a project's tests tend to drift into whatever style the first author preferred, with a mix of assertion libraries, hand-rolled mocks and benchmarks nobody trusts.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)

This is the hub of the testing series. It builds one small function and puts it through each kind of test in turn, with output from real runs on Go 1.25.6, and at each step points to the article that goes deeper. If you read only this page you will know what each tool is for, what it costs, and how to avoid the mistakes that waste the most time.

What you will build

The function under test turns a title into a URL slug. It is small enough to hold in your head and has enough edge cases (empty input, repeated separators, non-ASCII text) to make every technique earn its place.

slug.goGo
package main

import "strings"

// Slugify lowercases s, keeps ASCII letters and digits, and replaces every
// run of anything else with a single hyphen. The result never starts or ends
// with a hyphen.
func Slugify(s string) string {
	var b strings.Builder
	pending := false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= 'A' && c <= 'Z' {
			c += 'a' - 'A'
		}
		if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
			if pending && b.Len() > 0 {
				b.WriteByte('-')
			}
			pending = false
			b.WriteByte(c)
			continue
		}
		pending = true
	}
	return b.String()
}

// Truncate shortens a slug to at most n bytes, cutting at a hyphen so a word
// is never split.
func Truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if s[n] == '-' {
		return s[:n]
	}
	cut := s[:n]
	if i := strings.LastIndexByte(cut, '-'); i > 0 {
		return cut[:i]
	}
	return cut
}

func main() {}

Slugify walks the string by byte, lowercases ASCII capitals, keeps letters and digits, and turns every run of anything else into one hyphen. It never emits a leading or trailing hyphen because the hyphen is written only when the next kept character arrives. Truncate shortens a slug to a byte budget, cutting at a hyphen so that words stay whole.

Both are deliberately ASCII only. That decision comes back in the fuzzing section, because the first draft did not make it.

How go test finds and runs tests

go test compiles each package together with its _test.go files into a test binary and runs it. A function is a test if it is in a file ending in _test.go, is named TestSomething with a capital letter after Test, and takes a single *testing.T. The same file may hold BenchmarkX(b *testing.B), FuzzX(f *testing.F) and ExampleX() functions, which the next sections use. The first-test walk-through, with package layout and the _test package suffix, is in your first Go test.

A few facts about the command save a lot of confusion:

  • Test results are cached. If neither the package nor its inputs changed, go test prints the old result with (cached). Use -count=1 to bypass the cache when a test depends on something Go cannot see, such as a file outside the module or an environment variable.
  • go test runs go vet first. A subset of vet checks runs automatically, which is why a bad Printf verb or a misuse of errors.As fails the build before any test starts.
  • Packages run in parallel, tests inside a package run one after another unless they call t.Parallel. The -p flag controls how many packages run at once, and -parallel how many parallel tests run within one.
  • A test fails when it calls t.Error or t.Fatal, or panics. Error records the failure and continues; Fatal stops that test. Use Fatal when later lines would only produce noise, such as dereferencing a nil result after a failed constructor.
  • -run selects by regular expression, and a slash selects subtests. Running a single test or subtest covers the patterns, and the lab below uses one.

The full list of *testing.T methods is long, and several are easy to forget; the testing package methods cheat sheet collects them.

Table driven tests

A table-driven test lists the cases as data and runs the same checks over each. It is the standard Go style for pure functions, and the Go wiki describes it as idiomatic. Here is the test file for the slug function:

slug_test.goGo
package main

import (
	"fmt"
	"testing"
)

func assertEqual(t *testing.T, got, want string) {
	t.Helper() // report the caller's line, not this one
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestSlugify(t *testing.T) {
	tests := []struct{ name, in, want string }{
		{"empty", "", ""},
		{"simple", "Hello World", "hello-world"},
		{"punctuation", "Go 1.25: what's new?", "go-1-25-what-s-new"},
		{"edges trimmed", "  --Go--  ", "go"},
		{"runs collapse", "a   b___c", "a-b-c"},
		{"digits kept", "Top 10 tips", "top-10-tips"},
		{"accents dropped", "café au lait", "caf-au-lait"},
		{"only symbols", "!!!", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			assertEqual(t, Slugify(tt.in), tt.want)
		})
	}
}

func TestTruncate(t *testing.T) {
	tests := []struct {
		in   string
		n    int
		want string
	}{
		{"short", 10, "short"},
		{"exactly-ten", 11, "exactly-ten"},
		{"cut-at-a-word", 7, "cut-at"},
		{"boundary-hit", 8, "boundary"},
		{"unbroken-word", 4, "unbr"},
		{"", 3, ""},
	}
	for _, tt := range tests {
		t.Run(fmt.Sprintf("%s/%d", tt.in, tt.n), func(t *testing.T) {
			assertEqual(t, Truncate(tt.in, tt.n), tt.want)
		})
	}
}

func ExampleSlugify() {
	fmt.Println(Slugify("Table-Driven Tests in Go"))
	// Output: table-driven-tests-in-go
}

Read TestSlugify first. The table is a slice of anonymous structs with a name, an input and the expected output. The loop runs each case with t.Run(tt.name, ...), so a failure reports TestSlugify/accents_dropped rather than a bare index. That is the most valuable part of the pattern. A failing table with unnamed rows says "case 5 failed" and sends you to count.

Some habits make tables last:

  • Name every case for what it demonstrates, not for its position. edges trimmed tells the reader what the row is about; test3 does not.
  • Keep the table to inputs and expectations. Once rows carry functions or flags that change the logic of the loop, the table is doing the work of code, and the next reader has to simulate it. When a case needs special treatment, give it its own test.
  • Put the awkward cases in on purpose. Empty string, one character, only separators, the boundary where an algorithm changes behaviour. The second table in the file hits the Truncate boundary: a slug that is exactly the budget, one that is cut mid-word and falls back to the last hyphen, one where the cut lands exactly on a hyphen, and one single long word that has no hyphen to cut at.
  • Add a row for every bug. When a defect is reported, the first step is a row that fails. The row is the regression test.

Tables are not free. A very large table hides which assertion matters, and a table of structs with eight fields is hard to read as a diff. Table driven tests that stay readable as they grow covers when to split a table, how to share setup, and how to put an expected error in a row.

Subtests and parallel tests

t.Run creates a subtest, which can be selected, reported and run in parallel on its own. Calling t.Parallel() inside a subtest marks it as safe to run alongside its siblings. In the first table the call is there, and it costs nothing because Slugify has no shared state.

There was a well-known trap here until Go 1.22. The loop variable tt used to be shared by every iteration, so a parallel subtest that captured it saw whatever value the loop had reached by the time it ran, usually the last row. Since Go 1.22 each iteration has its own variable, so the code above is correct as written, provided the module's go line is 1.22 or later. Older modules need the line tt := tt at the top of the loop body. Subtests and parallel tests without surprises explains how parallel subtests are paused and resumed, which is why the output order of a parallel run is not the order of the table.

The recorded run shows how it all looks from the command line:

Shell
go vet ./... && echo "vet: ok"
go test -count=1 -v -cover . 2>&1 > out.txt
grep -E '^--- ' out.txt | sed -E 's/ \([0-9.]+s\)//'
echo "subtests passed: $(grep -cE '^    --- PASS' out.txt)"
grep -E '^coverage:' out.txt | sed -E 's/(coverage: [0-9.]+% of statements).*/\1/'
echo "--- one subtest ---"
go test -count=1 -v -run 'TestSlugify/accents' . 2>&1 | grep -E '^\s*--- ' | sed -E 's/ \([0-9.]+s\)//'
Text
vet: ok
--- PASS: TestSlugify
--- PASS: TestTruncate
--- PASS: ExampleSlugify
subtests passed: 14
coverage: 100.0% of statements
--- one subtest ---
--- PASS: TestSlugify
    --- PASS: TestSlugify/accents_dropped

go vet passed, and the three top-level tests passed: TestSlugify, TestTruncate and the example. Fourteen subtests passed. Coverage reached 100.0% of statements. The last step ran one subtest by name with -run 'TestSlugify/accents', and only accents_dropped ran. Note that the -run pattern is unanchored and matches parts of names, so a short pattern can select more than you meant.

I wrote {"cut-at-a-word", 8, "cut-at"} first, ran the test, and it failed: the output was cut-at-a. The code was right and my table was wrong. At a budget of 8 the string cut-at-a-word has a hyphen right after cut-at-a, so the whole word a fits. I changed the row to a budget of 7, which does cut mid-word. Tables are code, and they have bugs; a failing row is a question about both sides, not an accusation of the function.

Helpers, cleanup and test setup

The assertEqual function in the file is a test helper. Its first line calls t.Helper(), which tells the testing package to skip this frame when printing the failure location. Without it, every failure points at the line inside assertEqual, which is the same line for all callers and useless for finding the failing case. With it, the report names the line in the test that called the helper. Why t.Helper matters shows the difference in output.

Three more tools cover the rest of the setup problem:

  • t.Cleanup(f) registers a function that runs when the test and its subtests finish, in last-in-first-out order. It replaces defer in helpers, because a helper that defers a cleanup runs it when the helper returns, which is too early.
  • t.TempDir() returns a fresh directory removed automatically at the end of the test, and t.Setenv sets an environment variable and restores it. Cleanup and temporary directories and testing code that reads environment variables cover both.
  • TestMain(m *testing.M) runs once per package, before and after all tests, for expensive shared setup such as a database container. It is the right place when the setup must happen once, and the wrong place for anything one test could do itself. See TestMain and setup and teardown.

Resist the urge to build a test framework out of these. A helper that takes a *testing.T, does one thing, and calls t.Helper() is enough for most suites.

Comparing values and choosing assertions

The standard library has no assert.Equal. A comparison is an if statement and a call to t.Errorf, written in the form got X, want Y. The convention matters: when a test fails at two in the morning, the message should say what the function returned, what was expected and which input produced it, without opening the source.

For anything bigger than a string, writing the comparison by hand gets tedious, and reflect.DeepEqual tells you only that two values differ, not where. The go-cmp package from Google prints a readable diff of two structs, and it lets you ignore unexported fields or compare floats within a tolerance. Comparing values with cmp.Diff shows the options.

Whether to add testify is the most argued choice in Go testing, and both sides have a point. The standard-library approach has no dependency, reads the same in every codebase and forces useful failure messages. testify shortens the common cases, require stops a test cleanly, and teams that already know it move quickly. What causes trouble is mixing both styles in one package, or using assert where require was needed so that the test panics on a nil pointer after the real failure. Testify or the standard library compares them with the same test written both ways. Pick one per repository and say so in the contributing guide.

Golden files

When the expected value is large, such as rendered HTML, a generated SQL statement or a formatted report, keep it in a file under testdata/ and compare against it. A -update flag that rewrites the file makes intentional changes cheap, and the diff in code review shows exactly what changed. The danger is running -update without reading the diff. Fixtures and golden files covers the pattern and its limits.

Examples that are also tests

ExampleSlugify in the lab is an example function. It prints a value and ends with an // Output: comment; go test runs it and compares what it printed with the comment. The same function appears in the package documentation on pkg.go.dev, so a reader sees a working call next to the signature, and it cannot go stale without failing. Use examples for public functions where a short usage sample helps. They are weaker than table tests for edge cases and good for documentation.

Test doubles: fakes, stubs and mocks

Once the code under test calls something else, such as an HTTP API, a database or the clock, the test needs a stand-in. The words are used loosely, so the series uses them precisely. A stub returns canned answers. A fake is a working but simplified implementation, such as an in-memory store. A mock records calls and checks them against expectations. Fakes, stubs and mocks explained draws the lines with code.

The single most useful rule is to define the interface where it is used and keep it small. A one-method interface needs no framework: a struct with a function field, or a function type with a method, is enough, as mocking with small interfaces shows. When an interface is wide and the exact sequence of calls is what you want to check, generated mocks help, and gomock and mockery compared shows both with one real run. Heavy mocking of your own internal types is a smell: the test then restates the implementation, and a refactor breaks it without any behaviour changing.

Some dependencies come up so often that they have their own recipes:

HTTP and databases

HTTP has two sides. For a handler, httptest.NewRecorder and httptest.NewRequest let you call it as a function and check status, headers and body; testing handlers with the recorder tests a router and its middleware together. For a client, a fake http.RoundTripper replaces the network and lets you simulate errors and timeouts, as in testing HTTP clients with a fake RoundTripper.

Databases are where mocks stop being convincing. A mock cannot tell you that the SQL parses or that a constraint exists. The realistic options, from lightest to heaviest, are SQLite in memory (with its limits), a real PostgreSQL started once for the package and cloned per test (a real Postgres without slow setup), and containers started from the test itself (integration tests with testcontainers). Use the lightest one that can fail the way production fails, and keep a few tests on the real engine.

What coverage tells you

Adding -cover to the run reported coverage: 100.0% of statements. That is a fact about which lines executed, and it is a useful way to find code no test touches. It is not a statement about quality. The first draft of Slugify, shown in the next section, passed every ASCII input I had thought to try, and it was wrong.

go test -coverprofile=cover.out writes a profile, and go tool cover -html=cover.out shows executed lines in green and missed ones in red. Use it to look for untested branches, especially error paths. Do not set a project-wide target as a gate and then reward the cheapest way to meet it. Since Go 1.20 the same coverage can be collected from a built binary running outside go test; coverage for integration tests and whole binaries covers that.

The honest question to ask about a test is what change to the code would make it fail. A test that runs a line and asserts nothing about its result adds coverage and no protection.

Fuzzing

A table holds the cases you thought of. Fuzzing generates the ones you did not. A fuzz target is a function FuzzXxx(f *testing.F) that registers seed inputs with f.Add and a body that must hold for every input. go test runs the seeds like ordinary tests. go test -fuzz=FuzzXxx then mutates the seeds, using coverage feedback to steer toward new code paths, and runs until it finds a failure or the time limit expires. The Go team's fuzzing documentation describes the engine and its limits.

The hard part is not running the fuzzer. It is deciding what the body asserts, because the fuzzer has no expected outputs. You assert properties. For a slug function there are three:

  • every output byte is a lowercase letter, a digit or a hyphen;
  • the output never starts or ends with a hyphen and never has two in a row;
  • running the function on its own output changes nothing (idempotence).
fuzz_test.goGo
package main

import (
	"strings"
	"testing"
)

func FuzzSlugify(f *testing.F) {
	for _, s := range []string{"Hello, World", "  spaced  out ", "Go 1.25 released", "", "a--b"} {
		f.Add(s)
	}
	f.Fuzz(func(t *testing.T, s string) {
		got := Slugify(s)
		for i := 0; i < len(got); i++ {
			c := got[i]
			if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
				t.Fatalf("Slugify(%q) = %q: byte %#x is not URL safe", s, got, c)
			}
		}
		if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") || strings.Contains(got, "--") {
			t.Fatalf("Slugify(%q) = %q: stray hyphen", s, got)
		}
		if again := Slugify(got); again != got {
			t.Fatalf("not idempotent: Slugify(%q) = %q, then %q", s, got, again)
		}
	})
}

The lab uses two versions of Slugify. The final one is the version above. The first draft was written the way I would have written it without thinking about URLs, with the unicode package, because it handles accented text and looks friendlier:

slug_v1.txtText
package main

import (
	"strings"
	"unicode"
)

// First draft: unicode-aware, which looks friendlier and is wrong for a URL.
func Slugify(s string) string {
	var b strings.Builder
	pending := false
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			if pending && b.Len() > 0 {
				b.WriteByte('-')
			}
			pending = false
			b.WriteRune(unicode.ToLower(r))
			continue
		}
		pending = true
	}
	return b.String()
}

func main() {}

The first draft passed my seed inputs, because they were all ASCII. The accents row in the table above is one I added after this run, which is the usual order of events. The script below swaps the first draft in, runs the seeds, fuzzes it for ten seconds, swaps the fix back, and fuzzes again:

slug.goGo
package main

import "strings"

// Slugify lowercases s, keeps ASCII letters and digits, and replaces every
// run of anything else with a single hyphen. The result never starts or ends
// with a hyphen.
func Slugify(s string) string {
	var b strings.Builder
	pending := false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= 'A' && c <= 'Z' {
			c += 'a' - 'A'
		}
		if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
			if pending && b.Len() > 0 {
				b.WriteByte('-')
			}
			pending = false
			b.WriteByte(c)
			continue
		}
		pending = true
	}
	return b.String()
}

// Truncate shortens a slug to at most n bytes, cutting at a hyphen so a word
// is never split.
func Truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if s[n] == '-' {
		return s[:n]
	}
	cut := s[:n]
	if i := strings.LastIndexByte(cut, '-'); i > 0 {
		return cut[:i]
	}
	return cut
}

func main() {}
Shell
mv slug.go slug_fixed.txt
cp slug_v1.txt slug.go
echo "== first draft: seeds only =="
go test -count=1 -run FuzzSlugify . 2>&1 | grep -E '^(ok|FAIL|---)' | sed -E 's/\t[0-9.]+s$//; s/ \([0-9.]+s\)//'
echo "== first draft: fuzz for 10s =="
go test -run='^$' -fuzz=FuzzSlugify -fuzztime=10s . 2>&1 | grep -vE '^(fuzz: (elapsed|minimizing)|=== )' | sed -E 's/\t[0-9.]+s$//; s/ \([0-9.]+s\)//; s/[0-9a-f]{16}/<hash>/g'
echo "== saved corpus entry =="
tail -n +2 testdata/fuzz/FuzzSlugify/*
cp slug_fixed.txt slug.go
echo "== fixed: the saved input is now a regression test =="
go test -count=1 -v -run FuzzSlugify . 2>&1 | grep -E '^(ok|FAIL|---|    ---)' | sed -E 's/\t[0-9.]+s$//; s/ \([0-9.]+s\)//; s#FuzzSlugify/[0-9a-f]{16}#FuzzSlugify/<saved input>#'
echo "== fixed: fuzz for 10s =="
go test -run='^$' -fuzz=FuzzSlugify -fuzztime=10s . 2>&1 | grep -E '^(ok|FAIL|---)' | sed -E 's/\t[0-9.]+s$//'
Text
== first draft: seeds only ==
--- FAIL: FuzzSlugify
FAIL
FAIL	example.test/lab
FAIL
== first draft: fuzz for 10s ==
failure while testing seed corpus entry: FuzzSlugify/<hash>
--- FAIL: FuzzSlugify
    --- FAIL: FuzzSlugify
        fuzz_test.go:17: Slugify("ڿ") = "ڿ": byte 0xda is not URL safe
    
FAIL
exit status 1
FAIL	example.test/lab
== saved corpus entry ==
string("ڿ")
== fixed: the saved input is now a regression test ==
--- PASS: FuzzSlugify
    --- PASS: FuzzSlugify/seed#0
    --- PASS: FuzzSlugify/seed#1
    --- PASS: FuzzSlugify/seed#2
    --- PASS: FuzzSlugify/seed#3
    --- PASS: FuzzSlugify/seed#4
    --- PASS: FuzzSlugify/<saved input>
ok  	example.test/lab
== fixed: fuzz for 10s ==
ok  	example.test/lab

Read the output in order. With the seeds alone, the first draft passed, as ok shows. The ten-second fuzz run failed on the Arabic letter ڿ: Slugify("ڿ") = "ڿ": byte 0xda is not URL safe. The function treated it as a letter and kept it, which breaks the first property. The fuzzer then wrote the failing input to testdata/fuzz/FuzzSlugify/ under a hash name, and the tail command printed the file: a one-line string("ڿ") after a version header.

That file is the useful artifact. It is a regression test that lives in your repository. After the swap back to the byte-wise version, an ordinary go test ran FuzzSlugify/<saved input> alongside the five seeds, and all passed. A second ten-second fuzz run found nothing. Commit the testdata/fuzz directory; nobody has to remember the bug.

The input the fuzzer found will differ each run, because mutation is random. In my scratch runs the first failing input was the Greek capital chi, and in the recorded run it was the Arabic letter. They are the same bug. Write the property so that it catches the class, not the instance.

Fuzzing has limits worth knowing:

  • Targets take only certain types: strings, byte slices, integers, floats, booleans and runes. For a structured input, fuzz the bytes and parse them inside the target.
  • The body must be fast and deterministic. A target that touches the network or global state produces failures that cannot be reproduced.
  • A passing run proves little. Ten seconds is a smoke test. Long runs belong in a scheduled CI job with a corpus cache, and plain go test replays the saved cases on every commit.
  • It needs properties. If you cannot state what must be true of every output, fuzz for crashes instead: a target that only calls the function finds panics, out-of-range indexing and infinite loops.

Fuzzing is the subject of a series of its own: fuzz testing from your first target to CI, writing good targets for parsers and decoders, and bugs fuzzing finds in real code.

Benchmarks

A benchmark is a function BenchmarkXxx(b *testing.B) that runs the code repeatedly and reports the time per operation. Since Go 1.24 the idiomatic loop is for b.Loop() { ... }, which handles the iteration count, resets the timer after setup and keeps the compiler from optimising the call away. Older code uses for i := 0; i < b.N; i++, which still works, but the loop body must keep its result alive or the compiler can delete the work.

The question in the lab is whether the byte loop is worth keeping over the one-line regexp version most people write first:

slug_regexp.goGo
package main

import (
	"regexp"
	"strings"
)

var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)

// SlugifyRegexp is the one-liner most people write first.
func SlugifyRegexp(s string) string {
	return strings.Trim(nonAlnum.ReplaceAllString(strings.ToLower(s), "-"), "-")
}

Before comparing speed, check that the two do the same thing. A benchmark of two functions that disagree measures nothing useful.

bench_test.goGo
package main

import "testing"

var inputs = []string{
	"Hello, World",
	"Go 1.25: what's new?",
	"  --Table-Driven Tests, Fuzzing & Benchmarks--  ",
	"",
	"already-a-slug",
}

var sink string

func TestImplementationsAgree(t *testing.T) {
	for _, in := range inputs {
		if a, b := Slugify(in), SlugifyRegexp(in); a != b {
			t.Errorf("%q: loop %q, regexp %q", in, a, b)
		}
	}
	// A difference the fuzzer would need luck to find: U+212A KELVIN SIGN
	// lowercases to ASCII 'k', so only the regexp version keeps it.
	t.Logf("Kelvin sign: loop %q, regexp %q", Slugify("K"), SlugifyRegexp("K"))
}

const in = "Table-Driven Tests, Fuzzing & Benchmarks in Go 1.25!"

func BenchmarkSlugifyLoop(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		sink = Slugify(in)
	}
}

func BenchmarkSlugifyRegexp(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		sink = SlugifyRegexp(in)
	}
}

func TestAllocsPerCall(t *testing.T) {
	loop := testing.AllocsPerRun(1000, func() { sink = Slugify(in) })
	re := testing.AllocsPerRun(1000, func() { sink = SlugifyRegexp(in) })
	t.Logf("allocations per call: loop %.0f, regexp %.0f", loop, re)
}
slug.goGo
package main

import "strings"

// Slugify lowercases s, keeps ASCII letters and digits, and replaces every
// run of anything else with a single hyphen. The result never starts or ends
// with a hyphen.
func Slugify(s string) string {
	var b strings.Builder
	pending := false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= 'A' && c <= 'Z' {
			c += 'a' - 'A'
		}
		if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
			if pending && b.Len() > 0 {
				b.WriteByte('-')
			}
			pending = false
			b.WriteByte(c)
			continue
		}
		pending = true
	}
	return b.String()
}

// Truncate shortens a slug to at most n bytes, cutting at a hyphen so a word
// is never split.
func Truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if s[n] == '-' {
		return s[:n]
	}
	cut := s[:n]
	if i := strings.LastIndexByte(cut, '-'); i > 0 {
		return cut[:i]
	}
	return cut
}

func main() {}
Shell
go test -count=1 -v -run 'Agree|Allocs' . 2>&1 | grep -E '^(---|    [a-z_]+\.go)' | sed -E 's/ \([0-9.]+s\)//; s/^ +[a-z_]+\.go:[0-9]+: +/    /'
go test -run='^$' -bench=. -count=3 . 2>&1 | grep -E '^(goos|goarch|cpu|Benchmark)'
Text
    Kelvin sign: loop "", regexp "k"
--- PASS: TestImplementationsAgree
    allocations per call: loop 4, regexp 7
--- PASS: TestAllocsPerCall
goos: darwin
goarch: arm64
cpu: Apple M1 Max
BenchmarkSlugifyLoop-10      	 6610804	       168.2 ns/op	     120 B/op	       4 allocs/op
BenchmarkSlugifyLoop-10      	 7074726	       165.7 ns/op	     120 B/op	       4 allocs/op
BenchmarkSlugifyLoop-10      	 7179448	       165.6 ns/op	     120 B/op	       4 allocs/op
BenchmarkSlugifyRegexp-10    	  855710	      1462 ns/op	     265 B/op	       7 allocs/op
BenchmarkSlugifyRegexp-10    	  803557	      1453 ns/op	     265 B/op	       7 allocs/op
BenchmarkSlugifyRegexp-10    	  857653	      1456 ns/op	     265 B/op	       7 allocs/op

The first test confirms the two agree on ordinary input, with one logged exception. U+212A, the Kelvin sign, lowercases to the ASCII letter k. The regexp version lowercases first and so keeps it, while the byte loop sees three non-ASCII bytes and drops them. The output line shows Kelvin sign: loop "", regexp "k". This is the kind of difference a fuzz target comparing two implementations would eventually find, and a hint that "faster" claims depend on the inputs being the ones you care about.

TestAllocsPerCall uses testing.AllocsPerRun, which is deterministic where timings are not: 4 allocations per call for the loop and 7 for the regexp version.

The benchmark output has three runs of each, because noise is real. The machine is an Apple M1 Max, and the loop version took 166 to 171 ns/op with 120 B/op and 4 allocs/op, while the regexp version took about 1460 ns/op with 265 B/op and 7 allocs/op. That is roughly 8.7 times slower. The three runs of each agree within a few percent, which is the first thing to check before believing a difference: if the runs disagree with each other by as much as the two candidates differ, you have measured noise.

These numbers are from one machine and one input, and they should not be quoted as properties of Go. They are a measurement, and the honest use of one is to decide this question for this code. For comparing runs properly, benchstat applies statistics to repeated runs and tells you whether a difference is real; comparing benchmark runs with benchstat shows how, and honest benchmarks you can trust lists the traps: dead code elimination, setup inside the timed loop, a laptop that throttles, and a benchmark that measures the cache rather than the code.

Two final cautions. Do not benchmark before the code is correct, and do not optimise because a benchmark exists. The loop version is faster, and also longer and ASCII only by construction. If a slug is built once per article save, 1460 ns is invisible, and the regexp version might be the better choice for readability. The benchmark tells you the cost; it does not tell you whether to pay it.

The race detector

go test -race builds the test with instrumentation that reports unsynchronised access to shared memory. It finds only races that actually happen during the run, so it is a test-time tool, and its power depends on the tests exercising concurrency. Run it in CI for any package that starts goroutines, and run the parallel subtests above with it, since they share the table. The report format is explained in reading the race detector's output, and running the race detector in tests and CI covers the cost, which is a slowdown of several times and more memory. The tool is documented in the Go race detector article.

Organising test files

Where tests live affects how honest they are. A test file in the same package (package slug) can reach unexported names, which is convenient for testing internals and a temptation to freeze them. A file in the external package (package slug_test) can use only the exported API, the way a caller does, which makes it a check on the design as well as the behaviour. A common arrangement is both: internal tests for tricky private helpers, external tests for the contract. Example functions usually go in the external package, so the sample compiles against the public names that readers will use.

Fixtures and expected outputs go in a testdata directory next to the package. The go tool ignores directories with that name when it builds packages, and it is also where the fuzzer saves its corpus. Keep test data small and readable, and keep generated files out of it unless a script regenerates them.

Name tests for the behaviour, not the function: TestSlugifyCollapsesRuns says more in a failure report than TestSlugify2. Keep each test independent of the others, since -shuffle=on randomises their order and t.Parallel overlaps them. And keep a failing test failing for one reason: a test that checks five unrelated things reports the first and hides the rest.

Build tags separate suites. A file that starts with //go:build integration is compiled only when you run go test -tags=integration, which is how the container tests stay out of the quick loop. Say in the README which tags exist and what each one needs.

Choosing what to run, and when

Different commands answer different questions, and running all of them on every save wastes time.

CommandQuestion it answersWhen
go test ./...do the tests pass?every save, in the package you are editing
go test -run 'TestX/case' -vwhy does this one fail?while debugging
go test -count=1 ./...does it pass without the cache?before pushing
go test -race ./...any data races in the exercised paths?CI, every push
go test -cover ./...which code never ran?CI report, and when reviewing
go test -fuzz=FuzzX -fuzztime=30swhat inputs did I not think of?locally when writing a target; nightly in CI
go test -bench=. -benchmem -run='^$'what does it cost?when changing hot code

The -run='^$' in the last row matters. It matches no test, so only benchmarks execute; without it the benchmark run also runs all your tests.

Keep slow tests separate. A package full of container tests belongs behind a build tag or in its own directory so that go test ./... stays fast enough to run constantly. The unit suite should finish in seconds, because the suite people run is the suite that protects them.

Mistakes that cost the most time

These are the problems I see most often, in the order of how much time they waste:

  1. Asserting on error text. The first wording change breaks the test, or worse, the test passes after a %w became %v. Test identity with errors.Is and errors.As.
  2. Sleeping to wait for something. A time.Sleep(100 * time.Millisecond) passes on a developer machine and fails under CI load. Wait on a channel, a WaitGroup or a condition, or fake the clock.
  3. Shared mutable state between tests. Package-level variables, a shared database table, a global logger. The tests pass alone and fail together, or only with -shuffle=on. Use -shuffle=on occasionally to find these.
  4. Testing the mock. A test that sets up a mock to return X and then asserts that the code returned X has tested the mock. Check behaviour the code adds.
  5. A table with logic in it. If the loop body has branches for which rows are special, split the test.
  6. Coverage as the goal. Lines covered by tests with no assertions give false comfort.
  7. Treating a benchmark number as a fact. One run on a laptop is an anecdote.
  8. Fuzzing without properties. The fuzzer then finds only panics, which is useful, but not the bugs a stated property would find.
  9. Ignoring flaky tests. A test that fails one run in fifty teaches the team to rerun CI. Fix it or delete it.

A reasonable testing setup

For a new service or library, this is a starting point that I would not argue with:

  • Table-driven tests with subtests for every pure function and every handler, using only the standard library plus go-cmp for structs.
  • Small interfaces defined by their consumers, with hand-written doubles; generated mocks only where an interface is wide.
  • A handful of integration tests against the real database engine, in their own package, run in CI with a container.
  • A fuzz target for every parser, decoder and anything that takes untrusted text, with the corpus in testdata/fuzz committed.
  • Benchmarks only for code a profile has shown to be hot, compared with benchstat.
  • go vet, -race and -cover in CI. No coverage gate; a review comment when an error path is untested.

None of it needs a framework. The core of Go testing is a function that takes a *testing.T and uses ordinary code to check ordinary results, and the rest of this series is ways to make that cheaper to write and more likely to catch what matters. The reference for everything above is the testing package documentation, and the commands are documented under go help test.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.