go-table-driven-tests
Go Table-Driven Tests
Use this skill when writing or modifying Go table-driven tests. It ensures tests follow established patterns.
Core Principles
- One test function, many cases - Define test cases in a slice and iterate with
t.Run() - Explicit naming - Each case has a
namefield that becomes the subtest name - Structured inputs - Use struct fields for inputs, expected outputs, and configuration
- Helper functions - Use
t.Helper()in test helpers for proper line reporting - Environment guards - Skip integration tests when credentials are unavailable
Table Structure Pattern
func TestFunctionName(t *testing.T) {
tests := []struct {
name string // required: subtest name
input Type // function input
want Type // expected output
wantErr error // expected error (nil for success)
errCheck func(error) bool // optional: custom error validation
setupEnv func() func() // optional: env setup, returns cleanup
}{
{
name: "descriptive case name",
input: "test input",
want: "expected output",
},
// ... more cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test implementation using tt fields
})
}
}
Field Guidelines
| Field | Required | Purpose |
|---|---|---|
name |
Yes | Subtest name - be descriptive and specific |
input/args |
Varies | Input values for the function under test |
want/want* |
Varies | Expected output values (e.g., wantErr, wantResult) |
errCheck |
No | Custom error validation function |
setupEnv |
No | Environment setup function returning cleanup |
Naming Conventions
- Test function:
Test<FunctionName>orTest<FunctionName>_<Scenario> - Subtest names: lowercase, descriptive, spaces allowed
- Input fields: match parameter names or use
input/args - Output fields: prefix with
want(e.g.,want,wantErr,wantResult)
Common Patterns
1. Basic Table Test
func TestWithRegion(t *testing.T) {
tests := []struct {
name string
region string
}{
{"auto region", "auto"},
{"us-west-2", "us-west-2"},
{"eu-central-1", "eu-central-1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := &Options{}
WithRegion(tt.region)(o)
if o.Region != tt.region {
t.Errorf("Region = %v, want %v", o.Region, tt.region)
}
})
}
}
2. Error Checking with wantErr
func TestNew_errorCases(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
}{
{"empty input", "", ErrInvalidInput},
{"invalid input", "!!!", ErrInvalidInput},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.input)
if !errors.Is(err, tt.wantErr) {
t.Errorf("error = %v, want %v", err, tt.wantErr)
}
})
}
}
3. Custom Error Validation with errCheck
func TestNew_customErrors(t *testing.T) {
tests := []struct {
name string
setupEnv func() func()
wantErr error
errCheck func(error) bool
}{
{
name: "no bucket name returns ErrNoBucketName",
setupEnv: func() func() { return func() {} },
wantErr: ErrNoBucketName,
errCheck: func(err error) bool {
return errors.Is(err, ErrNoBucketName)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cleanup := tt.setupEnv()
defer cleanup()
_, err := New(context.Background())
if tt.wantErr != nil {
if tt.errCheck != nil {
if !tt.errCheck(err) {
t.Errorf("error = %v, want %v", err, tt.wantErr)
}
}
}
})
}
}
4. Environment Setup with setupEnv
func TestNew_envVarOverrides(t *testing.T) {
tests := []struct {
name string
setupEnv func() func()
options []Option
wantErr error
}{
{
name: "bucket from env var",
setupEnv: func() func() {
os.Setenv("TIGRIS_STORAGE_BUCKET", "test-bucket")
return func() { os.Unsetenv("TIGRIS_STORAGE_BUCKET") }
},
wantErr: nil,
},
{
name: "bucket from option overrides env var",
setupEnv: func() func() {
os.Setenv("TIGRIS_STORAGE_BUCKET", "env-bucket")
return func() { os.Unsetenv("TIGRIS_STORAGE_BUCKET") }
},
options: []Option{
func(o *Options) { o.BucketName = "option-bucket" },
},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cleanup := tt.setupEnv()
defer cleanup()
_, err := New(context.Background(), tt.options...)
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Errorf("error = %v, want %v", err, tt.wantErr)
}
})
}
}
Integration Test Guards
For tests requiring real credentials, use a skip helper:
// skipIfNoCreds skips the test if Tigris credentials are not set.
// Use this for integration tests that require real Tigris operations.
func skipIfNoCreds(t *testing.T) {
t.Helper()
if os.Getenv("TIGRIS_STORAGE_ACCESS_KEY_ID") == "" ||
os.Getenv("TIGRIS_STORAGE_SECRET_ACCESS_KEY") == "" {
t.Skip("skipping: TIGRIS_STORAGE_ACCESS_KEY_ID and TIGRIS_STORAGE_SECRET_ACCESS_KEY not set")
}
}
func TestCreateBucket(t *testing.T) {
tests := []struct {
name string
bucket string
options []BucketOption
wantErr error
}{
{
name: "create snapshot-enabled bucket",
bucket: "test-bucket",
options: []BucketOption{WithEnableSnapshot()},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
skipIfNoCreds(t)
// test implementation
})
}
}
Test Helpers
Use t.Helper() in helper functions for proper line number reporting:
func setupTestBucket(t *testing.T, ctx context.Context, client *Client) string {
t.Helper()
skipIfNoCreds(t)
bucket := "test-bucket-" + randomSuffix()
err := client.CreateBucket(ctx, bucket)
if err != nil {
t.Fatalf("failed to create test bucket: %v", err)
}
return bucket
}
func cleanupTestBucket(t *testing.T, ctx context.Context, client *Client, bucket string) {
t.Helper()
err := client.DeleteBucket(ctx, bucket, WithForceDelete())
if err != nil {
t.Logf("warning: failed to cleanup test bucket %s: %v", bucket, err)
}
}
Checklist
When writing table-driven tests:
- Table struct has
namefield as first field - Each test case has a descriptive name
- Input fields use clear naming (match parameters or use
input) - Expected output fields prefixed with
want - Iteration uses
t.Run(tt.name, func(t *testing.T) { ... }) - Error checking uses
errors.Is()for error comparison - Environment setup includes cleanup in
defer - Integration tests use
skipIfNoCreds(t)helper - Test helpers use
t.Helper()for proper line reporting - Test file is
*_test.goand lives next to the code it tests
Best Practices
Detailed Error Messages
Include both actual and expected values in error messages for clear failure diagnosis:
t.Errorf("got %q, want %q", actual, expected)
Note: t.Errorf is not an assertion - the test continues after logging. This helps identify whether failures are systematic or isolated to specific cases.
Maps for Test Cases
Consider using a map instead of a slice for test cases. Map iteration order is non-deterministic, which ensures test cases are truly independent:
tests := map[string]struct {
input string
want string
}{
"empty string": {input: "", want: ""},
"single character": {input: "x", want: "x"},
"multi-byte glyph": {input: "🎉", want: "🎉"},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := process(tt.input)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
Parallel Testing
Add t.Parallel() calls to run test cases in parallel. The loop variable is automatically captured per iteration:
func TestFunction(t *testing.T) {
tests := []struct {
name string
input string
}{
// ... test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // marks this subtest as parallel
// test implementation
})
}
}
References
- Go Wiki: TableDrivenTests - Official Go community best practices for table-driven testing
- Go Testing Package - Standard library testing documentation
- Prefer Table Driven Tests - Dave Cheney's guide on when and why to use table-driven tests over traditional test structures