Testing in Go: Unit Tests, Benchmarks, Mocks

Testing in Go: unit tests, table-driven tests, benchmarks, and mocks. Practical examples and patterns. A complete guide for Golang developers.
- tags
- #Go #Testing #Programming #Benchmarks #Code Quality
- categories
- Programming
- published
Testing in Go isn’t the pain it is in some other languages. Everything is built into the standard library, no external frameworks needed. go test and you’re off.
But there are nuances. Table-driven tests, subtests, benchmarks, mocks through interfaces — if you don’t know these patterns, you’ll end up writing tests like it’s Java circa 2005. Let’s go through it all.
The basics: your first test
In Go, tests live next to the code in *_test.go files. A test function name starts with Test and takes a *testing.T.
// 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, errors.New("division by zero")
}
return a / b, nil
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %d; want 5", result)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
}
Running it:
go test # run tests in the current package
go test -v # verbose output
go test ./... # all packages recursively
go test -run TestAdd # only tests with "TestAdd" in the name
t.Error vs t.Fatal
t.Error()/t.Errorf()— marks the test as failed but keeps runningt.Fatal()/t.Fatalf()— marks the test as failed and stops immediately
Use t.Fatal when continuing the test doesn’t make sense anymore (for example, when you failed to set up test data).
Table-driven tests: pattern #1 in Go
Writing a separate function for every case is the Java way. In Go, the convention is table-driven tests.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"mixed numbers", -2, 3, 1},
{"zeros", 0, 0, 0},
{"large numbers", 1000000, 2000000, 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)
}
})
}
}
Advantages:
- One test function covers a lot of cases
- Adding a new case is just a new row in the table
t.Runcreates a named subtest, so you can see exactly which case failed- You can run a specific subtest:
go test -run TestAdd/positive
Subtests for grouping
Subtests can be nested for logical grouping:
func TestDivide(t *testing.T) {
t.Run("valid cases", func(t *testing.T) {
tests := []struct {
a, b, expected int
}{
{10, 2, 5},
{9, 3, 3},
{100, 10, 10},
}
for _, tt := range tests {
result, err := Divide(tt.a, tt.b)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
}
})
t.Run("error cases", func(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
})
}
Testing HTTP handlers
For HTTP testing, use httptest — another gift from the standard library.
// handlers.go
package api
import (
"encoding/json"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "John"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// handlers_test.go
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetUserHandler(t *testing.T) {
// Create a fake request
req := httptest.NewRequest(http.MethodGet, "/user", nil)
// Create a ResponseRecorder to capture the response
rr := httptest.NewRecorder()
// Call the handler
GetUserHandler(rr, req)
// Check the status
if rr.Code != http.StatusOK {
t.Errorf("status = %d; want %d", rr.Code, http.StatusOK)
}
// Check the Content-Type
contentType := rr.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %s; want application/json", contentType)
}
// Check the response body
var user User
if err := json.Unmarshal(rr.Body.Bytes(), &user); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if user.ID != 1 || user.Name != "John" {
t.Errorf("user = %+v; want {ID:1 Name:John}", user)
}
}
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
HealthHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("status = %d; want %d", rr.Code, http.StatusOK)
}
if rr.Body.String() != "OK" {
t.Errorf("body = %s; want OK", rr.Body.String())
}
}
Testing with a router
If you’re using a router (chi, gorilla/mux, gin), test it through httptest.Server:
func TestAPIRoutes(t *testing.T) {
// Create a router with handlers
mux := http.NewServeMux()
mux.HandleFunc("/user", GetUserHandler)
mux.HandleFunc("/health", HealthHandler)
// Create a test server
server := httptest.NewServer(mux)
defer server.Close()
// Make a real HTTP request
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
}
Mocking through interfaces
Go doesn’t need mocking frameworks like Mockito. Use interfaces — it’s idiomatic and simple.
// repository.go
package user
type User struct {
ID int
Name string
Email string
}
// Interface for the repository
type UserRepository interface {
GetByID(id int) (*User, error)
Save(user *User) error
}
// The service depends on the interface, not on a concrete implementation
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.GetByID(id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
func (s *UserService) CreateUser(name, email string) (*User, error) {
user := &User{Name: name, Email: email}
if err := s.repo.Save(user); err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
return user, nil
}
// repository_test.go
package user
import (
"errors"
"testing"
)
// Mock repository — just a struct implementing the interface
type mockUserRepository struct {
users map[int]*User
err error // for simulating errors
}
func newMockRepo() *mockUserRepository {
return &mockUserRepository{
users: make(map[int]*User),
}
}
func (m *mockUserRepository) GetByID(id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, errors.New("user not found")
}
return user, nil
}
func (m *mockUserRepository) Save(user *User) error {
if m.err != nil {
return m.err
}
// Simulate auto-increment ID
user.ID = len(m.users) + 1
m.users[user.ID] = user
return nil
}
// Tests using the mock
func TestUserService_GetUser(t *testing.T) {
repo := newMockRepo()
repo.users[1] = &User{ID: 1, Name: "John", Email: "john@example.com"}
service := NewUserService(repo)
user, err := service.GetUser(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "John" {
t.Errorf("user.Name = %s; want John", user.Name)
}
}
func TestUserService_GetUser_NotFound(t *testing.T) {
repo := newMockRepo()
service := NewUserService(repo)
_, err := service.GetUser(999)
if err == nil {
t.Error("expected error for non-existent user")
}
}
func TestUserService_GetUser_RepoError(t *testing.T) {
repo := newMockRepo()
repo.err = errors.New("database connection failed")
service := NewUserService(repo)
_, err := service.GetUser(1)
if err == nil {
t.Error("expected error when repo fails")
}
}
func TestUserService_CreateUser(t *testing.T) {
repo := newMockRepo()
service := NewUserService(repo)
user, err := service.CreateUser("Alice", "alice@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.ID == 0 {
t.Error("user.ID should be set after save")
}
if user.Name != "Alice" {
t.Errorf("user.Name = %s; want Alice", user.Name)
}
}
The principle: dependencies are passed through interfaces → tests substitute mocks → no global variables, no magic.
By the way, if you want to see examples of bad code without tests, check out the article on Go anti-patterns . It has code that’s essentially untestable.
Benchmarks: measuring performance
Benchmarks in Go are functions with Benchmark in the name and a *testing.B parameter.
// benchmark_test.go
package math
import "testing"
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
func BenchmarkDivide(b *testing.B) {
for i := 0; i < b.N; i++ {
Divide(100, 5)
}
}
Running them:
go test -bench=. # all benchmarks
go test -bench=BenchmarkAdd # a specific benchmark
go test -bench=. -benchmem # + memory stats
go test -bench=. -benchtime=5s # 5 seconds per benchmark
go test -bench=. -count=5 # 5 runs for statistics
Output:
BenchmarkAdd-8 1000000000 0.2900 ns/op 0 B/op 0 allocs/op
BenchmarkDivide-8 847282562 1.418 ns/op 0 B/op 0 allocs/op
BenchmarkAdd-8— the name and the number of cores (GOMAXPROCS)1000000000— how many times the loop ran (b.N)0.2900 ns/op— nanoseconds per operation0 B/op— bytes of memory per operation0 allocs/op— allocations per operation
Comparing implementations
Benchmarks are great for comparing different implementations:
// String concatenation: three approaches
func ConcatPlus(strs []string) string {
result := ""
for _, s := range strs {
result += s
}
return result
}
func ConcatBuilder(strs []string) string {
var builder strings.Builder
for _, s := range strs {
builder.WriteString(s)
}
return builder.String()
}
func ConcatJoin(strs []string) string {
return strings.Join(strs, "")
}
// benchmark_test.go
var testStrings = []string{"hello", "world", "foo", "bar", "baz"}
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(testStrings)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(testStrings)
}
}
func BenchmarkConcatJoin(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatJoin(testStrings)
}
}
BenchmarkConcatPlus-8 5765996 207.3 ns/op 56 B/op 4 allocs/op
BenchmarkConcatBuilder-8 12491854 96.01 ns/op 64 B/op 2 allocs/op
BenchmarkConcatJoin-8 21663990 55.36 ns/op 32 B/op 1 allocs/op
You can see that strings.Join is 4 times faster than naive concatenation with +.
Benchmarks with varying input sizes
func BenchmarkConcatJoin(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
strs := make([]string, size)
for i := range strs {
strs[i] = "x"
}
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatJoin(strs)
}
})
}
}
BenchmarkConcatJoin/size=10-8 15462980 77.54 ns/op
BenchmarkConcatJoin/size=100-8 2994873 400.7 ns/op
BenchmarkConcatJoin/size=1000-8 364911 3287 ns/op
BenchmarkConcatJoin/size=10000-8 38913 30823 ns/op
For more on performance optimization and when to use channels vs. mutexes, see the article on concurrency in Go .
Code coverage
go test -cover # coverage percentage
go test -coverprofile=coverage.out # save to a file
go tool cover -html=coverage.out # open in a browser
go tool cover -func=coverage.out # coverage by function
-func output:
math.go:5: Add 100.0%
math.go:9: Divide 100.0%
total: (statements) 100.0%
Coverage in CI
# .gitlab-ci.yml
test:
script:
- go test -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out | grep total
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
TestMain: setup and teardown
If you need global setup/teardown for all tests in a package:
func TestMain(m *testing.M) {
// Setup
fmt.Println("Setting up tests...")
db := setupTestDB()
// Run the tests
code := m.Run()
// Teardown
fmt.Println("Cleaning up...")
db.Close()
os.Exit(code)
}
Testing with temporary files
func TestFileProcessing(t *testing.T) {
// t.TempDir() is automatically removed after the test
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(filePath, []byte("test content"), 0644); err != nil {
t.Fatal(err)
}
// Test the function that works with the file
result, err := ProcessFile(filePath)
if err != nil {
t.Fatalf("ProcessFile failed: %v", err)
}
// Check the result
if result != "expected" {
t.Errorf("result = %s; want expected", result)
}
}
Parallel tests
func TestParallel(t *testing.T) {
tests := []struct {
name string
input int
}{
{"case1", 1},
{"case2", 2},
{"case3", 3},
}
for _, tt := range tests {
tt := tt // important! capture the loop variable for the goroutine
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // this subtest can run in parallel
// ... test logic
time.Sleep(100 * time.Millisecond)
})
}
}
Important: tt := tt is a Go quirk. Without it, every goroutine would use the last value of tt from the loop.
Running with controlled parallelism:
go test -parallel 4 # at most 4 parallel tests
Useful practices
1. Naming tests
// Good: it's clear what's being tested and which case
func TestUserService_GetUser_ReturnsUser(t *testing.T) {}
func TestUserService_GetUser_NotFound(t *testing.T) {}
func TestUserService_GetUser_DatabaseError(t *testing.T) {}
// Bad: unclear what's being tested
func TestGetUser(t *testing.T) {}
func TestGetUser2(t *testing.T) {}
func TestError(t *testing.T) {}
2. Use testify for assertions (optional)
import "github.com/stretchr/testify/assert"
func TestAdd(t *testing.T) {
assert.Equal(t, 5, Add(2, 3))
assert.NotNil(t, someValue)
assert.Contains(t, "hello world", "world")
}
3. Golden files for complex outputs
func TestGenerateReport(t *testing.T) {
result := GenerateReport(testData)
goldenFile := "testdata/report.golden"
if *update {
os.WriteFile(goldenFile, []byte(result), 0644)
}
expected, _ := os.ReadFile(goldenFile)
if result != string(expected) {
t.Errorf("result differs from golden file")
}
}
4. Test the public API, not internals
// Good: testing behavior
func TestCalculator_Add(t *testing.T) {
calc := NewCalculator()
result := calc.Add(2, 3)
assert.Equal(t, 5, result)
}
// Bad: testing private methods
func TestCalculator_internalAdd(t *testing.T) {
// This test will break with any refactoring
}
Conclusion
Testing in Go is simple:
*_test.gofiles,Test*functions,go test- Table-driven tests for handling many cases
- Mocks through interfaces, no frameworks
- Benchmarks for measuring performance
- Coverage out of the box
The core principles:
- Test behavior, not implementation
- Use table-driven tests
- Mock through interfaces
- Benchmark before optimizing
If you want to automate writing tests, Cursor AI does a decent job of generating table-driven tests from an example.
Have questions about testing in Go? Leave a comment — happy to dig into specific cases.