Let me tell you about a practice that transformed how I write tests in Go. It started when I noticed my test files growing messy with repetitive code. Each new test case meant copying, pasting, and modifying existing tests. Then I discovered table-driven testing, and everything changed.
Table-driven testing organizes test cases as data rather than scattered functions. Instead of writing TestAddPositiveNumbers, TestAddNegativeNumbers, and TestAddWithZero, you create a single test with a table of inputs and expected outputs. This approach reduces duplication while making tests easier to read and maintain.
Here’s the basic pattern that I use every day:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"adding two positive numbers", 2, 3, 5},
{"adding two negative numbers", -1, -1, -2},
{"adding positive and negative", 5, -3, 2},
{"adding zero to a number", 0, 7, 7},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, result, tc.expected)
}
})
}
}
The magic happens in that anonymous struct slice. Each entry represents a complete test case with a descriptive name. The t.Run() creates individual subtests that run independently. When a test fails, you see exactly which case failed, not just that “TestAdd failed.”
I learned this pattern saves hours of maintenance. When requirements change, I modify the data table instead of hunting through multiple test functions. Adding edge cases becomes trivial—just insert another row in the table.
But basic table-driven testing is just the beginning. Over the years, I’ve discovered seven patterns that make my tests more powerful and maintainable.
The first pattern I want to share came from struggling with slow test suites. As my projects grew, running hundreds of tests sequentially took too long. That’s when I discovered parallel execution.
func TestProcessString(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello", "HELLO"},
{"world", "WORLD"},
{"go", "GO"},
{"testing", "TESTING"},
}
for _, tc := range tests {
tc := tc // This capture is crucial
t.Run(tc.input, func(t *testing.T) {
t.Parallel()
// Simulate some work
time.Sleep(100 * time.Millisecond)
result := strings.ToUpper(tc.input)
if result != tc.expected {
t.Errorf("Process(%q) = %q; want %q", tc.input, result, tc.expected)
}
})
}
}
Notice the tc := tc line before t.Run(). This captures the loop variable, ensuring each parallel subtest gets its own copy of test data. Without this, tests might use wrong values because of how Go handles loop variables in closures.
The t.Parallel() call tells Go’s test runner this subtest can run concurrently with others. Tests that share no state can run simultaneously, dramatically reducing execution time. I’ve seen test suites run three times faster with this simple addition.
But parallel tests require careful thinking. I once spent hours debugging intermittent failures because tests were modifying shared global state. Now I ensure each test creates its own resources or uses synchronization when sharing is necessary.
The second pattern addresses another common problem: repetitive setup code. When tests need databases, HTTP servers, or temporary files, copying setup logic becomes tedious and error-prone.
func TestUserRepository(t *testing.T) {
tests := []struct {
name string
user User
expectError bool
}{
{"valid user", User{Name: "Alice", Email: "[email protected]"}, false},
{"user with empty name", User{Name: "", Email: "[email protected]"}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup
db, cleanup := setupTestDatabase(t)
defer cleanup()
repo := NewUserRepository(db)
// Test
err := repo.Save(tc.user)
// Verify
if tc.expectError && err == nil {
t.Error("expected error but got none")
}
if !tc.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func setupTestDatabase(t *testing.T) (*sql.DB, func()) {
// Create temporary database
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Run migrations
_, err = db.Exec(`CREATE TABLE users (name TEXT, email TEXT)`)
if err != nil {
db.Close()
t.Fatalf("failed to create table: %v", err)
}
// Return cleanup function
cleanup := func() {
db.Close()
}
return db, cleanup
}
The setupTestDatabase function handles creation and cleanup. Each test gets a fresh database instance. The defer cleanup() ensures resources get released even if the test panics. This isolation prevents tests from interfering with each other.
I use this pattern for HTTP servers, cache instances, and temporary directories. Each test starts with a clean environment, making tests predictable and eliminating order dependencies.
The third pattern improves test readability. Early in my career, my tests were hard to read because validation logic obscured the test’s intent. Custom assertions changed that.
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v (type %T), want %v (type %T)", got, got, want, want)
}
}
func assertErrorContains(t *testing.T, err error, substring string) {
t.Helper()
if err == nil {
t.Error("expected error but got nil")
return
}
if !strings.Contains(err.Error(), substring) {
t.Errorf("error %q does not contain %q", err.Error(), substring)
}
}
func TestOrderProcessing(t *testing.T) {
tests := []struct {
name string
order Order
expectedTotal float64
expectError bool
errorContains string
}{
{
name: "valid order with items",
order: Order{
Items: []Item{{Price: 10.0, Quantity: 2}, {Price: 5.0, Quantity: 1}},
},
expectedTotal: 25.0,
},
{
name: "order with zero quantity",
order: Order{
Items: []Item{{Price: 10.0, Quantity: 0}},
},
expectError: true,
errorContains: "quantity must be positive",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
processor := NewOrderProcessor()
total, err := processor.CalculateTotal(tc.order)
if tc.expectError {
assertErrorContains(t, err, tc.errorContains)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertEqual(t, total, tc.expectedTotal)
})
}
}
The t.Helper() call marks these functions as test helpers. When they fail, the error points to the line in the test function, not inside the helper. This makes debugging much easier.
Custom assertions encapsulate complex checks. When validation logic changes, I update it in one place instead of dozens of tests. They also produce better error messages than simple if statements.
The fourth pattern might be the most important: testing error cases. I used to focus only on happy paths until production failures taught me otherwise.
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
configJSON string
expectError bool
errorType error
validate func(*testing.T, *Config)
}{
{
name: "valid config",
configJSON: `{"timeout": 30, "host": "localhost"}`,
validate: func(t *testing.T, c *Config) {
if c.Timeout != 30 {
t.Errorf("expected timeout 30, got %d", c.Timeout)
}
if c.Host != "localhost" {
t.Errorf("expected host localhost, got %s", c.Host)
}
},
},
{
name: "missing required field",
configJSON: `{"timeout": 30}`,
expectError: true,
errorType: ErrInvalidConfig,
},
{
name: "invalid JSON",
configJSON: `{invalid json}`,
expectError: true,
// errorType will be JSON parsing error
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
config, err := ParseConfig(strings.NewReader(tc.configJSON))
if tc.expectError {
if err == nil {
t.Error("expected error but got none")
}
if tc.errorType != nil && !errors.Is(err, tc.errorType) {
t.Errorf("error type mismatch: got %T, want %T", err, tc.errorType)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.validate != nil {
tc.validate(t, config)
}
})
}
}
The validate field holds a function that checks successful results. This flexibility lets me write custom validation for each test case while keeping the test structure consistent. For error cases, I verify both that an error occurred and that it’s the right type.
This pattern ensures my code handles failures gracefully. It documents what errors callers should expect and prevents error handling regressions.
The fifth pattern combines performance testing with functional testing. I use it for performance-critical functions where I need to prevent regressions.
func TestCompressData(t *testing.T) {
tests := []struct {
name string
input []byte
expected []byte
maxAllocs int64
maxDuration time.Duration
}{
{
name: "small data",
input: []byte("hello world"),
maxAllocs: 5,
maxDuration: 100 * time.Microsecond,
},
{
name: "large data",
input: bytes.Repeat([]byte("data"), 1000),
maxAllocs: 20,
maxDuration: 5 * time.Millisecond,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Functional test
compressed := CompressData(tc.input)
decompressed, err := DecompressData(compressed)
if err != nil {
t.Fatalf("decompression failed: %v", err)
}
if !bytes.Equal(decompressed, tc.input) {
t.Error("decompressed data doesn't match original")
}
// Performance assertions
result := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
CompressData(tc.input)
}
})
if result.AllocsPerOp() > tc.maxAllocs {
t.Errorf("too many allocations: got %d, max %d",
result.AllocsPerOp(), tc.maxAllocs)
}
if result.T > tc.maxDuration {
t.Errorf("too slow: got %v, max %v", result.T, tc.maxDuration)
}
})
}
}
This pattern catches performance regressions during development. When someone modifies CompressData, these tests verify it still meets performance requirements. The benchmarks run as part of the test suite, not as a separate step developers might skip.
I set maxAllocs and maxDuration based on production requirements. When a test fails because of performance regression, I investigate immediately rather than discovering slowdowns in production.
The sixth pattern handles complex outputs. When testing functions that produce HTML, JSON, or other structured data, comparing strings becomes messy.
func TestGenerateReport(t *testing.T) {
tests := []struct {
name string
data ReportData
golden string
update bool
}{
{
name: "monthly sales report",
data: ReportData{
Month: "January",
Sales: 15000.50,
Expenses: 8000.75,
},
golden: "testdata/monthly_sales.golden",
},
{
name: "empty report",
data: ReportData{},
golden: "testdata/empty.golden",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := GenerateReport(tc.data)
if tc.update {
// Update golden file (run with -update flag)
os.WriteFile(tc.golden, []byte(got), 0644)
return
}
wantBytes, err := os.ReadFile(tc.golden)
if err != nil {
t.Fatalf("failed to read golden file: %v", err)
}
want := string(wantBytes)
if got != want {
// Show diff for easier debugging
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(want),
B: difflib.SplitLines(got),
Context: 3,
}
text, _ := difflib.GetUnifiedDiffString(diff)
t.Errorf("output differs from golden file:\n%s", text)
}
})
}
}
Golden files store expected outputs. When I legitimately change report formatting, I run tests with an -update flag to refresh the golden files. The diff output helps identify what changed.
I keep golden files in a testdata directory. They serve as living documentation showing exactly what output the function produces. New team members can examine these files to understand the code’s behavior.
The seventh pattern finds edge cases I might miss. Instead of writing specific test cases, I define properties that should always hold true.
func TestAdditionProperties(t *testing.T) {
// Commutative property: a + b = b + a
commutative := func(a, b int) bool {
return Add(a, b) == Add(b, a)
}
if err := quick.Check(commutative, &quick.Config{MaxCount: 1000}); err != nil {
t.Errorf("addition not commutative: %v", err)
}
// Associative property: (a + b) + c = a + (b + c)
associative := func(a, b, c int) bool {
return Add(Add(a, b), c) == Add(a, Add(b, c))
}
if err := quick.Check(associative, &quick.Config{MaxCount: 1000}); err != nil {
t.Errorf("addition not associative: %v", err)
}
// Identity property: a + 0 = a
identity := func(a int) bool {
return Add(a, 0) == a
}
if err := quick.Check(identity, &quick.Config{MaxCount: 1000}); err != nil {
t.Errorf("0 is not identity element: %v", err)
}
}
func TestListOperations(t *testing.T) {
// Reversing twice returns original list
property := func(list []int) bool {
reversed := reverseList(list)
restored := reverseList(reversed)
return reflect.DeepEqual(list, restored)
}
config := &quick.Config{
MaxCount: 500,
Values: func(values []reflect.Value, rand *rand.Rand) {
// Generate random length list
n := rand.Intn(100)
list := make([]int, n)
for i := range list {
list[i] = rand.Intn(1000)
}
values[0] = reflect.ValueOf(list)
},
}
if err := quick.Check(property, config); err != nil {
t.Errorf("reverse(reverse(list)) != list: %v", err)
}
}
Property-based testing generates random inputs and verifies invariants. When it finds a failing case, it shrinks the input to the smallest example that demonstrates the failure. This often reveals edge cases I hadn’t considered.
I use this pattern for mathematical operations, serialization functions, and data structure methods. It complements my hand-written test cases by exploring parts of the input space I might overlook.
These seven patterns form a comprehensive approach to testing in Go. They’ve helped me build reliable systems with confidence. When tests are this organized, I spend less time fixing bugs and more time adding features.
The table-driven approach scales beautifully. I start with simple cases, add edge cases as I discover them, and incorporate performance checks for critical paths. Each test documents expected behavior while catching regressions.
Maintenance becomes predictable. When requirements change, I update the data tables. When I find a bug, I add a test case that reproduces it. The test suite grows organically, always reflecting current understanding of the system.
Good tests communicate intent. A new developer can read my test tables and understand what the code should do. They see all the edge cases and error conditions documented in one place. This makes onboarding easier and knowledge transfer more effective.
I encourage you to try these patterns in your own projects. Start with basic table-driven tests for pure functions. Add parallel execution for slow tests. Introduce custom assertions when validation logic gets complex. Each improvement makes your tests more valuable.
Remember that tests are code too. They deserve the same care as production code. Clean, organized tests pay dividends throughout a project’s lifecycle. They give you confidence to refactor, help new team members understand the system, and prevent regressions as complexity grows.
The goal isn’t perfect test coverage. The goal is confidence that your code works correctly and will continue working as it evolves. These patterns help achieve that confidence efficiently. They’ve certainly made me a more effective Go developer, and I believe they can do the same for you.