Go Table-Driven Tests: Parameterized Testing

Go's testing philosophy emphasizes simplicity and explicitness. Unlike frameworks in other languages that rely on decorators, annotations, or inheritance hierarchies, Go tests are just functions....

Key Insights

  • Table-driven tests leverage Go’s struct and slice primitives to create readable, maintainable test suites that make adding new test cases trivial
  • Using t.Run() with descriptive names transforms test output from cryptic failures into self-documenting specifications
  • The pattern scales from simple input/output validation to complex scenarios involving error handling, mocks, and parallel execution

Introduction to Table-Driven Tests

Go’s testing philosophy emphasizes simplicity and explicitness. Unlike frameworks in other languages that rely on decorators, annotations, or inheritance hierarchies, Go tests are just functions. Table-driven testing emerges naturally from this design—it’s not a framework feature but a pattern that exploits Go’s built-in data structures.

The concept is straightforward: instead of writing separate test functions for each scenario, you define a slice of test cases and iterate through them. Each case contains inputs and expected outputs. This approach eliminates repetitive test code, makes adding new cases trivial, and produces clear failure messages.

Consider a function we want to test:

package validator

import (
    "regexp"
    "strings"
)

func ValidateUsername(username string) (bool, string) {
    if len(username) < 3 {
        return false, "username must be at least 3 characters"
    }
    if len(username) > 20 {
        return false, "username must be at most 20 characters"
    }
    if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(username) {
        return false, "username can only contain letters, numbers, and underscores"
    }
    if strings.HasPrefix(username, "_") {
        return false, "username cannot start with underscore"
    }
    return true, ""
}

Without table-driven tests, you’d write separate functions for each validation rule. That’s tedious and error-prone. Let’s fix that.

Basic Table-Driven Test Structure

A table-driven test consists of three parts: a test case struct definition, a slice of test cases, and a loop that runs each case as a subtest.

func TestValidateUsername(t *testing.T) {
    tests := []struct {
        name        string
        username    string
        wantValid   bool
        wantMessage string
    }{
        {
            name:        "valid simple username",
            username:    "john_doe",
            wantValid:   true,
            wantMessage: "",
        },
        {
            name:        "too short",
            username:    "ab",
            wantValid:   false,
            wantMessage: "username must be at least 3 characters",
        },
        {
            name:        "too long",
            username:    "abcdefghijklmnopqrstuvwxyz",
            wantValid:   false,
            wantMessage: "username must be at most 20 characters",
        },
        {
            name:        "contains invalid characters",
            username:    "john@doe",
            wantValid:   false,
            wantMessage: "username can only contain letters, numbers, and underscores",
        },
        {
            name:        "starts with underscore",
            username:    "_johndoe",
            wantValid:   false,
            wantMessage: "username cannot start with underscore",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            gotValid, gotMessage := ValidateUsername(tt.username)
            if gotValid != tt.wantValid {
                t.Errorf("ValidateUsername(%q) valid = %v, want %v", 
                    tt.username, gotValid, tt.wantValid)
            }
            if gotMessage != tt.wantMessage {
                t.Errorf("ValidateUsername(%q) message = %q, want %q", 
                    tt.username, gotMessage, tt.wantMessage)
            }
        })
    }
}

The t.Run() call creates a subtest with the given name. When a test fails, you see exactly which case failed: TestValidateUsername/too_short. This makes debugging straightforward—no more hunting through logs to find which assertion failed.

Designing Effective Test Cases

Test case design matters more than the mechanical structure. A well-designed test table documents the function’s behavior and catches regressions.

Use named struct fields, not positional initialization. Compare these:

// Bad: What do these values mean?
{"test1", 5, 3, 8, false},

// Good: Self-documenting
{
    name:     "addition of positive numbers",
    a:        5,
    b:        3,
    expected: 8,
    wantErr:  false,
},

Choose test names that describe the scenario, not the implementation. “returns_error_when_negative” beats “test_case_7”. Your future self will thank you.

Here’s a calculator example demonstrating thorough coverage:

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        dividend  float64
        divisor   float64
        want      float64
        wantErr   bool
    }{
        {
            name:     "positive divided by positive",
            dividend: 10,
            divisor:  2,
            want:     5,
            wantErr:  false,
        },
        {
            name:     "negative divided by positive",
            dividend: -10,
            divisor:  2,
            want:     -5,
            wantErr:  false,
        },
        {
            name:     "division by zero",
            dividend: 10,
            divisor:  0,
            want:     0,
            wantErr:  true,
        },
        {
            name:     "zero divided by number",
            dividend: 0,
            divisor:  5,
            want:     0,
            wantErr:  false,
        },
        {
            name:     "fractional result",
            dividend: 7,
            divisor:  2,
            want:     3.5,
            wantErr:  false,
        },
    }

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

Advanced Patterns and Techniques

Real-world tests often need more than simple input/output validation. Table-driven tests accommodate complex scenarios through additional struct fields.

