Go From Zero, Part 10: Testing, Benchmarks, and Fuzzing
This is Part 10 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 9: Generics Without the Headache is the place to catch up.
We’ve covered a lot of ground. In Part 9 we learned generics, and before that concurrency, errors and interfaces. Now let’s make sure all that code actually does what we think it does.
Testing in Go needs no framework, no plugins and no configuration. It’s built into the go command, it’s fast, and the conventions are so consistent that you can open any Go project and immediately find and run its tests. I genuinely think it’s one of the best parts of the language.
Your first test
Tests live in files ending with _test.go, right next to the code they test. Here’s a small package:
// file: mathx/mathx.go
package mathx
// Abs returns the absolute value of n.
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
And its test:
// file: mathx/mathx_test.go
package mathx
import "testing"
func TestAbs(t *testing.T) {
got := Abs(-5)
want := 5
if got != want {
t.Errorf("Abs(-5) = %d, want %d", got, want)
}
}
Run it:
go test ./...
# ok example.com/shop/mathx 0.002s
go test -v ./... # verbose: shows each test by name
The rules:
- Test files end in
_test.go. They’re excluded from your normal build. - Test functions start with
Test, followed by a capitalised name, and taket *testing.T. t.Errorfreports a failure and keeps going.t.Fatalfreports and stops this test immediately. UseFatalwhen continuing makes no sense, like when setup fails.
Notice the error message format: Abs(-5) = 3, want 5. Say what you called, what you got and what you wanted. When a test fails in CI six months from now, that message is all you’ll have.
Table-driven tests: the Go way
Most Go tests follow one pattern: a table of cases and a loop. It’s the single most useful testing habit in Go.
package mathx
import "testing"
func TestAbsTable(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{name: "positive", in: 5, want: 5},
{name: "negative", in: -5, want: 5},
{name: "zero", in: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Abs(tt.in); got != tt.want {
t.Errorf("Abs(%d) = %d, want %d", tt.in, got, tt.want)
}
})
}
}

