Go Unit Testing: Structure & Best Practices

Go testing from basics to advanced patterns

Page content

Go’s built-in testing package provides a powerful, minimalist framework for writing unit tests without external dependencies. Here are the testing fundamentals, project structure, and advanced patterns to build reliable Go applications.

Go Unit Testing is awesome

Why Testing Matters in Go

Go’s philosophy emphasizes simplicity and reliability. The standard library includes the testing package, making unit testing a first-class citizen in the Go ecosystem. Well-tested Go code improves maintainability, catches bugs early, and provides documentation through examples. If you’re new to Go, check out our Go Cheat Sheet for a quick reference of the language fundamentals.

Key benefits of Go testing:

  • Built-in support: No external frameworks required
  • Fast execution: Concurrent test execution by default
  • Simple syntax: Minimal boilerplate code
  • Rich tooling: Coverage reports, benchmarks, and profiling
  • CI/CD friendly: Easy integration with automated pipelines

Project Structure for Go Tests

Go tests live alongside your production code with a clear naming convention:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Key conventions:

  • Test files end with _test.go
  • Tests are in the same package as the code (or use _test suffix for black-box testing)
  • Each source file can have a corresponding test file

Package Testing Approaches

White-box testing (same package):

package calculator

import "testing"
// Can access unexported functions and variables

Black-box testing (external package):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Can only access exported functions (recommended for public APIs)

Basic Test Structure

Every test function follows this pattern:

package calculator

import "testing"

// Test function must start with "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Testing.T methods:

  • t.Error() / t.Errorf(): Mark test as failed but continue
  • t.Fatal() / t.Fatalf(): Mark test as failed and stop immediately
  • t.Log() / t.Logf(): Log output (only shown with -v flag)
  • t.Skip() / t.Skipf(): Skip the test
  • t.Parallel(): Run test in parallel with other parallel tests

Table-Driven Tests: The Go Way

Table-driven tests are the idiomatic Go approach for testing multiple scenarios. With Go generics, you can also create type-safe test helpers that work across different data types:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division by zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Advantages:

  • Single test function for multiple scenarios
  • Easy to add new test cases
  • Clear documentation of expected behavior
  • Better test organization and maintainability

Running Tests

Basic Commands

# Run tests in current directory
go test

# Run tests with verbose output
go test -v

# Run tests in all subdirectories
go test ./...

# Run specific test
go test -run TestAdd

# Run tests matching pattern
go test -run TestCalculate/addition

# Run tests in parallel (default is GOMAXPROCS)
go test -parallel 4

# Run tests with timeout
go test -timeout 30s

Test Coverage

# Run tests with coverage
go test -cover

# Generate coverage profile
go test -coverprofile=coverage.out

# View coverage in browser
go tool cover -html=coverage.out

# Show coverage by function
go tool cover -func=coverage.out

# Set coverage mode (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Useful Flags

  • -short: Run tests marked with if testing.Short() checks
  • -race: Enable race detector (finds concurrent access issues)
  • -cpu: Specify GOMAXPROCS values
  • -count n: Run each test n times
  • -failfast: Stop on first test failure

Test Helpers and Setup/Teardown

Helper Functions

Mark helper functions with t.Helper() to improve error reporting:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // This line is reported as the caller
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Error line points here
}

Setup and Teardown

func TestMain(m *testing.M) {
    // Setup code here
    setup()
    
    // Run tests
    code := m.Run()
    
    // Teardown code here
    teardown()
    
    os.Exit(code)
}

Test Fixtures

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Test code here
}

Mocking and Dependency Injection

Interface-Based Mocking

When testing code that interacts with databases, using interfaces makes it easy to create mock implementations. If you’re working with PostgreSQL in Go, see our comparison of Go ORMs for choosing the right database library with good testability.

// Production code
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Test code
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Testify

The most popular Go testing library for assertions and mocks:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "they should be equal")
    assert.NotNil(t, result)
}

// Mock example
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Other Tools

  • gomock: Google’s mocking framework with code generation
  • httptest: Standard library for testing HTTP handlers
  • testcontainers-go: Integration testing with Docker containers
  • ginkgo/gomega: BDD-style testing framework

When testing integrations with external services like AI models, you’ll need to mock or stub those dependencies. For example, if you’re using Ollama in Go, consider creating interface wrappers to make your code more testable.

Benchmark Tests

Go includes built-in support for benchmarks:

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

// Run benchmarks
// go test -bench=. -benchmem

Output shows iterations per second and memory allocations.

Best Practices

  1. Write table-driven tests: Use the slice of structs pattern for multiple test cases
  2. Use t.Run for subtests: Better organization and can run subtests selectively
  3. Test exported functions first: Focus on public API behavior
  4. Keep tests simple: Each test should verify one thing
  5. Use meaningful test names: Describe what is being tested and expected outcome
  6. Don’t test implementation details: Test behavior, not internals
  7. Use interfaces for dependencies: Makes mocking easier
  8. Aim for high coverage, but quality over quantity: 100% coverage doesn’t mean bug-free
  9. Run tests with -race flag: Catch concurrency issues early
  10. Use TestMain for expensive setup: Avoid repeating setup in each test

Example: Complete Test Suite

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("name cannot be empty")
    }
    if u.Email == "" {
        return errors.New("email cannot be empty")
    }
    return nil
}

// Test file: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid user",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "empty name",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "name cannot be empty",
        },
        {
            name:    "empty email",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "email cannot be empty",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Conclusion

Go’s testing framework provides everything needed for comprehensive unit testing with minimal setup. By following Go idioms like table-driven tests, using interfaces for mocking, and leveraging built-in tools, you can create maintainable, reliable test suites that grow with your codebase.

These testing practices apply to all types of Go applications, from web services to CLI applications built with Cobra & Viper. Testing command-line tools requires similar patterns with additional focus on testing input/output and flag parsing.

Start with simple tests, gradually add coverage, and remember that testing is an investment in code quality and developer confidence. The Go community’s emphasis on testing makes it easier to maintain projects long-term and collaborate effectively with team members.