Блог инженера

History is written by its contributors

Test Doubles: mocks, stubs и dependency injection в Go

2025-05-09 время чтения 6 мин Golang Testing Quality-Assurance Ilya Brin

Привет, тестировщик! 👋

Твои тесты медленные, хрупкие и зависят от внешних сервисов? Каждый раз когда база данных недоступна, половина тестов падает?

Test Doubles - это твоё спасение. Вместо реальных зависимостей используй подделки: моки, стабы, фейки.

Разбираем, как правильно изолировать код для тестирования и писать быстрые, надёжные unit-тесты в Go 🚀

1. Что такое Test Doubles и зачем они нужны

Проблема реальных зависимостей

// Плохо: тест зависит от реальной базы данных
func TestUserService_CreateUser(t *testing.T) {
    db := connectToRealDatabase() // Медленно!
    service := NewUserService(db)
    
    user, err := service.CreateUser("john@example.com")
    assert.NoError(t, err)
    assert.Equal(t, "john@example.com", user.Email)
}

Проблемы:

  • 🐌 Медленно - подключение к базе занимает время
  • 💥 Хрупко - тест падает если база недоступна
  • 🔄 Побочные эффекты - тест изменяет состояние базы
  • 🚫 Сложно тестировать ошибки - как симулировать сбой базы?

Test Doubles: типы подделок

Dummy - объект-заглушка, не используется в тесте Stub - возвращает заранее определённые значения Mock - проверяет, что методы вызывались правильно Fake - упрощённая рабочая реализация Spy - записывает информацию о вызовах

2. Dependency Injection в Go

Проблема жёстких зависимостей

// Плохо: жёсткая зависимость
type UserService struct {}

func (s *UserService) CreateUser(email string) (*User, error) {
    db := postgres.Connect() // Нельзя заменить в тестах!
    return db.CreateUser(email)
}

Решение: внедрение зависимостей

// Хорошо: зависимость через интерфейс
type UserRepository interface {
    CreateUser(email string) (*User, error)
    GetUser(id string) (*User, error)
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) CreateUser(email string) (*User, error) {
    return s.repo.CreateUser(email)
}

3. Stubs: простые заглушки

Ручные стubs

// Stub реализация
type StubUserRepository struct {
    users map[string]*User
    err   error
}

func (s *StubUserRepository) CreateUser(email string) (*User, error) {
    if s.err != nil {
        return nil, s.err
    }
    
    user := &User{ID: "123", Email: email}
    s.users[user.ID] = user
    return user, nil
}

func (s *StubUserRepository) GetUser(id string) (*User, error) {
    if s.err != nil {
        return nil, s.err
    }
    return s.users[id], nil
}

Тест со stub

func TestUserService_CreateUser_Success(t *testing.T) {
    // Arrange
    stub := &StubUserRepository{
        users: make(map[string]*User),
    }
    service := NewUserService(stub)
    
    // Act
    user, err := service.CreateUser("john@example.com")
    
    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "john@example.com", user.Email)
}

func TestUserService_CreateUser_Error(t *testing.T) {
    // Arrange
    stub := &StubUserRepository{
        err: errors.New("database error"),
    }
    service := NewUserService(stub)
    
    // Act
    user, err := service.CreateUser("john@example.com")
    
    // Assert
    assert.Error(t, err)
    assert.Nil(t, user)
}

4. Mocks: проверка взаимодействий

Ручные mocks

type MockUserRepository struct {
    createUserCalled bool
    createUserEmail  string
    createUserResult *User
    createUserError  error
}

func (m *MockUserRepository) CreateUser(email string) (*User, error) {
    m.createUserCalled = true
    m.createUserEmail = email
    return m.createUserResult, m.createUserError
}

func (m *MockUserRepository) GetUser(id string) (*User, error) {
    return nil, nil
}

func (m *MockUserRepository) AssertCreateUserCalled(t *testing.T, email string) {
    assert.True(t, m.createUserCalled, "CreateUser should be called")
    assert.Equal(t, email, m.createUserEmail)
}

Тест с mock

func TestUserService_CreateUser_CallsRepository(t *testing.T) {
    // Arrange
    mock := &MockUserRepository{
        createUserResult: &User{ID: "123", Email: "john@example.com"},
    }
    service := NewUserService(mock)
    
    // Act
    service.CreateUser("john@example.com")
    
    // Assert
    mock.AssertCreateUserCalled(t, "john@example.com")
}

5. Testify/mock: автоматические mocks

Установка и генерация

go install github.com/vektra/mockery/v2@latest
mockery --name=UserRepository

Сгенерированный mock

//go:generate mockery --name=UserRepository
type UserRepository interface {
    CreateUser(email string) (*User, error)
    GetUser(id string) (*User, error)
}

Использование testify mocks

func TestUserService_CreateUser_WithTestify(t *testing.T) {
    // Arrange
    mockRepo := mocks.NewUserRepository(t)
    mockRepo.On("CreateUser", "john@example.com").
        Return(&User{ID: "123", Email: "john@example.com"}, nil)
    
    service := NewUserService(mockRepo)
    
    // Act
    user, err := service.CreateUser("john@example.com")
    
    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "john@example.com", user.Email)
    mockRepo.AssertExpectations(t)
}

6. Продвинутые паттерны

Builder для test data

type UserBuilder struct {
    user *User
}

func NewUserBuilder() *UserBuilder {
    return &UserBuilder{
        user: &User{
            ID:    "default-id",
            Email: "default@example.com",
        },
    }
}

func (b *UserBuilder) WithID(id string) *UserBuilder {
    b.user.ID = id
    return b
}