For HTTP handlers, include request setup and response validation:

func TestUserHandler(t *testing.T) {
    tests := []struct {
        name           string
        method         string
        path           string
        body           string
        wantStatusCode int
        wantBody       string
    }{
        {
            name:           "get existing user",
            method:         http.MethodGet,
            path:           "/users/123",
            body:           "",
            wantStatusCode: http.StatusOK,
            wantBody:       `{"id":"123","name":"John"}`,
        },
        {
            name:           "get non-existent user",
            method:         http.MethodGet,
            path:           "/users/999",
            body:           "",
            wantStatusCode: http.StatusNotFound,
            wantBody:       `{"error":"user not found"}`,
        },
        {
            name:           "create user with valid data",
            method:         http.MethodPost,
            path:           "/users",
            body:           `{"name":"Jane"}`,
            wantStatusCode: http.StatusCreated,
            wantBody:       `{"id":"456","name":"Jane"}`,
        },
        {
            name:           "create user with invalid JSON",
            method:         http.MethodPost,
            path:           "/users",
            body:           `{invalid}`,
            wantStatusCode: http.StatusBadRequest,
            wantBody:       `{"error":"invalid JSON"}`,
        },
    }

    handler := NewUserHandler(mockUserService{})

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
            rec := httptest.NewRecorder()

            handler.ServeHTTP(rec, req)

            if rec.Code != tt.wantStatusCode {
                t.Errorf("status code = %d, want %d", rec.Code, tt.wantStatusCode)
            }
            if strings.TrimSpace(rec.Body.String()) != tt.wantBody {
                t.Errorf("body = %s, want %s", rec.Body.String(), tt.wantBody)
            }
        })
    }
}

For parallel execution, call t.Parallel() in both the parent test and each subtest:

func TestParallel(t *testing.T) {
    t.Parallel()
    
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"case1", 1, 2},
        {"case2", 2, 4},
        {"case3", 3, 6},
    }

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

Testing Complex Scenarios

When testing functions that return structs, use github.com/google/go-cmp/cmp for readable diff output:

import "github.com/google/go-cmp/cmp"

func TestGetUser(t *testing.T) {
    tests := []struct {
        name     string
        userID   string
        mockRepo *mockUserRepo
        want     *User
        wantErr  bool
    }{
        {
            name:   "existing user",
            userID: "123",
            mockRepo: &mockUserRepo{
                users: map[string]*User{
                    "123": {ID: "123", Name: "John", Email: "john@example.com"},
                },
            },
            want:    &User{ID: "123", Name: "John", Email: "john@example.com"},
            wantErr: false,
        },
        {
            name:     "non-existent user",
            userID:   "999",
            mockRepo: &mockUserRepo{users: map[string]*User{}},
            want:     nil,
            wantErr:  true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := NewUserService(tt.mockRepo)
            got, err := svc.GetUser(tt.userID)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if diff := cmp.Diff(tt.want, got); diff != "" {
                t.Errorf("GetUser() mismatch (-want +got):\n%s", diff)
            }
        })
    }
}

The cmp.Diff output shows exactly which fields differ, making debugging complex struct comparisons trivial.

Common Pitfalls and Best Practices

Loop variable capture was historically Go’s biggest testing footgun. Before Go 1.22, this code was broken:

// Pre-Go 1.22: BROKEN - all subtests use the last tt value
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // tt is captured by reference, not value
    })
}

// Pre-Go 1.22 fix: shadow the variable
for _, tt := range tests {
    tt := tt // Create new variable for this iteration
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
    })
}

Go 1.22+ changed loop variable semantics, making each iteration create a new variable. If you’re on Go 1.22 or later, this isn’t a concern. Check your go.mod version.

Keep tables readable. When a test table grows beyond 10-15 cases, consider whether you’re testing multiple behaviors that should be separate test functions. Group related cases:

func TestValidation(t *testing.T) {
    t.Run("length constraints", func(t *testing.T) {
        tests := []struct{ /* ... */ }{
            // Only length-related cases
        }
        // ...
    })
    
    t.Run("character constraints", func(t *testing.T) {
        tests := []struct{ /* ... */ }{
            // Only character-related cases
        }
        // ...
    })
}

Know when to skip table-driven tests. If you have one or two cases, a simple test function is clearer. If each case requires significantly different setup, separate functions may be more readable. The pattern serves you, not the other way around.

Conclusion

Table-driven tests are idiomatic Go. They reduce boilerplate, improve maintainability, and produce clear failure output. Start with a simple struct containing inputs and expected outputs, iterate with t.Run(), and expand the pattern as your testing needs grow.

The key patterns to remember: use named struct fields, write descriptive test names, leverage cmp.Diff for struct comparisons, and know when the pattern doesn’t fit. For deeper exploration, the official Go wiki’s TableDrivenTests page and Dave Cheney’s testing articles provide excellent additional context.

Liked this? There's more.

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