Skip to main content
โšก Calmops

Table-Driven Tests

Table-Driven Tests

Table-driven tests are a powerful pattern in Go for writing comprehensive, maintainable tests. Instead of writing separate test functions for each case, you define a table of test cases and iterate through them. This guide covers table-driven testing patterns and best practices.

Basic Table-Driven Tests

Simple Example

package math

import "testing"

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"mixed numbers", 5, -3, 2},
        {"zero", 0, 0, 0},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func Add(a, b int) int {
    return a + b
}

Running Tests

# Run all tests
go test

# Run with verbose output
go test -v

# Run specific test
go test -run TestAdd

# Run specific subtest
go test -run TestAdd/positive

Advanced Table-Driven Tests

Testing with Error Cases

package validation

import "testing"

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid email",
            email:   "[email protected]",
            wantErr: false,
        },
        {
            name:    "missing @",
            email:   "userexample.com",
            wantErr: true,
            errMsg:  "missing @",
        },
        {
            name:    "empty email",
            email:   "",
            wantErr: true,
            errMsg:  "email is required",
        },
        {
            name:    "invalid domain",
            email:   "user@",
            wantErr: true,
            errMsg:  "invalid domain",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", tt.email, err, tt.wantErr)
            }
            if err != nil && tt.errMsg != "" && err.Error() != tt.errMsg {
                t.Errorf("ValidateEmail(%q) error message = %q, want %q", tt.email, err.Error(), tt.errMsg)
            }
        })
    }
}

func ValidateEmail(email string) error {
    if email == "" {
        return NewValidationError("email", "email is required")
    }
    if !contains(email, "@") {
        return NewValidationError("email", "missing @")
    }
    if email[len(email)-1] == '@' {
        return NewValidationError("email", "invalid domain")
    }
    return nil
}

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return e.Message
}

func NewValidationError(field, message string) *ValidationError {
    return &ValidationError{Field: field, Message: message}
}

func contains(s, substr string) bool {
    for i := 0; i < len(s); i++ {
        if s[i] == substr[0] {
            return true
        }
    }
    return false
}

Testing with Complex Types

package users

import "testing"

type User struct {
    ID    int
    Name  string
    Email string
}

func TestNewUser(t *testing.T) {
    tests := []struct {
        name      string
        id        int
        name      string
        email     string
        wantUser  *User
        wantError bool
    }{
        {
            name:     "valid user",
            id:       1,
            name:     "Alice",
            email:    "[email protected]",
            wantUser: &User{ID: 1, Name: "Alice", Email: "[email protected]"},
        },
        {
            name:      "empty name",
            id:        2,
            name:      "",
            email:     "[email protected]",
            wantError: true,
        },
        {
            name:      "empty email",
            id:        3,
            name:      "Charlie",
            email:     "",
            wantError: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            user, err := NewUser(tt.id, tt.name, tt.email)
            
            if (err != nil) != tt.wantError {
                t.Errorf("NewUser() error = %v, wantError %v", err, tt.wantError)
            }
            
            if !tt.wantError && user != tt.wantUser {
                t.Errorf("NewUser() = %v, want %v", user, tt.wantUser)
            }
        })
    }
}

func NewUser(id int, name, email string) (*User, error) {
    if name == "" {
        return nil, NewValidationError("name", "name is required")
    }
    if email == "" {
        return nil, NewValidationError("email", "email is required")
    }
    return &User{ID: id, Name: name, Email: email}, nil
}

Parameterized Tests

Using Subtests

package math

import "testing"

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      int
        expected  int
        wantError bool
    }{
        {"normal division", 10, 2, 5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative result", -10, 2, -5, false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)
            
            if (err != nil) != tt.wantError {
                t.Errorf("Divide(%d, %d) error = %v, wantError %v", tt.a, tt.b, err, tt.wantError)
            }
            
            if !tt.wantError && result != tt.expected {
                t.Errorf("Divide(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, NewError("division by zero")
    }
    return a / b, nil
}

type Error struct {
    Message string
}

func (e *Error) Error() string {
    return e.Message
}

func NewError(message string) *Error {
    return &Error{Message: message}
}

Benchmarking with Tables

package math

import "testing"

func BenchmarkAdd(b *testing.B) {
    tests := []struct {
        name string
        a, b int
    }{
        {"small", 1, 2},
        {"medium", 1000, 2000},
        {"large", 1000000, 2000000},
    }
    
    for _, tt := range tests {
        b.Run(tt.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Add(tt.a, tt.b)
            }
        })
    }
}

Testing HTTP Handlers

