Skip to main content
โšก Calmops

Test Organization and Best Practices in Go

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