Go Intermediate #6 Testing — testing Package and Table-Driven
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.
package math
func Add(a, b int) int {
return a + b
}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:
- File name ends in
_test.go - Function name starts with
Test, parameter is*testing.T - Can be in the same package, or in
package math_testfor an external test package
Running #
go test # current package
go test ./... # all packages
go test -v # verbose (each test result)
go test -run TestAdd # only a specific testgo test -v is used often — you can see clearly which tests ran and which failed.
Reporting failure — t.Errorf vs t.Fatalf
#
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 / Error | Fatalf / Fatal | |
|---|---|---|
| Records failure | ✓ | ✓ |
| Runs subsequent code | ✓ | ✗ (immediate stop) |
| Fits | independent 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.
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:
- Define cases as a slice — adding a new case is one line
- Subtests via
t.Run(name, ...)— each case runs and reports independently - 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 #
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.
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.
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
#
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:
// 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
#
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}Rules:
- Function name starts with
Benchmark, parameter*testing.B - Code to measure inside
for i := 0; i < b.N; i++ b.Nis auto-tuned by Go — until iterations are enough for a meaningful measurement
go test -bench=.
go test -bench=. -benchmem # also measure memory allocationsBenchmarkAdd-8 1000000000 0.30 ns/opMeans a single Add averages 0.30 nanoseconds. Number of CPU cores (8) is shown too.
b.ResetTimer() — exclude setup cost
#
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 #
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.
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.
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
#
go test -cover
go test -coverprofile=cover.out
go tool cover -html=cover.out # HTML visualizationWhich 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.
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, functionTest..., parameter*testing.T t.Errorf(continues) vst.Fatalf(stops immediately)- table-driven +
t.Runis the standard Go pattern t.Helper()to show the caller location in helper functionst.Cleanup,t.TempDir— auto-cleanup tools_test-suffixed package for external-viewpoint testsBenchmark...+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.