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
- Use descriptive test names - Clearly indicate what’s being tested
- Group related tests - Use table-driven tests for similar cases
- Test edge cases - Empty values, nil, zero, negative numbers
- Test error cases - Invalid input, boundary conditions
- Use subtests - For better organization and reporting
- Keep test data simple - Easy to understand and maintain
- Use helper functions - For common test setup
- 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