Go Testing — Table Tests Benchmarks Coverage and Test Suites Explained
In this tutorial, you will learn about Go Testing. We cover key concepts, practical examples, and best practices to help you master this topic.
Go testing uses the built-in testing package with t *testing.T for assertions, table-driven tests for multiple cases, test coverage via go test -cover, and benchmarks with t *testing.B for performance measurement.
What You'll Learn
- Writing unit tests with the testing package
- Table-driven test patterns
- Running benchmarks
- Test coverage analysis
Why It Matters
Testing is built into Go's toolchain. Docker runs thousands of tests on every commit. Kubernete uses extensive table-driven tests. DodaZIP tests all archive formats with benchmarks. Go's testing philosophy: tests are code, not magic.
Real-World Use
CI/CD pipelines, pre-commit hooks, code review gates. Every Go project uses the same testing package with the same patterns.
flowchart LR
A["Testing"] --> B["Unit Tests"]
B --> C["Table Tests"]
C --> D["Benchmarks"]
D --> E["Coverage"]
A:::current --> B
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Basic Unit Test
// math.go
package math
func Add(a, b int) int {
return a + b
}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatal("unexpected error:", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %d; want 5", result)
}
}
go test -v
# === RUN TestAdd
# --- PASS: TestAdd (0.00s)
# === RUN TestDivide
# --- PASS: TestDivide (0.00s)
# PASS
# ok example.com/math 0.002s
Table-Driven Tests
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{name: "positive numbers", a: 2, b: 3, expected: 5},
{name: "negative numbers", a: -1, b: -2, expected: -3},
{name: "zero", a: 0, b: 5, expected: 5},
{name: "large numbers", a: 1000000, b: 2000000, expected: 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
wantError bool
}{
{name: "normal division", a: 10, b: 2, expected: 5, wantError: false},
{name: "division by zero", a: 5, b: 0, wantError: true},
{name: "negative division", a: -6, b: 3, expected: -2, wantError: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.wantError {
if err == nil {
t.Error("expected error but got none")
}
return
}
if err != nil {
t.Fatal("unexpected error:", err)
}
if result != tt.expected {
t.Errorf("Divide(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
Helper Functions
func TestUserValidation(t *testing.T) {
user := &User{Name: "Alice", Age: 30}
err := ValidateUser(user)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Helper for common setup
func setupTest(t *testing.T) *DB {
t.Helper() // Marks as helper — stack traces skip it
db, err := connectToTestDB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
Running Tests
# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run specific test
go test -run TestAdd ./...
# Run tests in package
go test ./math/...
# Run tests with timeout
go test -timeout 30s ./...
# Run tests multiple times (flake detection)
go test -count=3 ./...
# Short mode (skip long tests)
go test -short ./...
Test Coverage
# Run with coverage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View coverage in browser
go cover -html=coverage.out
# Coverage per function
go test -cover -covermode=count ./...
Writing for Coverage
func Process(input string) string {
if input == "" {
return "default"
}
if len(input) < 3 {
return input
}
return input[:3]
}
func TestProcess(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{name: "empty", input: "", expected: "default"},
{name: "short", input: "ab", expected: "ab"},
{name: "long", input: "abcdef", expected: "abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Process(tt.input)
if result != tt.expected {
t.Errorf("got %q; want %q", result, tt.expected)
}
})
}
}
Benchmarking
// Benchmark basic operation
func BenchmarkAdd(b *testing.B) {
a, c := 5, 3
for i := 0; i < b.N; i++ {
Add(a, c)
}
}
Realistic Benchmark
// functions.go
func SumSlice(items []int) int {
total := 0
for _, v := range items {
total += v
}
return total
}
// functions_test.go
func BenchmarkSumSlice(b *testing.B) {
items := make([]int, 1000)
for i := range items {
items[i] = i
}
b.ResetTimer() // Exclude setup time
for i := 0; i < b.N; i++ {
SumSlice(items)
}
}
// Running specific benchmark
// go test -bench=BenchmarkSumSlice -benchmem ./...
Benchmark Comparison
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"a", "b", "c", "d", "e"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
result := ""
for _, p := range parts {
result += p
}
_ = result
}
})
b.Run("strings.Join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
result := strings.Join(parts, "")
_ = result
}
})
b.Run("strings.Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
}
// Example output:
// BenchmarkStringConcat/plus-8 5000000 312 ns/op
// BenchmarkStringConcat/strings.Join-8 10000000 152 ns/op
// BenchmarkStringConcat/Builder-8 10000000 134 ns/op
Subtests
func TestUser(t *testing.T) {
// Setup shared across subtests
db := setupTestDB(t)
t.Run("create", func(t *testing.T) {
u, err := CreateUser(db, "Alice", "alice@test.com")
if err != nil {
t.Fatal(err)
}
if u.Name != "Alice" {
t.Errorf("got name %q; want %q", u.Name, "Alice")
}
})
t.Run("find", func(t *testing.T) {
u, err := FindUser(db, 1)
if err != nil {
t.Fatal(err)
}
if u == nil {
t.Fatal("user not found")
}
})
t.Run("delete", func(t *testing.T) {
err := DeleteUser(db, 1)
if err != nil {
t.Fatal(err)
}
})
}
Test Fixtures
// testdata/testdata.go
package testdata
func LoadTestUsers() []User {
return []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
}
// test file
func TestWithFixtures(t *testing.T) {
users := testdata.LoadTestUsers()
if len(users) != 2 {
t.Fatal("expected 2 users")
}
}
Common Mistakes
1. Not Using t.Helper()
func assertEqual(t *testing.T, got, want interface{}) {
// Without t.Helper(), failure line points here, not the caller
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
2. Using t.Error Instead of t.Fatal in Setup
// Bad — continues on failure
db, err := connectDB()
if err != nil {
t.Error(err) // Test continues without valid DB
}
// Good — stops immediately
db, err := connectDB()
if err != nil {
t.Fatal(err) // Stops test
}
3. Not Running Tests in Clean State
// Tests sharing state without cleanup
var cache map[string]string
func TestCache(t *testing.T) {
// Bad — previous test's data persists
cache = make(map[string]string) // Should be in setup
}
4. Only Testing the Happy Path
// Bad — no error cases
func TestDivide(t *testing.T) {
result, _ := Divide(10, 2)
assertEqual(t, result, 5)
}
// Good — test errors too
func TestDivide_Error(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
}
5. Not Using -count=1 for Cached Results
# Go caches test results. Use -count=1 to disable:
go test -count=1 ./...
Practice Questions
1. How do you write a table-driven test?
Define a slice of test cases (struct with name, inputs, expected outputs), iterate with t.Run(tt.name, ...), and check results. This pattern is idiomatic Go.
2. What's the difference between t.Error and t.Fatal?
t.Error marks the test as failed but continues execution. t.Fatal marks as failed and stops the current test immediately. Use t.Fatal in setup; use t.Error for assertions.
3. How do you run a specific test?
go test -run TestAdd runs tests matching "TestAdd". go test -run TestAdd/positive runs the "positive" subtest. -run takes a regex pattern.
4. How do you measure test coverage?
go test -cover shows coverage percentage. go test -coverprofile=out generates a profile. go tool cover -html=out opens an HTML visualization showing covered/uncovered lines.
Challenge: Write a comprehensive test suite for a StringSet type (a set of strings) including table-driven tests for all operations and a benchmark comparing different implementations.
Solution
// stringset.go
package stringset
type StringSet struct {
items map[string]struct{}
}
func New() *StringSet {
return &StringSet{items: make(map[string]struct{})}
}
func (s *StringSet) Add(item string) {
s.items[item] = struct{}{}
}
func (s *StringSet) Remove(item string) {
delete(s.items, item)
}
func (s *StringSet) Contains(item string) bool {
_, ok := s.items[item]
return ok
}
func (s *StringSet) Size() int {
return len(s.items)
}
func (s *StringSet) Items() []string {
result := make([]string, 0, len(s.items))
for item := range s.items {
result = append(result, item)
}
return result
}
// stringset_test.go
package stringset
import "testing"
func TestStringSet(t *testing.T) {
tests := []struct {
name string
ops func(s *StringSet)
check func(t *testing.T, s *StringSet)
}{
{
name: "add and contains",
ops: func(s *StringSet) { s.Add("a"); s.Add("b"); s.Add("a") },
check: func(t *testing.T, s *StringSet) {
if !s.Contains("a") { t.Error("expected contains a") }
if !s.Contains("b") { t.Error("expected contains b") }
if s.Contains("c") { t.Error("unexpected contains c") }
},
},
{
name: "remove",
ops: func(s *StringSet) { s.Add("a"); s.Add("b"); s.Remove("a") },
check: func(t *testing.T, s *StringSet) {
if s.Contains("a") { t.Error("unexpected contains a") }
if !s.Contains("b") { t.Error("expected contains b") }
},
},
{
name: "size",
ops: func(s *StringSet) { s.Add("a"); s.Add("b"); s.Add("c") },
check: func(t *testing.T, s *StringSet) {
if s.Size() != 3 { t.Errorf("got size %d; want 3", s.Size()) }
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New()
if tt.ops != nil {
tt.ops(s)
}
tt.check(t, s)
})
}
}
func BenchmarkStringSet(b *testing.B) {
elements := []string{"a", "b", "c", "d", "e"}
s := New()
b.Run("add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s.Add(elements[i%len(elements)])
}
})
b.Run("contains", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s.Contains(elements[i%len(elements)])
}
})
}
go test -v -bench=. -cover
FAQ
{{< faq question="What file naming convention do tests use?" >}}
Files ending with _test.go. Example: math_test.go tests math.go. Test functions start with Test. Benchmark functions start with Benchmark.
{{< /faq >}}
{{< faq question="Can I put tests in a different package?" >}}
Yes. Use package math_test for external tests (black-box) or package math for internal tests (white-box). External tests can only access exported identifiers.
{{< /faq >}}
{{< faq question="How do I test HTTP handlers?" >}}
Use httptest.NewRecorder and httptest.NewServer. Create a request, call the handler, inspect the response recorder. No need to start a real server.
{{< /faq >}}
{{< faq question="What is go test -short?" >}}
A flag for tests to skip long-running or integration tests. Check testing.Short() in your test and call t.Skip("skipping in short mode").
{{< /faq >}}
{{< faq question="How do I test main()?" >}}
Rarely needed. Extract logic into functions and test those. If needed, use os.Args manipulation and call main() directly.
{{< /faq >}}
Try It Yourself
// calculator.go
package calculator
import "errors"
func Add(a, b float64) float64 { return a + b }
func Subtract(a, b float64) float64 { return a - b }
func Multiply(a, b float64) float64 { return a * b }
func Divide(a, b float64) (float64, error) {
if b == 0 { return 0, errors.New("division by zero") }
return a / b, nil
}
// calculator_test.go
package calculator
import "testing"
func TestOperations(t *testing.T) {
tests := []struct {
name string
op func() (float64, error)
want float64
}{
{name: "add", op: func() (float64, error) { return Add(5, 3), nil }, want: 8},
{name: "subtract", op: func() (float64, error) { return Subtract(10, 4), nil }, want: 6},
{name: "multiply", op: func() (float64, error) { return Multiply(3, 7), nil }, want: 21},
{name: "divide", op: func() (float64, error) { return Divide(15, 3) }, want: 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.op()
if err != nil { t.Fatal(err) }
if got != tt.want { t.Errorf("got %v; want %v", got, tt.want) }
})
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(5, 0)
if err == nil { t.Error("expected error") }
}
go test -v -cover
What's Next
Now that you understand testing, learn about goroutines for concurrent programming in Go.
| Topic | Description | Link |
|---|---|---|
| Go Goroutines | Concurrency, goroutines, sync | {{< ref "17-goroutines" >}} |
| Go Channels | Communication, buffering, select | {{< ref "18-channels" >}} |
| Rust Testing | Compare Rust's testing framework | Rust |