Go Unit Testing: Structure & Best Practices The article explains Go's built-in testing package, which provides a minimalist framework for writing unit tests without external dependencies, emphasizing that tests should live alongside production code using a clear naming convention with the `_test.go` suffix. It covers key testing patterns including table-driven tests, white-box versus black-box testing approaches, and essential `testing.T` methods like `Error`, `Fatal`, and `Skip`. The article also details practical command-line flags for running tests, such as `-v` for verbose output, `-cover` for coverage analysis, and `-parallel` for concurrent test execution. Go's built-in testing package https://www.glukhov.org/app-architecture/testing-architecture/unit-testing-in-go/ 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 https://www.glukhov.org/developer-tools/cheatsheets/golang-cheatsheet/ 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 https://www.glukhov.org/observability/logging/structured-logging-go-slog/ . Table-Driven Tests: The Go Way Table-driven tests are the idiomatic Go approach for testing multiple scenarios. With Go generics https://www.glukhov.org/app-architecture/code-architecture/generics-in-go/ , 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 https://www.glukhov.org/app-architecture/data-access/comparing-go-orms-gorm-ent-bun-sqlc/ 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 https://www.glukhov.org/llm-hosting/ollama/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 https://pkg.go.dev/testing Go Blog: Table-Driven Tests https://go.dev/blog/subtests Testify GitHub Repository https://github.com/stretchr/testify GoMock Documentation https://github.com/golang/mock Learn Go with Tests https://quii.gitbook.io/learn-go-with-tests/ Go Code Coverage Tool https://go.dev/blog/cover Go Cheat Sheet https://www.glukhov.org/developer-tools/cheatsheets/golang-cheatsheet/ Structured Logging in Go with slog for Observability and Alerting https://www.glukhov.org/observability/logging/structured-logging-go-slog/ Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc https://www.glukhov.org/app-architecture/data-access/comparing-go-orms-gorm-ent-bun-sqlc/ Go SDKs for Ollama - comparison with examples https://www.glukhov.org/llm-hosting/ollama/using-ollama-in-go/ Building CLI Applications in Go with Cobra & Viper https://www.glukhov.org/developer-tools/cli-tools/go-cli-applications-with-cobra-and-viper/ Go Generics: Use Cases and Patterns https://www.glukhov.org/app-architecture/code-architecture/generics-in-go/ 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 https://www.glukhov.org/developer-tools/cli-tools/go-cli-applications-with-cobra-and-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 https://www.glukhov.org/app-architecture/ for related guides on Go project structure, dependency injection, API design, and integration patterns.