Testing is a first-class citizen in Go. The language includes a built-in testing package and encourages test-driven development. This guide covers everything you need to know about writing effective unit tests in Go. For more context, see Go Installation Guide, Go Ecosystem Overview, Go Best Practices.
Getting Started with Testing
Basic Test Structure
package main
import (
"testing"
)
// Function to test
func Add(a, b int) int {
return a + b
}
// Test function
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 TestAddNegative(t *testing.T) {
result := Add(-2, -3)
expected := -5
if result != expected {
t.Errorf("Add(-2, -3) = %d; want %d", result, expected)
}
}
Running Tests
# Run all tests in current package
go test
# Run with verbose output
go test -v
# Run specific test
go test -run TestAdd
# Run with coverage
go test -cover
# Generate coverage report
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
Test Patterns
Table-Driven Tests
package main
import (
"testing"
)
func TestAddTable(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
{"mixed", 5, -3, 2},
{"zero", 0, 0, 0},
{"with zero", 5, 0, 5},
}
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)
}
})
}
}
Subtests
package main
import (
"testing"
)
func TestMath(t *testing.T) {
t.Run("addition", func(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add failed: got %d, want 5", result)
}
})
t.Run("subtraction", func(t *testing.T) {
result := 5 - 3
if result != 2 {
t.Errorf("Subtraction failed: got %d, want 2", result)
}
})
t.Run("multiplication", func(t *testing.T) {
result := 3 * 4
if result != 12 {
t.Errorf("Multiplication failed: got %d, want 12", result)
}
})
}
Test Helpers and Utilities
Helper Functions
package main
import (
"testing"
)
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestWithHelper(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5)
}
Setup and Teardown
package main
import (
"testing"
)
func TestWithSetup(t *testing.T) {
// Setup
data := []int{1, 2, 3, 4, 5}
// Test
if len(data) != 5 {
t.Errorf("Expected 5 elements, got %d", len(data))
}
// Teardown (if needed)
data = nil
}
func TestWithCleanup(t *testing.T) {
// Setup
resource := "test resource"
// Register cleanup
t.Cleanup(func() {
// Cleanup code
resource = ""
})
// Test
if resource != "test resource" {
t.Errorf("Resource not initialized")
}
}
Testing Different Scenarios
Testing Errors
package main
import (
"errors"
"testing"
)
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func TestDivideError(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("Expected error for division by zero")
}
if err.Error() != "division by zero" {
t.Errorf("Wrong error message: %v", err)
}
}
func TestDivideSuccess(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Expected 5, got %d", result)
}
}
Testing Interfaces
package main
import (
"testing"
)
type Reader interface {
Read() string
}
type MockReader struct {
data string
}
func (m *MockReader) Read() string {
return m.data
}
func ProcessReader(r Reader) string {
return "Processed: " + r.Read()
}
func TestInterface(t *testing.T) {
mock := &MockReader{data: "test"}
result := ProcessReader(mock)
expected := "Processed: test"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
Testing Concurrency
package main
import (
"sync"
"testing"
"time"
)
func TestConcurrency(t *testing.T) {
var wg sync.WaitGroup
results := make([]int, 0)
var mu sync.Mutex
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
results = append(results, n)
mu.Unlock()
}(i)
}
wg.Wait()
if len(results) != 10 {
t.Errorf("Expected 10 results, got %d", len(results))
}
}
func TestTimeout(t *testing.T) {
done := make(chan bool)
go func() {
time.Sleep(100 * time.Millisecond)
done <- true
}()
select {
case <-done:
// Success
case <-time.After(1 * time.Second):
t.Error("Test timed out")
}
}
Practical Testing Examples
Testing String Functions
package main
import (
"strings"
"testing"
)
func TestStringFunctions(t *testing.T) {
tests := []struct {
name string
input string
contains string
expected bool
}{
{"contains", "hello world", "world", true},
{"not contains", "hello world", "xyz", false},
{"empty", "", "test", false},
{"case sensitive", "Hello", "hello", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := strings.Contains(tt.input, tt.contains)
if result != tt.expected {
t.Errorf("Contains(%q, %q) = %v; want %v",
tt.input, tt.contains, result, tt.expected)
}
})
}
}
Testing Slices
package main
import (
"testing"
)
func Contains(slice []int, value int) bool {
for _, v := range slice {
if v == value {
return true
}
}
return false
}
func TestSliceOperations(t *testing.T) {
slice := []int{1, 2, 3, 4, 5}
tests := []struct {
name string
value int
expected bool
}{
{"found", 3, true},
{"not found", 10, false},
{"first", 1, true},
{"last", 5, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Contains(slice, tt.value)
if result != tt.expected {
t.Errorf("Contains(%v, %d) = %v; want %v",
slice, tt.value, result, tt.expected)
}
})
}
}
Testing Maps
package main
import (
"testing"
)
func TestMapOperations(t *testing.T) {
m := map[string]int{
"apple": 5,
"banana": 3,
"cherry": 8,
}
tests := []struct {
name string
key string
expected int
exists bool
}{
{"exists", "apple", 5, true},
{"not exists", "date", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value, ok := m[tt.key]
if ok != tt.exists {
t.Errorf("Key %q exists = %v; want %v", tt.key, ok, tt.exists)
}
if ok && value != tt.expected {
t.Errorf("m[%q] = %d; want %d", tt.key, value, tt.expected)
}
})
}
}
Best Practices
✅ Good Practices
// Use table-driven tests
func TestFunction(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
// Test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test logic
})
}
}
// Use t.Helper() for helper functions
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
// Test error cases
func TestError(t *testing.T) {
_, err := functionThatErrors()
if err == nil {
t.Error("Expected error")
}
}
// Use subtests for organization
t.Run("subtest", func(t *testing.T) {
// Subtest logic
})
❌ Anti-Patterns
// Don't use generic test names
func TestFunction(t *testing.T) {
// Hard to identify which case failed
}
// Don't ignore errors
result, _ := functionThatErrors()
// Should check error
// Don't test implementation details
// Test behavior, not internal state
// Don't create interdependent tests
// Each test should be independent
Resources
Summary
Go’s testing package provides powerful tools for writing effective tests:
- Use table-driven tests for multiple scenarios
- Organize tests with subtests
- Use helper functions for common assertions
- Test both success and error cases
- Keep tests independent and focused
- Aim for good coverage but prioritize meaningful tests
With these practices, you can build reliable, well-tested Go applications.
Comments