Go Table-Driven Tests: Best Practices

Table-driven tests are the idiomatic way to write tests in Go. Instead of creating separate test functions for each scenario, you define your test cases as data in a slice and iterate through them....

Key Insights

  • Table-driven tests reduce duplication by defining test cases as data structures, making it trivial to add new scenarios without writing repetitive code
  • Using t.Run() with descriptive names creates isolated subtests that can run in parallel and provide clear failure messages pinpointing exactly which case failed
  • The loop variable capture bug is the most common pitfall—always shadow the loop variable or use the proper Go 1.22+ syntax to avoid stale references

Introduction to Table-Driven Tests

Table-driven tests are the idiomatic way to write tests in Go. Instead of creating separate test functions for each scenario, you define your test cases as data in a slice and iterate through them. This approach eliminates duplication, makes adding new test cases trivial, and keeps your test suite maintainable.

Consider testing a function that validates email addresses. The naive approach creates separate functions:

func TestValidEmail(t *testing.T) {
    if !IsValidEmail("user@example.com") {
        t.Error("expected valid email")
    }
}

func TestInvalidEmailNoAt(t *testing.T) {
    if IsValidEmail("userexample.com") {
        t.Error("expected invalid email")
    }
}

func TestInvalidEmailNoDomain(t *testing.T) {
    if IsValidEmail("user@") {
        t.Error("expected invalid email")
    }
}

This gets tedious quickly. Here’s the table-driven equivalent:

func TestIsValidEmail(t *testing.T) {
    tests := []struct {
        email string
        want  bool
    }{
        {"user@example.com", true},
        {"userexample.com", false},
        {"user@", false},
    }

    for _, tt := range tests {
        if got := IsValidEmail(tt.email); got != tt.want {
            t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
        }
    }
}

The table-driven version is more concise and adding new cases is just one line of data.

Basic Table-Driven Test Structure

Every table-driven test follows the same pattern: define a struct for your test cases, create a slice of those structs, and loop through executing each case.

Here’s a complete example testing a string reversal function:

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

func TestReverse(t *testing.T) {
    tests := []struct {
        input string
        want  string
    }{
        {"hello", "olleh"},
        {"", ""},
        {"a", "a"},
        {"Hello, 世界", "界世 ,olleH"},
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            got := Reverse(tt.input)
            if got != tt.want {
                t.Errorf("got %q, want %q", got, tt.want)
            }
        })
    }
}

The t.Run() call creates named subtests. When a test fails, you’ll see exactly which input caused the failure. The first argument to t.Run() is the subtest name—use something descriptive.

Organizing Test Cases Effectively

As your test tables grow, organization becomes critical. Use descriptive names for test cases and group related scenarios together.

func TestParseInt(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int
        wantErr bool
    }{
        // Happy path cases
        {
            name:  "positive integer",
            input: "42",
            want:  42,
        },
        {
            name:  "negative integer",
            input: "-42",
            want:  -42,
        },
        {
            name:  "zero",
            input: "0",
            want:  0,
        },
        
        // Edge cases
        {
            name:  "leading zeros",
            input: "007",
            want:  7,
        },
        
        // Error cases
        {
            name:    "empty string",
            input:   "",
            wantErr: true,
        },
        {
            name:    "non-numeric",
            input:   "abc",
            wantErr: true,
        },
        {
            name:    "mixed alphanumeric",
            input:   "12abc",
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseInt(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseInt() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !tt.wantErr && got != tt.want {
                t.Errorf("ParseInt() = %v, want %v", got, tt.want)
            }
        })
    }
}

Notice the explicit name field and the comments grouping related scenarios. This makes the test table self-documenting.

For simple cases, anonymous structs work fine. For complex test cases shared across multiple test functions, define a named type.

Advanced Patterns

Table-driven tests shine when combined with Go’s testing features. Use t.Parallel() to run test cases concurrently, and add setup functions for cases that need specific initialization.

func TestUserService(t *testing.T) {
    tests := []struct {
        name  string
        setup func(*testing.T) *Database
        input string
        want  *User
    }{
        {
            name: "existing user",
            setup: func(t *testing.T) *Database {
                db := NewTestDB(t)
                db.InsertUser(&User{ID: "123", Name: "Alice"})
                return db
            },
            input: "123",
            want:  &User{ID: "123", Name: "Alice"},
        },
        {
            name: "non-existent user",
            setup: func(t *testing.T) *Database {
                return NewTestDB(t)
            },
            input: "999",
            want:  nil,
        },
    }

    for _, tt := range tests {
        tt := tt // Capture range variable (Go 1.21 and earlier)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            db := tt.setup(t)
            defer db.Close()
            
            svc := NewUserService(db)
            got := svc.GetUser(tt.input)
            
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("got %+v, want %+v", got, tt.want)
            }
        })
    }
}

