Go Unit Testing: Structure & Best Practices
Go testing from basics to advanced patterns
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.

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
_testsuffix 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 continuet.Fatal()/t.Fatalf(): Mark test as failed and stop immediatelyt.Log()/t.Logf(): Log output (only shown with-vflag)t.Skip()/t.Skipf(): Skip the testt.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 withif 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)
}
}
Popular Testing Libraries
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
- Write table-driven tests: Use the slice of structs pattern for multiple test cases
- Use t.Run for subtests: Better organization and can run subtests selectively
- Test exported functions first: Focus on public API behavior
- Keep tests simple: Each test should verify one thing
- Use meaningful test names: Describe what is being tested and expected outcome
- Don’t test implementation details: Test behavior, not internals
- Use interfaces for dependencies: Makes mocking easier
- Aim for high coverage, but quality over quantity: 100% coverage doesn’t mean bug-free
- Run tests with -race flag: Catch concurrency issues early
- 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)
}
})
}
}
Useful Links
- Official Go Testing Package Documentation
- Go Blog: Table-Driven Tests
- Testify GitHub Repository
- GoMock Documentation
- Learn Go with Tests
- Go Code Coverage Tool
- Go Cheat Sheet
- Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
- Go SDKs for Ollama - comparison with examples
- Building CLI Applications in Go with Cobra & Viper
- Go Generics: Use Cases and Patterns
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.