Go Testing: Writing Unit Tests with testing Package
Go takes a refreshingly pragmatic approach to testing. Unlike languages that require third-party frameworks for basic testing capabilities, Go includes everything you need in the standard library's...
Key Insights
- Go’s testing package is intentionally minimal, providing just enough tooling to write effective tests without external dependencies—embrace this simplicity rather than fighting it
- Table-driven tests are the idiomatic Go pattern for testing multiple scenarios, reducing code duplication and making it trivial to add new test cases
- Test coverage tools are built into the standard toolchain, making it effortless to identify untested code paths and measure test effectiveness
Introduction to Go’s Built-in Testing
Go takes a refreshingly pragmatic approach to testing. Unlike languages that require third-party frameworks for basic testing capabilities, Go includes everything you need in the standard library’s testing package. This minimalist philosophy means you can write comprehensive test suites using only the tools that ship with the language.
The testing package provides the fundamental building blocks: test execution, benchmarking, and examples. It doesn’t include assertion libraries or mocking frameworks because Go’s designers believe simple comparison operators and explicit error checking lead to clearer, more maintainable tests.
Every test file in Go follows a strict naming convention: it must end with _test.go. This suffix tells the Go toolchain to exclude these files from regular builds while including them during test runs. Here’s the simplest possible test:
// math.go
package math
func Add(a, b int) int {
return a + b
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
Test functions must start with Test, accept a single *testing.T parameter, and live in a _test.go file. Run them with go test in your package directory.
Writing Your First Unit Test
The *testing.T type provides methods for reporting test failures and controlling test execution. The most common methods are:
t.Error()andt.Errorf(): Mark the test as failed but continue executiont.Fatal()andt.Fatalf(): Mark the test as failed and stop execution immediatelyt.Log()andt.Logf(): Output information (only shown when tests fail or with-vflag)
Use Error when subsequent checks might still provide useful information. Use Fatal when continuing the test would be meaningless or dangerous:
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %f; want 5", result)
}
}
When you create helper functions for your tests, use t.Helper() to ensure error messages point to the calling code rather than the helper itself:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
func TestCalculation(t *testing.T) {
result := ComplexCalculation(5)
assertEqual(t, result, 25) // Error will point to this line, not inside assertEqual
}
Table-Driven Tests
Testing multiple scenarios with separate test functions creates unnecessary duplication. Table-driven tests solve this by defining test cases as data structures:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
isValid bool
}{
{"valid email", "user@example.com", true},
{"missing @", "userexample.com", false},
{"missing domain", "user@", false},
{"empty string", "", false},
{"multiple @", "user@@example.com", false},
{"valid with subdomain", "user@mail.example.com", true},
}
for _, tt := range tests {
result := ValidateEmail(tt.email)
if result != tt.isValid {
t.Errorf("ValidateEmail(%q) = %v; want %v",
tt.email, result, tt.isValid)
}
}
}
This pattern makes adding new test cases trivial—just append to the slice. The struct fields are self-documenting, and you can easily see all tested scenarios at a glance.
Test Coverage and Running Tests
Go’s test runner provides several useful flags. Run tests verbosely to see all output:
go test -v
Measure code coverage with the -cover flag:
go test -cover
This outputs a coverage percentage. For detailed coverage analysis, generate a coverage profile:
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
The HTML report shows exactly which lines are covered, making it easy to identify untested code paths. You can also view coverage in the terminal:
go tool cover -func=coverage.out
To run tests for all packages in your module:
go test ./...
Add -short to skip long-running tests (when you’ve marked them with if testing.Short() { t.Skip() }), and use -run to execute specific tests:
go test -run TestValidateEmail
Subtests and Test Organization
Subtests improve test organization and enable parallel execution. Use t.Run() to create named subtests:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
isValid bool
}{
{"valid email", "user@example.com", true},
{"missing @", "userexample.com", false},
{"missing domain", "user@", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateEmail(tt.email)
if result != tt.isValid {
t.Errorf("ValidateEmail(%q) = %v; want %v",
tt.email, result, tt.isValid)
}
})
}
}
Now each test case appears as a separate test in the output, and you can run individual cases:
go test -run TestValidateEmail/missing_@
Enable parallel execution with t.Parallel() to speed up test suites:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
isValid bool
}{
{"valid email", "user@example.com", true},
{"missing @", "userexample.com", false},
}
for _, tt := range tests {
tt := tt // Capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := ValidateEmail(tt.email)
if result != tt.isValid {
t.Errorf("ValidateEmail(%q) = %v; want %v",
tt.email, result, tt.isValid)
}
})
}
}
The tt := tt line is crucial—it captures the loop variable to prevent race conditions in parallel tests.
Testing Best Practices
Name your tests descriptively. TestAdd is acceptable for simple functions, but TestUserRegistrationWithDuplicateEmail clearly communicates intent.
Ensure test isolation. Each test should set up its own state and clean up afterward:
func TestDatabaseOperation(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test code using db
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
return db
}
Use t.Skip() for conditional test execution:
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Long-running integration test
}
Build tags provide another mechanism for conditional compilation:
//go:build integration
// +build integration
package mypackage
import "testing"
func TestExternalAPI(t *testing.T) {
// Only runs with: go test -tags=integration
}
Common Pitfalls and Tips
Don’t test implementation details—test behavior. Here’s a common mistake:
// Bad: Testing implementation
func TestUserCache(t *testing.T) {
cache := NewUserCache()
cache.data["user1"] = User{ID: 1}
if len(cache.data) != 1 {
t.Error("cache should have 1 item")
}
}
Instead, test the public API:
// Good: Testing behavior
func TestUserCache(t *testing.T) {
cache := NewUserCache()
user := User{ID: 1, Name: "Alice"}
cache.Set("user1", user)
retrieved, found := cache.Get("user1")
if !found {
t.Fatal("expected to find user1 in cache")
}
if retrieved.ID != user.ID || retrieved.Name != user.Name {
t.Errorf("got %+v; want %+v", retrieved, user)
}
}
This approach makes refactoring easier—you can change the internal implementation without breaking tests.
Handle errors explicitly in tests. Don’t ignore them:
// Bad
result, _ := ParseJSON(input)
// Good
result, err := ParseJSON(input)
if err != nil {
t.Fatalf("ParseJSON failed: %v", err)
}
Keep tests readable. If a test requires extensive setup or complex assertions, it’s probably testing too much. Break it into smaller, focused tests.
Go’s testing package may seem bare-bones compared to frameworks in other languages, but this simplicity is a strength. You write more explicit code, which makes tests easier to understand and maintain. The standard library gives you everything needed for comprehensive testing—use it effectively, and your codebase will thank you.