Well-organized tests are as important as well-organized code. This guide covers best practices for structuring, naming, and maintaining test suites in Go. For more context, see Go Installation Guide, Go Ecosystem Overview. See Golang Guide for more context.
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