Why this works so well:
- Adding a new case is one line. You’ll actually add edge cases because it’s so cheap.
t.Runcreates a subtest for each case, so failures show exactly which case broke:TestAbsTable/negative.- You can run one case on its own:
go test -run 'TestAbsTable/zero' ./....
Testing errors
From Part 5, we check errors with errors.Is and errors.As. Tests do the same. Here’s a function and a table test that covers both success and failure:
// file: config/port.go
package config
import (
"errors"
"fmt"
"strconv"
)
var ErrPortRange = errors.New("port out of range")
func ParsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parsing port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d: %w", n, ErrPortRange)
}
return n, nil
}
// file: config/port_test.go
package config
import (
"errors"
"testing"
)
func TestParsePort(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr error
}{
{name: "valid", in: "8080", want: 8080},
{name: "too big", in: "70000", wantErr: ErrPortRange},
{name: "zero", in: "0", wantErr: ErrPortRange},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePort(tt.in)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ParsePort(%q) error = %v, want %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParsePort(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
t.Run("not a number", func(t *testing.T) {
if _, err := ParsePort("abc"); err == nil {
t.Error("expected an error for non-numeric input")
}
})
}
errors.Is(nil, nil) is true, so the success cases pass the error check naturally.
Faking dependencies with small interfaces
Remember Part 4’s advice to define small interfaces where they’re used? This is where it pays off. Say our signup code sends a welcome email:
// file: signup/signup.go
package signup
import "fmt"
// Mailer is the only thing signup needs from an email client.
type Mailer interface {
Send(to, subject string) error
}
type Service struct {
mail Mailer
}
func NewService(m Mailer) *Service { return &Service{mail: m} }
func (s *Service) Register(email string) error {
// ... save the user ...
if err := s.mail.Send(email, "Welcome!"); err != nil {
return fmt.Errorf("sending welcome email: %w", err)
}
return nil
}
In production, you pass a real email client. In tests, a tiny fake:
// file: signup/signup_test.go
package signup
import (
"errors"
"testing"
)
type fakeMailer struct {
sentTo []string
err error
}
func (f *fakeMailer) Send(to, subject string) error {
f.sentTo = append(f.sentTo, to)
return f.err
}
func TestRegisterSendsWelcome(t *testing.T) {
fm := &fakeMailer{}
svc := NewService(fm)
if err := svc.Register("asha@example.com"); err != nil {
t.Fatalf("Register() error = %v", err)
}
if len(fm.sentTo) != 1 || fm.sentTo[0] != "asha@example.com" {
t.Errorf("sent to %v, want [asha@example.com]", fm.sentTo)
}
}
func TestRegisterMailFailure(t *testing.T) {
boom := errors.New("smtp down")
svc := NewService(&fakeMailer{err: boom})
err := svc.Register("ravi@example.com")
if !errors.Is(err, boom) {
t.Errorf("Register() error = %v, want it to wrap %v", err, boom)
}
}
No mocking library, no code generation. A struct with the right method is all it takes.
Helpers, cleanup and temp files
A few testing.T methods make tests cleaner:
t.Helper()marks a function as a helper, so failures point to the line in your test that called it, not inside the helper.t.Cleanup(func(){...})registers cleanup that runs when the test finishes.t.TempDir()gives you a fresh directory that’s deleted automatically.t.Parallel()lets independent tests run at the same time.
func newTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("opening test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
Testing HTTP handlers
The net/http/httptest package lets you test handlers without starting a real server. We’ll use this a lot in Part 12.
// file: api/health_test.go
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
health(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if body := rec.Body.String(); body != "ok" {
t.Errorf("body = %q, want %q", body, "ok")
}
}
Coverage
go test -cover ./...
go test -coverprofile=cover.out ./... && go tool cover -html=cover.out
The HTML view highlights exactly which lines your tests never ran. Use coverage to find blind spots, not as a score to chase. 100% coverage of trivial getters is worth less than solid tests of the tricky parts.
Benchmarks: measure, don’t guess
Benchmarks also live in _test.go files. Since Go 1.24, the clean way to write one is with b.Loop():
// file: strjoin/join_test.go
package strjoin
import (
"strings"
"testing"
)
var words = strings.Fields("the quick brown fox jumps over the lazy dog")
func BenchmarkPlus(b *testing.B) {
for b.Loop() {
s := ""
for _, w := range words {
s += w + " "
}
}
}
func BenchmarkBuilder(b *testing.B) {
for b.Loop() {
var sb strings.Builder
for _, w := range words {
sb.WriteString(w)
sb.WriteByte(' ')
}
_ = sb.String()
}
}
go test -bench=. -benchmem ./strjoin
You’ll see nanoseconds per operation and allocations per operation for each approach. On my machine, the strings.Builder version was roughly 1.7× faster and made less than half as many allocations. The gap grows quickly as the number of words grows. (On Go versions before 1.24, you’ll see the older for i := 0; i < b.N; i++ style instead. It does the same job.)
The golden rule of performance work: measure first. Your intuition about what’s slow is often wrong, and benchmarks keep you honest. Part 11 shows how to profile a whole program.
Fuzzing: let Go find the edge cases
Fuzz tests feed your function huge numbers of random, mutated inputs, looking for crashes or broken invariants. They’re brilliant for parsers and anything that handles user input.
// file: textx/reverse_test.go
package textx
import (
"testing"
"unicode/utf8"
)
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 FuzzReverse(f *testing.F) {
f.Add("hello") // seed inputs
f.Add("héllo 👋")
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip() // only valid text is interesting here
}
rev := Reverse(s)
if Reverse(rev) != s {
t.Errorf("double reverse of %q gave %q", s, Reverse(rev))
}
})
}
go test ./textx # runs seeds as normal tests
go test -fuzz=FuzzReverse -fuzztime=10s ./textx
When the fuzzer finds a failing input, it saves it under testdata/fuzz/ so it becomes a permanent regression test.
Best practices for testing
- Use table-driven tests with
t.Runfor anything with more than one interesting input. - Write clear failure messages:
Func(input) = got, want want. - Test behaviour, not implementation. Test through exported functions so you can refactor freely.
- Fake dependencies with small interfaces instead of reaching for mocking frameworks.
- Run
go test -race ./...in CI, always. - Keep tests fast and independent. Use
t.TempDir(),t.Cleanup()andt.Parallel(). - Benchmark before optimising, with
-benchmemto see allocations. - Fuzz anything that parses input.
Recap
- Tests are
TestXxx(t *testing.T)functions in_test.gofiles, run withgo test ./.... - Table-driven tests plus
t.Runsubtests are the standard Go style. - Small interfaces make faking dependencies trivial.
httptesttests HTTP handlers without a server.- Benchmarks (
BenchmarkXxx,b.Loop()) and fuzz tests (FuzzXxx) are built in too.
Try this: write a table-driven test for the Stack[T] from Part 9. Cover pushing and popping several values, and popping from an empty stack.
In Part 11, we’ll pull together the habits that separate hobby Go code from production Go code: idioms, tooling, logging, configuration, graceful shutdown and profiling.
Where to go from here
⬅️ Previous: Part 9: Generics Without the Headache
➡️ Next: Part 11: Best Practices for Production-Ready Go
The full Go From Zero series
- Part 1: Why Go, Setting It Up, and Your First Program
- Part 2: Variables, Types, Control Flow, and Functions
- Part 3: Arrays, Slices, Maps, and Structs
- Part 4: Methods, Interfaces, and Composition
- Part 5: Error Handling the Go Way
- Part 6: Packages, Modules, and Project Layout
- Part 7: Goroutines, Channels, and select
- Part 8: sync, context, and Concurrency Patterns
- Part 9: Generics Without the Headache
- Part 10: Testing, Benchmarks, and Fuzzing (you are here)
- Part 11: Best Practices for Production-Ready Go
- Part 12: Building a REST API with the Standard Library
If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.
Releted Posts
Go From Zero, Part 9: Generics Without the Headache
This is Part 9 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 8: sync, context, and Concurrency Patterns is the place to catch up.
Read moreGo From Zero, Part 8: sync, context, and Concurrency Patterns
This is Part 8 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 7: Goroutines, Channels, and select is the place to catch up.
Read moreGo From Zero, Part 7: Goroutines, Channels, and select
This is Part 7 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 6: Packages, Modules, and Project Layout is the place to catch up.
Read more