package handlers

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestGetUser(t *testing.T) {
    tests := []struct {
        name           string
        userID         string
        expectedStatus int
        expectedBody   string
    }{
        {
            name:           "valid user",
            userID:         "1",
            expectedStatus: http.StatusOK,
            expectedBody:   `{"id":1,"name":"Alice"}`,
        },
        {
            name:           "invalid user",
            userID:         "999",
            expectedStatus: http.StatusNotFound,
            expectedBody:   `{"error":"user not found"}`,
        },
        {
            name:           "invalid id",
            userID:         "abc",
            expectedStatus: http.StatusBadRequest,
            expectedBody:   `{"error":"invalid user id"}`,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", "/users/"+tt.userID, nil)
            w := httptest.NewRecorder()
            
            GetUserHandler(w, req)
            
            if w.Code != tt.expectedStatus {
                t.Errorf("GetUserHandler() status = %d, want %d", w.Code, tt.expectedStatus)
            }
            
            if w.Body.String() != tt.expectedBody {
                t.Errorf("GetUserHandler() body = %q, want %q", w.Body.String(), tt.expectedBody)
            }
        })
    }
}

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    // Implementation
}

Best Practices

โœ… Good Practices

  1. Use descriptive test names - Clearly indicate what’s being tested
  2. Group related tests - Use table-driven tests for similar cases
  3. Test edge cases - Empty values, nil, zero, negative numbers
  4. Test error cases - Invalid input, boundary conditions
  5. Use subtests - For better organization and reporting
  6. Keep test data simple - Easy to understand and maintain
  7. Use helper functions - For common test setup
  8. Document complex tests - Explain the purpose

โŒ Anti-Patterns

// โŒ Bad: Separate test functions for each case
func TestAdd1(t *testing.T) {
    if Add(2, 3) != 5 {
        t.Error("Add(2, 3) failed")
    }
}

func TestAdd2(t *testing.T) {
    if Add(-2, -3) != -5 {
        t.Error("Add(-2, -3) failed")
    }
}

// โœ… Good: Table-driven tests
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 2, 3, 5},
        {"negative", -2, -3, -5},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if Add(tt.a, tt.b) != tt.expected {
                t.Errorf("Add(%d, %d) failed", tt.a, tt.b)
            }
        })
    }
}

// โŒ Bad: Unclear test names
func TestFunc(t *testing.T) {
    // What is this testing?
}

// โœ… Good: Descriptive test names
func TestValidateEmail_WithValidEmail(t *testing.T) {
    // Clear what's being tested
}

Advanced Patterns

Parameterized Benchmarks

package math

import "testing"

func BenchmarkFibonacci(b *testing.B) {
    tests := []struct {
        name string
        n    int
    }{
        {"small", 10},
        {"medium", 20},
        {"large", 30},
    }
    
    for _, tt := range tests {
        b.Run(tt.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Fibonacci(tt.n)
            }
        })
    }
}

func Fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    return Fibonacci(n-1) + Fibonacci(n-2)
}

Testing with Setup and Teardown

package database

import "testing"

func TestUserOperations(t *testing.T) {
    tests := []struct {
        name string
        test func(t *testing.T, db *DB)
    }{
        {
            name: "create user",
            test: func(t *testing.T, db *DB) {
                user, err := db.CreateUser("Alice")
                if err != nil {
                    t.Errorf("CreateUser failed: %v", err)
                }
                if user.Name != "Alice" {
                    t.Errorf("User name = %q, want %q", user.Name, "Alice")
                }
            },
        },
        {
            name: "get user",
            test: func(t *testing.T, db *DB) {
                db.CreateUser("Bob")
                user, err := db.GetUser("Bob")
                if err != nil {
                    t.Errorf("GetUser failed: %v", err)
                }
                if user.Name != "Bob" {
                    t.Errorf("User name = %q, want %q", user.Name, "Bob")
                }
            },
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            db := setupTestDB(t)
            defer db.Close()
            tt.test(t, db)
        })
    }
}

type DB struct{}

func setupTestDB(t *testing.T) *DB {
    // Setup test database
    return &DB{}
}

func (db *DB) Close() {
    // Cleanup
}

func (db *DB) CreateUser(name string) (*User, error) {
    return &User{Name: name}, nil
}

func (db *DB) GetUser(name string) (*User, error) {
    return &User{Name: name}, nil
}

type User struct {
    Name string
}

Summary

Table-driven tests are powerful:

  • Write comprehensive tests efficiently
  • Use subtests for organization
  • Test edge cases and error conditions
  • Keep test data simple and clear
  • Use descriptive test names
  • Leverage helper functions
  • Document complex tests

Master table-driven testing for maintainable test suites.

Comments