func (b *UserBuilder) WithEmail(email string) *UserBuilder {
    b.user.Email = email
    return b
}

func (b *UserBuilder) Build() *User {
    return b.user
}

// Использование
func TestUserService_UpdateUser(t *testing.T) {
    user := NewUserBuilder().
        WithID("123").
        WithEmail("john@example.com").
        Build()
    
    // тест...
}

Spy pattern

type SpyUserRepository struct {
    calls []string
    StubUserRepository
}

func (s *SpyUserRepository) CreateUser(email string) (*User, error) {
    s.calls = append(s.calls, fmt.Sprintf("CreateUser(%s)", email))
    return s.StubUserRepository.CreateUser(email)
}

func (s *SpyUserRepository) GetCallHistory() []string {
    return s.calls
}

func TestUserService_CallOrder(t *testing.T) {
    spy := &SpyUserRepository{}
    service := NewUserService(spy)
    
    service.CreateUser("john@example.com")
    service.GetUser("123")
    
    expected := []string{
        "CreateUser(john@example.com)",
        "GetUser(123)",
    }
    assert.Equal(t, expected, spy.GetCallHistory())
}

7. HTTP клиенты и внешние API

Тестирование HTTP клиентов

type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
}

type APIClient struct {
    client HTTPClient
    baseURL string
}

func (c *APIClient) GetUser(id string) (*User, error) {
    req, _ := http.NewRequest("GET", c.baseURL+"/users/"+id, nil)
    resp, err := c.client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var user User
    json.NewDecoder(resp.Body).Decode(&user)
    return &user, nil
}

Mock HTTP клиента

type MockHTTPClient struct {
    response *http.Response
    err      error
}

func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
    return m.response, m.err
}

func TestAPIClient_GetUser(t *testing.T) {
    // Arrange
    responseBody := `{"id":"123","email":"john@example.com"}`
    mockClient := &MockHTTPClient{
        response: &http.Response{
            StatusCode: 200,
            Body:       io.NopCloser(strings.NewReader(responseBody)),
        },
    }
    
    client := &APIClient{
        client:  mockClient,
        baseURL: "https://api.example.com",
    }
    
    // Act
    user, err := client.GetUser("123")
    
    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "123", user.ID)
}

8. Интеграционные тесты с testcontainers

Реальная база для интеграционных тестов

func TestUserRepository_Integration(t *testing.T) {
    // Запускаем PostgreSQL в Docker
    ctx := context.Background()
    postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "postgres:13",
            ExposedPorts: []string{"5432/tcp"},
            Env: map[string]string{
                "POSTGRES_PASSWORD": "password",
                "POSTGRES_DB":       "testdb",
            },
        },
        Started: true,
    })
    require.NoError(t, err)
    defer postgres.Terminate(ctx)
    
    // Получаем порт и подключаемся
    port, _ := postgres.MappedPort(ctx, "5432")
    dsn := fmt.Sprintf("postgres://postgres:password@localhost:%s/testdb?sslmode=disable", port.Port())
    
    db, err := sql.Open("postgres", dsn)
    require.NoError(t, err)
    
    // Тестируем реальную реализацию
    repo := NewPostgresUserRepository(db)
    user, err := repo.CreateUser("john@example.com")
    
    assert.NoError(t, err)
    assert.NotEmpty(t, user.ID)
}

9. Лучшие практики

Правило пирамиды тестов

        /\
       /  \
      / UI \     <- Мало (E2E тесты)
     /______\
    /        \
   /Integration\ <- Средне (с реальными компонентами)
  /__________\
 /            \
/  Unit Tests  \  <- Много (с test doubles)
/______________\

Когда использовать каждый тип

Unit тесты (70%): Test doubles для всех зависимостей Integration тесты (20%): Реальные компоненты (база, очереди) E2E тесты (10%): Полная система

Принципы хороших тестов

// FIRST принципы:
// Fast - быстрые
// Independent - независимые
// Repeatable - повторяемые
// Self-validating - самопроверяющиеся
// Timely - своевременные

func TestUserService_CreateUser_FIRST(t *testing.T) {
    // Fast: используем mock вместо реальной базы
    mock := &MockUserRepository{}
    
    // Independent: не зависит от других тестов
    service := NewUserService(mock)
    
    // Repeatable: всегда один результат
    mock.createUserResult = &User{ID: "123"}
    
    // Self-validating: чёткие assertions
    user, err := service.CreateUser("test@example.com")
    assert.NoError(t, err)
    assert.Equal(t, "123", user.ID)
    
    // Timely: написан вместе с кодом
}

Вывод: Test Doubles = быстрые и надёжные тесты

Правильное использование test doubles: 🚀 Ускоряет тесты - никаких внешних зависимостей
🛡️ Повышает надёжность - тесты не падают из-за сети
🎯 Улучшает фокус - тестируем только свой код
🔧 Упрощает отладку - контролируемое поведение

Золотые правила:

  • Используй интерфейсы для всех зависимостей
  • Unit тесты = test doubles, интеграционные = реальные компоненты
  • Не мокай то, чем не владеешь (внешние библиотеки)
  • Тестируй поведение, а не реализацию

P.S. Как вы тестируете сложные зависимости? Делитесь опытом! 🚀

// Дополнительные ресурсы:
// - "Test Doubles" by Martin Fowler
// - testify/mock: https://github.com/stretchr/testify
// - mockery: https://github.com/vektra/mockery
// - testcontainers-go: https://github.com/testcontainers/testcontainers-go
comments powered by Disqus