Stop Mocking

Rethinking Service Mocking in Software Testing

When I encounter a new codebase littered with mocked services in tests, my heart sinks. This usually signals a codebase riddled with tangled dependencies and, at its worst, a convoluted system crafted with overconfidence. Here’s why excessive reliance on mocking might be indicative of deeper issues.

Mocking in tests often stems from several missteps and misconceptions:

  1. Confusion Over Testing Types:
    • End-to-End (E2E) Tests ensure the system works as intended from start to finish in a production-like setting.
    • Integration Tests check if multiple components work well together.
    • Unit Tests focus on verifying the smallest pieces of code independently.
  2. Overemphasis on Test Coverage: An insistence on absolute test coverage often originates from management far removed from the realities of coding, promoting quantity over quality.
  3. Poor Encapsulation Practices: Well-encapsulated code, which avoids excessive coupling, rarely needs mocks. True separation of concerns means mocks are seldom necessary.
  4. Misguided Metrics of Success: Teams that measure success by lines of code or user stories completed might fall into the trap of using mocks to inflate their output, which can lead to perverse incentives.

A Modern Approach to Design and Testing

Let’s look towards a more sustainable and modern approach to software development and testing:

Illustrating Good vs. Bad Practices

Example of Problematic Code:

type UserService struct {
    // Direct dependency on external services.
}

func (s *UserService) GetUserProcessedData(userID string) (*ProcessedData, error) {
    userData, err := s.fetchUserData(userID) // Direct API call
    if err != nil {
        return nil, err
    }
    processedData := s.processUserData(userData)
    return processedData, nil
}

This example lacks separation of concerns, blending data retrieval with business logic in a way that’s not conducive to clean, testable code.

A Better Structure:

// UserDataFetcher defines an interface for data retrieval.
type UserDataFetcher interface {
    FetchUserData(userID string) (*UserData, error)
}

// Actual implementation for fetching user data.
type ExternalUserDataService struct{}

func (s *ExternalUserDataService) FetchUserData(userID string) (*UserData, error) {
    // Fetches data from external sources.
}

// UserService leverages the UserDataFetcher abstraction.
type UserService struct {
    DataFetcher UserDataFetcher
}

func (s *UserService) GetUserProcessedData(userID string) (*ProcessedData, error) {
    userData, err := s.DataFetcher.FetchUserData(userID)
    if err != nil {
        return nil, err
    }
    processedData := processUserData(userData) // Now assumes processUserData is standalone.
    return processedData, nil
}

This refined approach champions the separation of data retrieval from processing, allowing for simplified mocking that aligns with the function’s signature rather than its underlying services. The Core Message

The essence of modern software design and testing lies in the division of code into two distinct phases: data gathering and data manipulation. By adhering to this principle, we ensure our code remains both clean and testable, free from the pitfalls of over-mocking and under-designing.

#Go   #Best Practices   #Mocking   #Testing