Go Intermediate #6 Testing — testing Package and Table-Driven

7 min read

After #5 context in Depth, this time we look at another of Go’s strengths: a powerful testing tool baked into the standard library.

Your first test #

Go testing runs through the standard testing package and the go test command. No additional libraries required.

add.go
package math

func Add(a, b int) int {
	return a + b
}
add_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	want := 5
	if got != want {
		t.Errorf("Add(2,3) = %d, want %d", got, want)
	}
}

Rules:

  1. File name ends in _test.go
  2. Function name starts with Test, parameter is *testing.T
  3. Can be in the same package, or in package math_test for an external test package

Running #

run tests
go test                  # current package
go test ./...            # all packages
go test -v               # verbose (each test result)
go test -run TestAdd     # only a specific test

go test -v is used often — you can see clearly which tests ran and which failed.

Reporting failure — t.Errorf vs t.Fatalf #

Error vs Fatal
func TestSomething(t *testing.T) {
	if !condition1 {
		t.Errorf("condition 1 failed")    // record failure + keep running
	}
	if !condition2 {
		t.Fatalf("condition 2 failed")    // record failure + stop immediately
	}
	// ...
}
Errorf / ErrorFatalf / Fatal
Records failure
Runs subsequent code✗ (immediate stop)
Fitsindependent checks (multiple in one test)when later code is meaningless

Use Fatal when setup has failed, there’s a nil pointer, or any other case where continuing the test makes no sense.

Table-Driven tests #

When you want to check many inputs to the same function — Go’s most famous testing pattern.

table-driven
func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"positive", 2, 3, 5},
		{"negative", -1, -2, -3},
		{"mixed", -5, 10, 5},
		{"zero", 0, 0, 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := Add(tc.a, tc.b)
			if got != tc.want {
				t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
			}
		})
	}
}

Key points:

  1. Define cases as a slice — adding a new case is one line
  2. Subtests via t.Run(name, ...) — each case runs and reports independently
  3. Each case has a name — instantly clear which one failed

go test -v output:

=== RUN   TestAdd
=== RUN   TestAdd/positive
=== RUN   TestAdd/negative
=== RUN   TestAdd/mixed
=== RUN   TestAdd/zero
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/positive (0.00s)
    --- PASS: TestAdd/negative (0.00s)
    --- PASS: TestAdd/mixed (0.00s)
    --- PASS: TestAdd/zero (0.00s)

Run only a specific subtest #

subtest filter
go test -run "TestAdd/positive"

This is the standard test shape in nearly all Go code. Express cases as data, keep test logic in one place.

Helper functions — t.Helper() #

When you extract a frequently used check into a helper function, failure messages point at the helper rather than the call site, making it hard to see where the test actually failed.

t.Helper()
func assertEqual(t *testing.T, got, want int) {
	t.Helper()    // report this function's caller location
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

func TestAdd(t *testing.T) {
	assertEqual(t, Add(2, 3), 5)
}

A single t.Helper() line — and the failure message points at the caller’s location, not the helper. Almost essential when you write many helpers.

Setup and cleanup — t.Cleanup #

When per-test setup/cleanup is needed.

setup / cleanup
func TestWithDB(t *testing.T) {
	db := setupDB(t)
	t.Cleanup(func() {
		db.Close()
	})

	// test body
}

t.Cleanup runs automatically when the test ends. Regardless of pass/fail. Similar to defer — but it also works inside test helper functions (the cleanup persists until the test ends, even after the helper returns).

Temp directory — t.TempDir #

t.TempDir
func TestFileWrite(t *testing.T) {
	dir := t.TempDir()    // a temp dir cleaned up automatically

	path := filepath.Join(dir, "test.txt")
	os.WriteFile(path, []byte("hi"), 0644)

	// dir is auto-deleted when the test ends
}

A common helper. Shorter and safer than manually wiring os.MkdirTemp and defer os.RemoveAll.

External package tests — _test suffix #

Test files in the same folder usually share the package name — this lets you test unexported functions. But sometimes you want to test from the external viewpoint:

external package test
// math/add_test.go
package math_test    // _test suffix

import (
	"testing"
	"path/to/math"   // import as if from outside
)

func TestAdd(t *testing.T) {
	got := math.Add(2, 3)
	// ...
}

This forces tests to use only the exported API. Big libraries sometimes mix both styles.

Benchmarks — BenchmarkXxx #

benchmark
func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}

