Testing in Go: concepts, trade-offs and production lessons
Go's standard testing library is deliberately minimal, yet production teams ship robust APIs with confidence using just testing.T and the test runner built into the language. We've learned through years of running payment processors, ride-hailing dispatch systems, and SaaS backends that Go's approach to testing—fast feedback loops, no test framework overhead, and parallelization by default—scales better than many heavier alternatives. This guide walks you through the concepts, trade-offs, and lessons we've learned when testing in Go at scale.
Test files: the simple foundation
Go enforces a convention: test files live alongside production code with a _test.go suffix. A handler in api/handler.go has its tests in api/handler_test.go, in the same package. This keeps tests close to the code they verify and makes the structure immediately visible in your editor.
Running tests is equally straightforward. From your project root:
go test ./...This recursively finds every _test.go file, compiles them, and runs all test functions that begin with Test. No configuration files, no test runners to install. In a microservice with dozens of packages, this simplicity saves enormous friction during CI/CD setup and local iteration.
A basic test function looks like this:
func TestParseOrder(t *testing.T) {
data := []byte(`{"id": 123, "amount": 99.99}`)
order, err := ParseOrder(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if order.ID != 123 {
t.Errorf("ID = %d, want 123", order.ID)
}
}The *testing.T receiver provides methods like Errorf (log and continue), Fatalf (log and stop), and Skip (conditionally disable). Most production codebases develop a pattern: use Errorf for assertions that don't block follow-up checks, and Fatalf when continuing makes no sense—such as when setup fails.
Table-driven tests: scaling test coverage
When you have ten variations of the same logic, writing ten separate test functions is repetitive and hard to maintain. Table-driven tests let you define test cases as data and loop through them once. This is a Go idiom we use constantly in production APIs.
Here's a realistic example from a pricing service:
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
basePrice float64
discount float64
wantPrice float64
wantErr bool
}{
{"no discount", 100.0, 0, 100.0, false},
{"10% off", 100.0, 10, 90.0, false},
{"negative discount", 100.0, -5, 0, true},
{"100% discount", 100.0, 100, 0, false},
{"discount over 100%", 100.0, 150, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyDiscount(tt.basePrice, tt.discount)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.wantPrice {
t.Errorf("ApplyDiscount(%f, %f) = %f, want %f",
tt.basePrice, tt.discount, got, tt.wantPrice)
}
})
}
}Each row is a test case. The struct contains inputs, expected outputs, and a human-readable name. When one case fails, the error message includes the name, making it obvious which scenario broke. We've debugged real incidents this way—a refactor that broke the "discount over 100%" edge case showed up immediately in test output.
The trade-off is that table-driven tests can become unwieldy if your cases have very different setup requirements. When cases diverge significantly (e.g., one needs a database, another doesn't), separate test functions are often clearer.
Subtests: organization and parallel safety
The t.Run method, introduced in Go 1.7, creates a subtest with its own name and scope. Subtests appear in test output hierarchically and can run in parallel independently. We use them heavily in integration tests where setup varies by case.
Combining table-driven tests with subtests gives us organization and parallelism:
func TestOrderProcessing(t *testing.T) {
cases := []struct {
name string
order Order
wantCode int
}{
{"valid order", Order{ID: 1, Total: 50.00}, 200},
{"zero amount", Order{ID: 2, Total: 0}, 400},
{"negative amount", Order{ID: 3, Total: -10}, 400},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
// Each subtest can initialize independently
code, err := ProcessOrder(tt.order)
if code != tt.wantCode {
t.Errorf("code = %d, want %d", code, tt.wantCode)
}
})
}
}When you run this test, output shows:
TestOrderProcessing/valid_order
TestOrderProcessing/zero_amount
TestOrderProcessing/negative_amountSubtests also enable test filtering. If you want to run only the "zero_amount" case during development:
go test -run TestOrderProcessing/zero_amount ./...By default, Go runs tests serially within a test function but runs multiple test functions in parallel. Subtests respect goroutine safety, so if your code is concurrent, subtests will catch race conditions reliably.
Helpers: cleaner error messages
When a test fails, the error message should point to the test code, not to utility functions inside the testing machinery. The t.Helper() method tells Go to skip that function when reporting the failure location. This is crucial for readability in large test suites.
Without t.Helper(), a utility function pollutes stack traces:
func assertEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestSomething(t *testing.T) {
result := someFunc()
assertEqual(t, result, 42) // Error points to assertEqual, not here
}With t.Helper(), the error points directly to the assertion line:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // Add this line
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}We use helpers for common patterns: setup database fixtures, make HTTP requests, compare complex structures. A helper keeps test code concise while preserving stack trace clarity.
Common mistakes
- Global state in tests: Tests run in parallel by default. If one test modifies a global variable or shared resource without synchronization, others fail intermittently. Use per-test fixtures or synchronization primitives.
- Ignoring test output formatting: When a test fails in CI, you see only text. Cryptic error messages like "assertion failed" waste debugging time. Include values in messages:
t.Errorf("got %v, want %v", got, want). - Testing implementation instead of behavior: If your test checks internal struct fields directly, refactoring breaks the test even when behavior is correct. Test through public APIs.
- Overly complex table structures: If your test struct has 15 fields and most are unused in each row, it's usually clearer to split into multiple table-driven tests or separate test functions.
- Not using -race: Go's race detector catches many concurrency bugs. Run
go test -race ./...routinely. It has ~10% overhead but catches real production issues early.
Key takeaways
- Go's minimal testing standard library—built-in parallelism, simple syntax, no dependencies—is a strength in production systems. It reduces friction in CI/CD and onboarding.
- Table-driven tests let you cover many cases efficiently, especially for pure functions and business logic.
- Subtests with
t.Runorganize complex test scenarios and enable fine-grained filtering during development. - Use
t.Helper()in utility functions to keep error messages and stack traces clear. - Always run tests with
-racein CI and before committing code that touches concurrency. - Test behavior and contracts, not implementation details. This makes refactoring safe.
- When test setup diverges significantly across cases, separate functions are often more readable than forcing everything into one table.






Comments
Loading comments…