The setup function field allows per-case initialization. Calling t.Parallel() runs subtests concurrently, dramatically speeding up slow test suites.

Testing Error Cases and Multiple Outputs

Most real-world functions return (result, error). Your test table needs to verify both values and handle error comparison properly.

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        want      float64
        wantErr   error
    }{
        {
            name: "simple division",
            a:    10,
            b:    2,
            want: 5,
        },
        {
            name:    "divide by zero",
            a:       10,
            b:       0,
            wantErr: ErrDivisionByZero,
        },
        {
            name: "negative result",
            a:    -10,
            b:    2,
            want: -5,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            
            if tt.wantErr != nil {
                if !errors.Is(err, tt.wantErr) {
                    t.Errorf("got error %v, want %v", err, tt.wantErr)
                }
                return
            }
            
            if err != nil {
                t.Errorf("unexpected error: %v", err)
                return
            }
            
            if got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

Use errors.Is() for sentinel errors and errors.As() for error types. Don’t compare error strings—they’re implementation details that change.

Common Pitfalls and Solutions

The most common bug in table-driven tests is the loop variable capture problem. In Go 1.21 and earlier, loop variables are reused across iterations:

// WRONG - Bug in Go 1.21 and earlier
func TestBroken(t *testing.T) {
    tests := []struct {
        input string
    }{
        {"first"},
        {"second"},
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            t.Parallel() // All goroutines see "second"!
            // Test using tt.input
        })
    }
}

// CORRECT - Shadow the variable
func TestFixed(t *testing.T) {
    tests := []struct {
        input string
    }{
        {"first"},
        {"second"},
    }

    for _, tt := range tests {
        tt := tt // Create new variable for this iteration
        t.Run(tt.input, func(t *testing.T) {
            t.Parallel()
            // Now each goroutine has its own tt
        })
    }
}

In Go 1.22+, loop variables are automatically per-iteration, but shadowing remains good practice for backward compatibility.

Don’t use table-driven tests when test cases require fundamentally different logic. If your loop body has complex conditionals checking which test case is running, you need separate test functions instead.

Real-World Example

Here’s a comprehensive example testing an HTTP handler that processes user registration:

func TestRegisterHandler(t *testing.T) {
    tests := []struct {
        name           string
        requestBody    string
        setupMock      func(*MockUserStore)
        wantStatusCode int
        wantBody       string
    }{
        {
            name:        "successful registration",
            requestBody: `{"email":"user@example.com","password":"secret123"}`,
            setupMock: func(m *MockUserStore) {
                m.On("CreateUser", mock.Anything).Return(nil)
            },
            wantStatusCode: http.StatusCreated,
            wantBody:       `{"message":"user created"}`,
        },
        {
            name:           "invalid JSON",
            requestBody:    `{invalid json}`,
            setupMock:      func(m *MockUserStore) {},
            wantStatusCode: http.StatusBadRequest,
            wantBody:       `{"error":"invalid request"}`,
        },
        {
            name:        "duplicate email",
            requestBody: `{"email":"existing@example.com","password":"secret123"}`,
            setupMock: func(m *MockUserStore) {
                m.On("CreateUser", mock.Anything).Return(ErrDuplicateEmail)
            },
            wantStatusCode: http.StatusConflict,
            wantBody:       `{"error":"email already exists"}`,
        },
        {
            name:           "missing password",
            requestBody:    `{"email":"user@example.com"}`,
            setupMock:      func(m *MockUserStore) {},
            wantStatusCode: http.StatusBadRequest,
            wantBody:       `{"error":"password required"}`,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockStore := new(MockUserStore)
            tt.setupMock(mockStore)
            
            handler := NewRegisterHandler(mockStore)
            
            req := httptest.NewRequest(http.MethodPost, "/register", 
                strings.NewReader(tt.requestBody))
            req.Header.Set("Content-Type", "application/json")
            
            rec := httptest.NewRecorder()
            handler.ServeHTTP(rec, req)
            
            if rec.Code != tt.wantStatusCode {
                t.Errorf("status code = %d, want %d", rec.Code, tt.wantStatusCode)
            }
            
            gotBody := strings.TrimSpace(rec.Body.String())
            if gotBody != tt.wantBody {
                t.Errorf("body = %q, want %q", gotBody, tt.wantBody)
            }
            
            mockStore.AssertExpectations(t)
        })
    }
}

This example demonstrates real-world complexity: mocking dependencies, testing HTTP handlers, verifying status codes and response bodies, and handling multiple error scenarios—all in a clean, maintainable table structure.

Table-driven tests are more than a pattern—they’re a philosophy of treating test cases as data. Master this approach and your Go test suites will be cleaner, faster, and easier to maintain.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.