Rules:

  1. Function name starts with Benchmark, parameter *testing.B
  2. Code to measure inside for i := 0; i < b.N; i++
  3. b.N is auto-tuned by Go — until iterations are enough for a meaningful measurement
run benchmark
go test -bench=.
go test -bench=. -benchmem    # also measure memory allocations
example output
BenchmarkAdd-8          1000000000      0.30 ns/op

Means a single Add averages 0.30 nanoseconds. Number of CPU cores (8) is shown too.

b.ResetTimer() — exclude setup cost #

ResetTimer
func BenchmarkParse(b *testing.B) {
	data := generateBigData()    // setup — exclude from measurement
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		Parse(data)
	}
}

If setup time is included the result isn’t accurate — call ResetTimer() after setup.

Example functions — documentation and tests #

example
func ExampleAdd() {
	fmt.Println(Add(2, 3))
	// Output: 5
}

A function starting with Example paired with a trailing // Output: comment. It appears as a code example in Go’s godoc/pkg.go.dev and also runs as a test, failing if the actual output doesn’t match.

The standard tool for making library docs richer.

Mocking — Go’s approach #

Go has mocking libraries (testify, gomock), but with interfaces used well, the standard tools alone are often sufficient.

mocking with interfaces
type UserStore interface {
	GetUser(id string) (*User, error)
}

type Service struct {
	store UserStore
}

// real implementation
type DBStore struct{ /* ... */ }
func (s *DBStore) GetUser(id string) (*User, error) { /* DB lookup */ }

// fake for tests
type fakeStore struct {
	users map[string]*User
}
func (s *fakeStore) GetUser(id string) (*User, error) {
	if u, ok := s.users[id]; ok {
		return u, nil
	}
	return nil, ErrNotFound
}

// test
func TestService(t *testing.T) {
	store := &fakeStore{
		users: map[string]*User{"u1": {Name: "Curtis"}},
	}
	svc := &Service{store: store}

	user, err := svc.LookupUser("u1")
	// ...
}

Thanks to implicit implementation from #1 Interfaces — you can plug in a test implementation freely. The standard library is enough without a mocking library.

httptest — HTTP testing #

The standard library has tools for testing HTTP handlers.

httptest.NewServer
import (
	"net/http"
	"net/http/httptest"
)

func TestHandler(t *testing.T) {
	handler := http.HandlerFunc(myHandler)
	server := httptest.NewServer(handler)
	defer server.Close()

	resp, err := http.Get(server.URL + "/api/users")
	// assertions
}

httptest.NewRecorder too — call your handler without spinning up a real server and inspect the result. Detailed in Practice #6 Testing and Deployment.

Coverage — -cover #

coverage
go test -cover
go test -coverprofile=cover.out
go tool cover -html=cover.out      # HTML visualization

Which lines your tests executed — in one command. A surprisingly lightweight tool.

Common test library — testify #

The standard is enough — but as assertions grow long, testify is often used.

testify
import "github.com/stretchr/testify/assert"

func TestSomething(t *testing.T) {
	got := Add(2, 3)
	assert.Equal(t, 5, got)
	assert.NoError(t, err)
	assert.NotNil(t, result)
}

Shorter than if/Errorf and clearer in intent. The standard library is fully sufficient — pick what suits your team’s conventions.

Wrap-up #

What we covered:

  • File _test.go, function Test..., parameter *testing.T
  • t.Errorf (continues) vs t.Fatalf (stops immediately)
  • table-driven + t.Run is the standard Go pattern
  • t.Helper() to show the caller location in helper functions
  • t.Cleanup, t.TempDir — auto-cleanup tools
  • _test-suffixed package for external-viewpoint tests
  • Benchmark... + b.N + -bench=.
  • Example... is both documentation and test
  • Standard mocking via interfaces + fakes
  • Built-in support like httptest, -cover

In the next post (#7 Standard Library Tour) — a sweep through the most-used parts (io, fmt, strings, time, encoding/json, etc.) of Go’s hefty standard library.

X