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
_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
t.Log
is for human-readable test diagnostics. In running services, log/slog and JSON-friendly records are usually a better match for aggregation and incident debugging. See Structured Logging in Go with slog for Observability and Alerting.
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
go test
go test -v
go test ./...
go test -run TestAdd
go test -run TestCalculate/addition
go test -parallel 4
go test -timeout 30s
Test Coverage
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
go tool cover -func=coverage.out
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 DocumentationGo Blog: Table-Driven TestsTestify GitHub RepositoryGoMock DocumentationLearn Go with TestsGo Code Coverage ToolGo Cheat SheetStructured Logging in Go with slog for Observability and AlertingComparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlcGo SDKs for Ollama - comparison with examplesBuilding CLI Applications in Go with Cobra & ViperGo 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.
See the App Architecture hub for related guides on Go project structure, dependency injection, API design, and integration patterns.