Test Organization and Best Practices in Go
Well-organized tests are as important as well-organized code. This guide covers best practices for structuring, naming, and maintaining test suites in Go.
Test File Organization
Standard Test Structure
// user.go - Production code
package user
type User struct {
ID string
Name string
Email string
}
func (u *User) IsValid() bool {
return u.ID != "" && u.Name != "" && u.Email != ""
}
// user_test.go - Test code
package user
import (
"testing"
)
func TestUserIsValid(t *testing.T) {
tests := []struct {
name string
user *User
expected bool
}{
{
name: "valid user",
user: &User{ID: "1", Name: "Alice", Email: "[email protected]"},
expected: true,
},
{
name: "missing ID",
user: &User{ID: "", Name: "Alice", Email: "[email protected]"},
expected: false,
},
{
name: "missing name",
user: &User{ID: "1", Name: "", Email: "[email protected]"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.user.IsValid()
if result != tt.expected {
t.Errorf("IsValid() = %v; want %v", result, tt.expected)
}
})
}
}
Separate Test Packages
// user.go
package user
type User struct {
ID string
Name string
}
// user_test.go - Using separate package for integration tests
package user_test
import (
"testing"
"myapp/user"
)
func TestUserIntegration(t *testing.T) {
u := &user.User{ID: "1", Name: "Alice"}
if u.ID != "1" {
t.Error("User ID not set correctly")
}
}
Test Naming Conventions
Naming Test Functions
package main
import (
"testing"
)
// Good: Clear, descriptive names
func TestUserCreation(t *testing.T) {}
func TestUserCreation_WithValidData(t *testing.T) {}
func TestUserCreation_WithInvalidEmail(t *testing.T) {}
// Good: Using subtests
func TestUser(t *testing.T) {
t.Run("creation", func(t *testing.T) {})
t.Run("validation", func(t *testing.T) {})
t.Run("deletion", func(t *testing.T) {})
}
// Bad: Unclear names
func TestUser1(t *testing.T) {}
func Test(t *testing.T) {}
func TestX(t *testing.T) {}
Naming Test Variables
package main
import (
"testing"
)
func TestWithGoodNaming(t *testing.T) {
// Good: Clear variable names
inputUser := &User{ID: "1", Name: "Alice"}
expectedError := "invalid email"
actualResult := validateUser(inputUser)
if actualResult != expectedError {
t.Errorf("Expected %s, got %s", expectedError, actualResult)
}
}
func TestWithBadNaming(t *testing.T) {
// Bad: Unclear variable names
u := &User{ID: "1", Name: "Alice"}
e := "invalid email"
r := validateUser(u)
if r != e {
t.Errorf("Expected %s, got %s", e, r)
}
}
Test Helpers and Utilities
Helper Functions
package main
import (
"testing"
)
// Helper to create test users
func createTestUser(t *testing.T, id, name string) *User {
t.Helper()
user := &User{ID: id, Name: name}
if !user.IsValid() {
t.Fatalf("Failed to create valid test user")
}
return user
}
// Helper to assert equality
func assertEqual(t *testing.T, expected, actual interface{}) {
t.Helper()
if expected != actual {
t.Errorf("Expected %v, got %v", expected, actual)
}
}
// Helper to assert error
func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Error("Expected error, got nil")
}
}
// Usage in tests
func TestUserWithHelpers(t *testing.T) {
user := createTestUser(t, "1", "Alice")
assertEqual(t, "1", user.ID)
assertEqual(t, "Alice", user.Name)
}
Setup and Teardown
package main
import (
"testing"
)
func TestWithSetupTeardown(t *testing.T) {
// Setup
user := &User{ID: "1", Name: "Alice"}
// Test
if user.ID != "1" {
t.Error("User ID not set")
}
// Teardown (if needed)
user = nil
}
func TestWithCleanup(t *testing.T) {
// Setup
resource := "test resource"
// Register cleanup
t.Cleanup(func() {
// Cleanup code
resource = ""
})
// Test
if resource != "test resource" {
t.Error("Resource not initialized")
}
}
Test Patterns
Table-Driven Tests
package main
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", 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)
}
})
}
}
Subtests
package main
import (
"testing"
)
func TestUserOperations(t *testing.T) {
t.Run("creation", func(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
if user.ID != "1" {
t.Error("User creation failed")
}
})
t.Run("validation", func(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
if !user.IsValid() {
t.Error("User validation failed")
}
})
t.Run("invalid user", func(t *testing.T) {
user := &User{ID: "", Name: ""}
if user.IsValid() {
t.Error("Invalid user should not be valid")
}
})
}
Test Quality
Avoiding Common Mistakes
package main
import (
"testing"
)
// โ Bad: Testing implementation details
func TestBadImplementation(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
// Testing internal state instead of behavior
if user.ID != "1" {
t.Error("ID not set")
}
}
// โ
Good: Testing behavior
func TestGoodBehavior(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
if !user.IsValid() {
t.Error("Valid user should pass validation")
}
}
// โ Bad: Unclear error messages
func TestBadErrorMessage(t *testing.T) {
if 2+2 != 4 {
t.Error("failed")
}
}
// โ
Good: Clear error messages
func TestGoodErrorMessage(t *testing.T) {
result := 2 + 2
if result != 4 {
t.Errorf("Expected 2+2=4, got %d", result)
}
}
// โ Bad: Ignoring errors
func TestBadErrorHandling(t *testing.T) {
user, _ := getUser("1")
if user == nil {
t.Error("User not found")
}
}
// โ
Good: Checking errors
func TestGoodErrorHandling(t *testing.T) {
user, err := getUser("1")
if err != nil {
t.Fatalf("Failed to get user: %v", err)
}
if user == nil {
t.Error("User not found")
}
}
Test Maintenance
Keeping Tests DRY
package main
import (
"testing"
)
// โ Bad: Repeated setup code
func TestUser1(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
// Test code
}
func TestUser2(t *testing.T) {
user := &User{ID: "1", Name: "Alice"}
// Test code
}
// โ
Good: Shared setup
func setupTestUser() *User {
return &User{ID: "1", Name: "Alice"}
}
func TestUser1Good(t *testing.T) {
user := setupTestUser()
// Test code
}
func TestUser2Good(t *testing.T) {
user := setupTestUser()
// Test code
}
Test Documentation
package main
import (
"testing"
)
// TestUserValidation verifies that User.IsValid() correctly
// identifies valid and invalid users based on required fields.
//
// Valid users must have:
// - Non-empty ID
// - Non-empty Name
// - Non-empty Email
func TestUserValidation(t *testing.T) {
tests := []struct {
name string
user *User
expected bool
}{
{
name: "valid user with all fields",
user: &User{ID: "1", Name: "Alice", Email: "[email protected]"},
expected: true,
},
{
name: "invalid user missing ID",
user: &User{ID: "", Name: "Alice", Email: "[email protected]"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.user.IsValid()
if result != tt.expected {
t.Errorf("IsValid() = %v; want %v", result, tt.expected)
}
})
}
}
Best Practices Checklist
โ Good Practices
// Use table-driven tests
tests := []struct{
name string
// fields
}{}
// Use subtests for organization
t.Run("subtest", func(t *testing.T) {})
// Use t.Helper() in helper functions
func helper(t *testing.T) {
t.Helper()
}
// Use clear, descriptive names
func TestUserCreation(t *testing.T) {}
// Test behavior, not implementation
// Verify outcomes, not internal state
// Keep tests focused and independent
// One assertion per test when possible
// Use table-driven tests for multiple scenarios
// Reduces code duplication
โ Anti-Patterns
// Don't use generic test names
func TestUser(t *testing.T) {}
// Don't test implementation details
// Test public behavior
// Don't create interdependent tests
// Each test should be independent
// Don't ignore errors
// Always check and handle errors
// Don't use magic numbers
// Use named constants
// Don't skip cleanup
// Always clean up resources
Resources
Summary
Well-organized tests are crucial for maintainable code:
- Use table-driven tests for multiple scenarios
- Organize tests with subtests
- Use clear, descriptive names
- Create helper functions to reduce duplication
- Test behavior, not implementation
- Keep tests independent and focused
- Document complex test logic
- Maintain tests as carefully as production code
With these practices, you can build and maintain high-quality test suites in Go.
Comments