When writing unit tests, you often need to test code that depends on external services, databases, file systems, or other components. These dependencies can make tests slow, unreliable, or difficult to set up. Mocking allows you to replace these dependencies with controlled implementations that you can configure to return specific values or simulate different scenarios.
The key to effective mocking in Go is designing your code with interfaces. When your code depends on interfaces rather than concrete types, you can easily swap in mock implementations during testing. This approach aligns with Go's philosophy of implicit interfaces—you don't need to explicitly declare that a type implements an interface, you just need to implement the required methods.
Consider a service that needs to fetch user data from a database. Instead of directly depending on a database connection, the service depends on an interface that defines the operations it needs:
package user
type User struct {
ID int
Name string
Email string
}
// UserRepository defines the interface for user data operations
type UserRepository interface {
GetUser(id int) (*User, error)
SaveUser(user *User) error
}
// UserService depends on the interface, not a concrete implementation
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUserByID(id int) (*User, error) {
return s.repo.GetUser(id)
}
In production, you would pass a real database implementation. In tests, you can pass a mock that implements the same interface:
package user
import (
"errors"
"testing"
)
// MockUserRepository is a mock implementation for testing
type MockUserRepository struct {
users map[int]*User
getUserError error
saveUserError error
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[int]*User),
}
}
func (m *MockUserRepository) GetUser(id int) (*User, error) {
if m.getUserError != nil {
return nil, m.getUserError
}
return m.users[id], nil
}
func (m *MockUserRepository) SaveUser(user *User) error {
if m.saveUserError != nil {
return m.saveUserError
}
m.users[user.ID] = user
return nil
}
// Helper methods to configure the mock's behavior
func (m *MockUserRepository) SetUser(user *User) {
m.users[user.ID] = user
}
func (m *MockUserRepository) SetGetUserError(err error) {
m.getUserError = err
}
func TestUserService_GetUserByID(t *testing.T) {
mockRepo := NewMockUserRepository()
service := NewUserService(mockRepo)
// Configure the mock to return a specific user
expectedUser := &User{ID: 1, Name: "Alice", Email: "[email protected]"}
mockRepo.SetUser(expectedUser)
// Test the service
user, err := service.GetUserByID(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.ID != expectedUser.ID {
t.Errorf("got user ID %d, want %d", user.ID, expectedUser.ID)
}
if user.Name != expectedUser.Name {
t.Errorf("got user name %q, want %q", user.Name, expectedUser.Name)
}
}
This approach gives you several advantages. You can test your service logic without setting up a real database, making tests fast and reliable. You can easily simulate error conditions by configuring the mock to return errors. You can test edge cases by controlling exactly what the mock returns. And your tests are isolated—they don't depend on external systems that might be unavailable or have different data.
When testing error conditions, mocks are particularly useful. You can configure the mock to return specific errors and verify that your code handles them correctly:
func TestUserService_GetUserByID_Error(t *testing.T) {
mockRepo := NewMockUserRepository()
service := NewUserService(mockRepo)
// Configure the mock to return an error
expectedError := errors.New("database connection failed")
mockRepo.SetGetUserError(expectedError)
// Test error handling
user, err := service.GetUserByID(1)
if err == nil {
t.Fatal("expected error, got nil")
}
if err != expectedError {
t.Errorf("got error %v, want %v", err, expectedError)
}
if user != nil {
t.Errorf("expected nil user on error, got %v", user)
}
}
For more complex scenarios, you might want to track how methods are called. This is useful for verifying that your code calls dependencies with the correct parameters:
type MockUserRepositoryWithCalls struct {
MockUserRepository
getUserCalls []int
saveUserCalls []*User
}
func (m *MockUserRepositoryWithCalls) GetUser(id int) (*User, error) {
m.getUserCalls = append(m.getUserCalls, id)
return m.MockUserRepository.GetUser(id)
}
func (m *MockUserRepositoryWithCalls) SaveUser(user *User) error {
m.saveUserCalls = append(m.saveUserCalls, user)
return m.MockUserRepository.SaveUser(user)
}
func TestUserService_CallsRepository(t *testing.T) {
mockRepo := &MockUserRepositoryWithCalls{
MockUserRepository: *NewMockUserRepository(),
}
service := NewUserService(mockRepo)
service.GetUserByID(42)
if len(mockRepo.getUserCalls) != 1 {
t.Errorf("expected 1 call to GetUser, got %d", len(mockRepo.getUserCalls))
}
if mockRepo.getUserCalls[0] != 42 {
t.Errorf("expected GetUser called with 42, got %d", mockRepo.getUserCalls[0])
}
}
Sometimes you need to mock functions rather than methods. In Go, you can do this by storing the function in a variable and reassigning it in tests. However, this approach requires careful design to be testable:
package httpclient
import "net/http"
// HTTPClient is a function type that can be mocked
type HTTPClient func(*http.Request) (*http.Response, error)
var defaultClient HTTPClient = http.DefaultClient.Do
func FetchData(url string, client HTTPClient) ([]byte, error) {
if client == nil {
client = defaultClient
}
req, _ := http.NewRequest("GET", url, nil)
resp, err := client(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// ... read response
return nil, nil
}
// In tests:
func TestFetchData(t *testing.T) {
mockClient := func(*http.Request) (*http.Response, error) {
// Return mock response
return &http.Response{StatusCode: 200}, nil
}
data, err := FetchData("http://example.com", mockClient)
// ... test assertions
}
When designing code for testability, prefer interfaces over concrete types for dependencies. This doesn't mean every type needs an interface—only create interfaces for dependencies that you want to mock. Keep mock implementations simple and focused on what you need for testing. Don't over-engineer mocks with features you don't use. And remember that mocks are for unit tests—for integration tests, you might want to use real implementations to verify that components work together correctly.