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 take t *testing.T.
  • t.Errorf reports a failure and keeps going. t.Fatalf reports and stops this test immediately. Use Fatal when 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)
            }
        })
    }
}

Table-driven tests with subtests

Why this works so well:

  • Adding a new case is one line. You’ll actually add edge cases because it’s so cheap.
  • t.Run creates 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.Run for 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() and t.Parallel().
  • Benchmark before optimising, with -benchmem to see allocations.
  • Fuzz anything that parses input.

Recap

  • Tests are TestXxx(t *testing.T) functions in _test.go files, run with go test ./....
  • Table-driven tests plus t.Run subtests are the standard Go style.
  • Small interfaces make faking dependencies trivial.
  • httptest tests 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

  1. Part 1: Why Go, Setting It Up, and Your First Program
  2. Part 2: Variables, Types, Control Flow, and Functions
  3. Part 3: Arrays, Slices, Maps, and Structs
  4. Part 4: Methods, Interfaces, and Composition
  5. Part 5: Error Handling the Go Way
  6. Part 6: Packages, Modules, and Project Layout
  7. Part 7: Goroutines, Channels, and select
  8. Part 8: sync, context, and Concurrency Patterns
  9. Part 9: Generics Without the Headache
  10. Part 10: Testing, Benchmarks, and Fuzzing (you are here)
  11. Part 11: Best Practices for Production-Ready Go
  12. 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.

comments powered by Disqus

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 more

Go 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 more

Go 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