Testing with godi
Dependency injection makes testing easier. Replace real implementations with mocks, test services in isolation, and verify behavior.
Testing Strategies
1. Unit Testing with Mocks
Test a service by injecting mock dependencies:
// service.go
type UserService struct {
repo UserRepository
logger Logger
}
func NewUserService(repo UserRepository, logger Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
s.logger.Debug("fetching user", "id", id)
return s.repo.GetByID(ctx, id)
}
// service_test.go
type mockRepository struct {
users map[int]*User
err error
}
func (m *mockRepository) GetByID(ctx context.Context, id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}
type mockLogger struct{}
func (m *mockLogger) Debug(msg string, args ...any) {}
func TestUserService_GetUser(t *testing.T) {
// Arrange
repo := &mockRepository{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
},
}
logger := &mockLogger{}
service := NewUserService(repo, logger)
// Act
user, err := service.GetUser(context.Background(), 1)
// Assert
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
2. Integration Testing with Test Container
Create a test provider with some real, some mock services:
func TestUserService_Integration(t *testing.T) {
services := godi.NewCollection()
// Real services
services.AddSingleton(func() *Config {
return &Config{DatabaseURL: "postgres://test:test@localhost/test"}
})
services.AddSingleton(NewDatabase)
services.AddScoped(NewUserRepository)
// Mock logger for quiet tests
services.AddSingleton(func() Logger {
return &mockLogger{}
})
services.AddScoped(NewUserService)
provider, err := services.Build()
if err != nil {
t.Fatal(err)
}
defer provider.Close()
// Create scope and test
scope, _ := provider.CreateScope(context.Background())
defer scope.Close()
service := godi.MustResolve[*UserService](scope)
// ... test with real database
}
3. HTTP Handler Testing
Test handlers with request scopes:
func TestUserHandler_List(t *testing.T) {
services := godi.NewCollection()
// Mock repository with test data
services.AddScoped(func() *UserRepository {
return &mockUserRepository{
users: []User{{ID: 1, Name: "Alice"}},
}
})
services.AddScoped(NewRequestContext)
services.AddScoped(NewUserService)
services.AddScoped(NewUserController)
provider, _ := services.Build()
defer provider.Close()
// Create handler
mux := http.NewServeMux()
mux.HandleFunc("GET /users", godihttp.Handle((*UserController).List))
handler := godihttp.ScopeMiddleware(provider)(mux)
// Make request
req := httptest.NewRequest("GET", "/users", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Assert
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
var users []User
json.NewDecoder(rec.Body).Decode(&users)
if len(users) != 1 {
t.Errorf("expected 1 user, got %d", len(users))
}
}
Test Modules
Create reusable test modules:
// test/modules.go
package test
import "github.com/junioryono/godi/v4"
// MockInfrastructureModule provides mocks for all infrastructure
func MockInfrastructureModule() godi.Module {
return func(services *godi.ServiceCollection) {
services.AddSingleton(func() *Config {
return &Config{Debug: true}
})
services.AddSingleton(func() Logger {
return &mockLogger{}
})
}
}
// TestDatabaseModule provides a test database
func TestDatabaseModule(url string) godi.Module {
return func(services *godi.ServiceCollection) {
services.AddSingleton(func() (*Database, error) {
return NewDatabase(&Config{DatabaseURL: url})
})
}
}
Use in tests:
func TestWithModules(t *testing.T) {
services := godi.NewCollection()
services.AddModule(test.MockInfrastructureModule())
services.AddModule(test.TestDatabaseModule("postgres://test@localhost/test"))
services.AddModule(users.Module())
provider, _ := services.Build()
defer provider.Close()
// ...
}
Interface-Based Mocking
Design for testability with interfaces:
// Define interface
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
// Real implementation
type postgresUserRepository struct {
db *Database
}
func NewUserRepository(db *Database) UserRepository {
return &postgresUserRepository{db: db}
}
// Register with interface
services.AddScoped(NewUserRepository)
// In tests, replace with mock
type mockUserRepository struct {
users map[int]*User
created []*User
getError error
}
func (m *mockUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
if m.getError != nil {
return nil, m.getError
}
return m.users[id], nil
}
func (m *mockUserRepository) Create(ctx context.Context, user *User) error {
m.created = append(m.created, user)
return nil
}
// Register mock
services.AddScoped(func() UserRepository {
return &mockUserRepository{
users: map[int]*User{1: {ID: 1, Name: "Test"}},
}
})
Testing Error Paths
Test how services handle failures:
func TestUserService_GetUser_NotFound(t *testing.T) {
services := godi.NewCollection()
services.AddScoped(func() UserRepository {
return &mockUserRepository{
getError: sql.ErrNoRows,
}
})
services.AddSingleton(func() Logger { return &mockLogger{} })
services.AddScoped(NewUserService)
provider, _ := services.Build()
scope, _ := provider.CreateScope(context.Background())
defer scope.Close()
service := godi.MustResolve[*UserService](scope)
_, err := service.GetUser(context.Background(), 999)
if err != sql.ErrNoRows {
t.Errorf("expected ErrNoRows, got %v", err)
}
}
Table-Driven Tests
Combine with table-driven tests:
func TestUserService_GetUser_Cases(t *testing.T) {
cases := []struct {
name string
userID int
mockUsers map[int]*User
mockError error
wantName string
wantErr bool
}{
{
name: "found",
userID: 1,
mockUsers: map[int]*User{1: {ID: 1, Name: "Alice"}},
wantName: "Alice",
},
{
name: "not found",
userID: 999,
mockUsers: map[int]*User{},
wantErr: true,
},
{
name: "database error",
userID: 1,
mockError: errors.New("connection lost"),
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
services := godi.NewCollection()
services.AddScoped(func() UserRepository {
return &mockUserRepository{
users: tc.mockUsers,
getError: tc.mockError,
}
})
services.AddSingleton(func() Logger { return &mockLogger{} })
services.AddScoped(NewUserService)
provider, _ := services.Build()
scope, _ := provider.CreateScope(context.Background())
defer scope.Close()
service := godi.MustResolve[*UserService](scope)
user, err := service.GetUser(context.Background(), tc.userID)
if tc.wantErr && err == nil {
t.Error("expected error")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if !tc.wantErr && user.Name != tc.wantName {
t.Errorf("expected %s, got %s", tc.wantName, user.Name)
}
})
}
}
Best Practices
Use interfaces for dependencies you need to mock
Create test modules for reusable mock configurations
Test error paths not just happy paths
Close providers and scopes to avoid resource leaks
Isolate tests - each test should create its own provider
Next: Learn about debugging errors