Writing testable code in Go significantly enhances maintenance and reliability. I’ve found that incorporating specific practices from the start saves considerable time and effort during development.
Go’s approach to testability differs from other languages due to its simplicity and standard library support. After working with numerous Go codebases, I’ve identified seven crucial strategies that consistently produce testable, maintainable code.
Dependency Injection
Dependency injection is fundamental to testable Go code. Instead of creating dependencies within functions, pass them as parameters:
// Hard to test
func FetchUserData() ([]User, error) {
db := database.Connect() // Direct dependency
return db.GetAllUsers()
}
// Testable version
func FetchUserData(db Database) ([]User, error) {
return db.GetAllUsers() // Injected dependency
}
This pattern allows me to substitute real implementations with test versions. For testing, I can provide a mock database that returns predictable results without touching actual data stores.
Dependency injection applies to time-based functions too. I often create a time provider interface:
type TimeProvider interface {
Now() time.Time
}
type realTime struct{}
func (r realTime) Now() time.Time {
return time.Now()
}
func SendTimedGreeting(timeProvider TimeProvider) string {
hour := timeProvider.Now().Hour()
if hour < 12 {
return "Good morning"
}
return "Good afternoon"
}
In tests, I can create a fixed-time implementation:
type fixedTime struct {
fixedTime time.Time
}
func (f fixedTime) Now() time.Time {
return f.fixedTime
}
func TestSendTimedGreeting(t *testing.T) {
morningTime := fixedTime{time.Date(2023, 1, 1, 9, 0, 0, 0, time.UTC)}
afternoonTime := fixedTime{time.Date(2023, 1, 1, 14, 0, 0, 0, time.UTC)}
if got := SendTimedGreeting(morningTime); got != "Good morning" {
t.Errorf("expected morning greeting, got %s", got)
}
if got := SendTimedGreeting(afternoonTime); got != "Good afternoon" {
t.Errorf("expected afternoon greeting, got %s", got)
}
}
Interface Segregation
Go interfaces are particularly powerful for testing because they’re satisfied implicitly. I’ve found that small, focused interfaces make testing simpler:
// Too broad
type DataStore interface {
Connect() error
Close() error
GetUser(id string) (User, error)
SaveUser(user User) error
DeleteUser(id string) error
// Many more methods...
}
// Better for testing
type UserGetter interface {
GetUser(id string) (User, error)
}
type UserSaver interface {
SaveUser(user User) error
}
With smaller interfaces, test implementations require less code:
type mockUserGetter struct{}
func (m mockUserGetter) GetUser(id string) (User, error) {
return User{ID: id, Name: "Test User"}, nil
}
func TestUserProcessor(t *testing.T) {
// Only need to implement GetUser, not the entire DataStore
processor := NewUserProcessor(mockUserGetter{})
result, err := processor.Process("user-123")
// Assert on result...
}
This approach follows the interface segregation principle and makes tests more focused and maintainable.
Avoiding Global State
Global variables create hidden dependencies that complicate testing. I’ve refactored many functions from this:
var defaultClient *http.Client
func FetchURL(url string) ([]byte, error) {
resp, err := defaultClient.Get(url)
// Process response...
}
To this more testable version:
func FetchURL(client *http.Client, url string) ([]byte, error) {
resp, err := client.Get(url)
// Process response...
}
When globals are unavoidable, I ensure they’re accessible and resettable for tests:
var (
defaultTimeout = 30 * time.Second
defaultRetries = 3
)
// For tests
func SetDefaultTimeout(t time.Duration) {
defaultTimeout = t
}
func ResetDefaults() {
defaultTimeout = 30 * time.Second
defaultRetries = 3
}
In test cleanup:
func TestSomething(t *testing.T) {
// Change for this test
SetDefaultTimeout(1 * time.Second)
// Reset after test
t.Cleanup(func() {
ResetDefaults()
})
// Test logic...
}
Export Internal Logic
Go’s package system supports testing of internal components through _test.go
files in the same package. For public packages, I often create testable units while keeping the API clean:
// api.go
package myapi
// Public API
func ProcessRequest(req Request) Response {
validated := validateRequest(req)
data := extractData(validated)
return formatResponse(data)
}
// internal.go
package myapi
// Exported for testing but still internal
func validateRequest(req Request) validatedRequest {
// Complex logic here
}
func extractData(req validatedRequest) data {
// More complex logic
}
func formatResponse(d data) Response {
// Final formatting
}
Then I can test individual components:
// internal_test.go
package myapi
func TestValidateRequest(t *testing.T) {
req := Request{/* test data */}
validated := validateRequest(req)
// Assert on validation results
if validated.field != expectedValue {
t.Errorf("expected %v, got %v", expectedValue, validated.field)
}
}
For truly internal functions that shouldn’t be exposed even to tests in the same package, I use a separate internal package and white-box testing techniques.
Table-Driven Tests
Table-driven tests are a Go standard that I apply consistently. They simplify adding test cases:
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
income float64
expectedTax float64
expectedBracket string
}{
{"Zero income", 0.0, 0.0, "Exempt"},
{"Low income", 10000.0, 1000.0, "Low"},
{"Medium income", 50000.0, 10000.0, "Medium"},
{"High income", 150000.0, 45000.0, "High"},
{"Edge case", 30000.0, 5000.0, "Low"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tax, bracket := CalculateTax(tt.income)
if tax != tt.expectedTax {
t.Errorf("expected tax %f, got %f", tt.expectedTax, tax)
}
if bracket != tt.expectedBracket {
t.Errorf("expected bracket %s, got %s", tt.expectedBracket, bracket)
}
})
}
}
This pattern makes adding new test cases trivial – just add another entry to the slice.
Concurrent Test Safety
Go tests can run in parallel with t.Parallel()
, but this requires careful design. I ensure tests don’t share state:
func TestConcurrentFunction(t *testing.T) {
t.Parallel() // Mark as parallel-safe
// Use local variables, not shared ones
localCache := NewCache()
processor := NewProcessor(localCache)
// Test logic with local resources...
}
For tests that use shared resources, I create isolated environments:
func TestDatabaseOperations(t *testing.T) {
t.Parallel()
// Create unique identifier for this test
testID := uuid.New().String()
// Use test-specific resources
dbName := fmt.Sprintf("test_db_%s", testID)
db := setupTestDatabase(dbName)
t.Cleanup(func() {
teardownTestDatabase(dbName)
})
// Test with isolated database...
}
This approach prevents flaky tests caused by concurrent operations affecting each other’s state.
Test Doubles
Creating effective test doubles (mocks, stubs, fakes) is essential for Go testing. I prefer simple manual implementations over complex mocking frameworks:
// Interface to mock
type UserRepository interface {
GetByID(id string) (User, error)
Save(user User) error
}
// Simple mock implementation
type mockUserRepository struct {
users map[string]User
saveCalled bool
saveArg User
}
func newMockUserRepo() *mockUserRepository {
return &mockUserRepository{
users: map[string]User{
"existing-id": {ID: "existing-id", Name: "Existing User"},
},
}
}
func (m *mockUserRepository) GetByID(id string) (User, error) {
user, exists := m.users[id]
if !exists {
return User{}, errors.New("user not found")
}
return user, nil
}
func (m *mockUserRepository) Save(user User) error {
m.saveCalled = true
m.saveArg = user
m.users[user.ID] = user
return nil
}
This mock implementation allows me to:
- Control returned values
- Verify function calls
- Capture arguments
Using it in tests:
func TestUserService(t *testing.T) {
mock := newMockUserRepo()
service := NewUserService(mock)
// Test getting existing user
user, err := service.GetUser("existing-id")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.Name != "Existing User" {
t.Errorf("expected 'Existing User', got %s", user.Name)
}
// Test saving user
err = service.UpdateUser(User{ID: "new-id", Name: "New User"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// Verify mock was called with correct args
if !mock.saveCalled {
t.Error("expected Save to be called")
}
if mock.saveArg.Name != "New User" {
t.Errorf("expected arg name 'New User', got %s", mock.saveArg.Name)
}
}
For advanced scenarios, I use testify/mock
or similar libraries, but simple hand-written mocks are often clearer and more maintainable.
Practical Examples
To demonstrate these practices together, here’s a complete example of a service that processes user data:
// Service definition with proper dependency injection
type UserService struct {
repo UserRepository
logger Logger
clock TimeProvider
}
func NewUserService(repo UserRepository, logger Logger, clock TimeProvider) *UserService {
return &UserService{
repo: repo,
logger: logger,
clock: clock,
}
}
// Small interfaces for dependencies
type UserRepository interface {
GetByID(id string) (User, error)
Save(user User) error
}
type Logger interface {
Info(msg string, args ...interface{})
Error(msg string, args ...interface{})
}
type TimeProvider interface {
Now() time.Time
}
// Method using dependencies properly
func (s *UserService) UpdateLastActive(userID string) error {
user, err := s.repo.GetByID(userID)
if err != nil {
s.logger.Error("Failed to get user: %v", err)
return fmt.Errorf("getting user: %w", err)
}
user.LastActive = s.clock.Now()
user.ActivityCount++
if err := s.repo.Save(user); err != nil {
s.logger.Error("Failed to save user: %v", err)
return fmt.Errorf("saving user: %w", err)
}
s.logger.Info("Updated user %s last active time", userID)
return nil
}
Testing this service becomes straightforward:
func TestUpdateLastActive(t *testing.T) {
// Test fixtures
fixedTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
userID := "test-user"
// Test cases
tests := []struct {
name string
getUserError error
saveUserError error
expectedError bool
expectedCalls struct {
getUser bool
saveUser bool
}
}{
{
name: "successful update",
getUserError: nil,
saveUserError: nil,
expectedError: false,
expectedCalls: struct {
getUser bool
saveUser bool
}{true, true},
},
{
name: "get user fails",
getUserError: errors.New("db error"),
saveUserError: nil,
expectedError: true,
expectedCalls: struct {
getUser bool
saveUser bool
}{true, false},
},
{
name: "save user fails",
getUserError: nil,
saveUserError: errors.New("db error"),
expectedError: true,
expectedCalls: struct {
getUser bool
saveUser bool
}{true, true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mocks
mockRepo := &mockUserRepository{
getUserError: tt.getUserError,
saveUserError: tt.saveUserError,
}
mockLogger := &mockLogger{}
mockClock := &mockTimeProvider{
currentTime: fixedTime,
}
// Create service with mocks
service := NewUserService(mockRepo, mockLogger, mockClock)
// Call method
err := service.UpdateLastActive(userID)
// Verify error
if (err != nil) != tt.expectedError {
t.Errorf("expected error: %v, got: %v", tt.expectedError, err != nil)
}
// Verify mock calls
if mockRepo.getUserCalled != tt.expectedCalls.getUser {
t.Errorf("expected GetByID called: %v, got: %v",
tt.expectedCalls.getUser, mockRepo.getUserCalled)
}
if mockRepo.saveUserCalled != tt.expectedCalls.saveUser {
t.Errorf("expected Save called: %v, got: %v",
tt.expectedCalls.saveUser, mockRepo.saveUserCalled)
}
// Verify user data if save was called
if mockRepo.saveUserCalled {
savedUser := mockRepo.savedUser
if savedUser.LastActive != fixedTime {
t.Errorf("expected LastActive: %v, got: %v",
fixedTime, savedUser.LastActive)
}
if savedUser.ActivityCount != 1 {
t.Errorf("expected ActivityCount: 1, got: %d",
savedUser.ActivityCount)
}
}
})
}
}
This example demonstrates all seven practices working together: dependency injection, small interfaces, avoiding globals, exposing testable functions, table-driven tests, concurrent safety, and effective test doubles.
By consistently applying these practices in my Go projects, I’ve created codebases that are easy to test, maintain, and extend. I’ve found that investing in testability from the beginning pays significant dividends throughout the project lifecycle.