Цели модуля

После модуля студент сможет:

  • Писать юнит-тесты с пакетом testing
  • Использовать table-driven тесты для покрытия множества случаев
  • Создавать моки и фейковые реализации
  • Использовать testify для assertions и mocking
  • Запускать тесты с race detector
  • Писать fuzz-тесты для поиска edge cases
  • Измерять и интерпретировать покрытие кода
  • Применять лучшие практики тестирования

5.1 Юнит-тесты с testing

Основные правила

Тестовые файлы в Go:

  • Имя файла: имя_модуля_test.go
  • Пакет: тот же пакет или пакет_test
  • Функции: начинаются с Test
  • Сигнатура: func TestName(t *testing.T)

Пример: тест для модели Repo

Файл internal/models/repo_test.go:

go
package models

import (
    "testing"
)

func TestNewRepo(t *testing.T) {
    repo := NewRepo("my-project", "user-123")

    if repo.Name != "my-project" {
        t.Errorf("expected name 'my-project', got '%s'", repo.Name)
    }
    if repo.OwnerID != "user-123" {
        t.Errorf("expected owner 'user-123', got '%s'", repo.OwnerID)
    }
    if repo.ID == "" {
        t.Error("expected non-empty ID")
    }
    if repo.CreatedAt.IsZero() {
        t.Error("expected non-zero CreatedAt")
    }
}

Запуск тестов

bash
# Все тесты в проекте
go test ./...

# Тесты конкретного пакета
go test ./internal/models/

# Один тест по имени
go test -run TestNewRepo ./internal/models/

# Подробный вывод
go test -v ./internal/models/

# С race detector
go test -race ./...

Методы testing.T

МетодНазначение
t.ErrorЗаписать ошибку, продолжить тест
t.ErrorfЗаписать ошибку с форматированием, продолжить
t.FatalЗаписать ошибку, остановить тест
t.FatalfЗаписать ошибку с форматированием, остановить
t.LogЛогирование (видно с -v)
t.LogfЛогирование с форматированием
t.HelperПометить функцию как helper
t.ParallelЗапустить тест параллельно
t.CleanupЗарегистрировать cleanup-функцию
t.TempDirСоздать временную директорию

Подтесты

Подтесты позволяют группировать связанные проверки:

go
func TestRepo_Validate(t *testing.T) {
    t.Run("valid repo", func(t *testing.T) {
        repo := NewRepo("my-project", "user-123")
        if err := repo.Validate(); err != nil {
            t.Errorf("expected no error, got %v", err)
        }
    })

    t.Run("empty name", func(t *testing.T) {
        repo := NewRepo("", "user-123")
        if err := repo.Validate(); err == nil {
            t.Error("expected error for empty name")
        }
    })
}

Запуск конкретного подтеста:

bash
go test -run TestRepo_Validate/valid_repo ./internal/models/

5.2 Table-driven тесты

Паттерн

Table-driven тесты определяют набор случаев в виде таблицы. Каждый случай - структура с входными данными и ожидаемым результатом.

go
func TestRepo_Validate(t *testing.T) {
    tests := []struct {
        name    string
        repo    *Repo
        wantErr bool
    }{
        {
            name:    "valid repo",
            repo:    &Repo{Name: "my-project", OwnerID: "user-1"},
            wantErr: false,
        },
        {
            name:    "empty name",
            repo:    &Repo{Name: "", OwnerID: "user-1"},
            wantErr: true,
        },
        {
            name:    "name too short",
            repo:    &Repo{Name: "ab", OwnerID: "user-1"},
            wantErr: true,
        },
        {
            name:    "name too long",
            repo:    &Repo{Name: strings.Repeat("a", 101), OwnerID: "user-1"},
            wantErr: true,
        },
        {
            name:    "empty owner",
            repo:    &Repo{Name: "my-project", OwnerID: ""},
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.repo.Validate()
            if tt.wantErr && err == nil {
                t.Error("expected error, got nil")
            }
            if !tt.wantErr && err != nil {
                t.Errorf("expected no error, got %v", err)
            }
        })
    }
}

Преимущества table-driven тестов

  • Легко добавлять новые случаи
  • Видно все случаи одновременно
  • Меньше дублирования кода
  • Каждый случай запускается как подтест
  • Легко найти какой случай упал

Правила хорошей таблицы

  • Имя случая должно быть описательным
  • Один случай - одна проверка
  • Покрывайте: успех, ошибки, edge cases
  • Не делайте таблицу слишком большой (максимум 10-15 случаев)

5.3 Моки и фейковые реализации

Зачем нужны моки

Моки позволяют тестировать код без реальных зависимостей:

  • Без базы данных
  • Без сети
  • Без файловой системы
  • Без внешних сервисов

Интерфейсы - основа мокирования

В Go мокирование работает через интерфейсы. Если сервис зависит от интерфейса, мы можем подставить mock-реализацию.

go
// Интерфейс в internal/repository/
type RepoRepository interface {
    Create(ctx context.Context, repo *models.Repo) error
    GetByID(ctx context.Context, id string) (*models.Repo, error)
}

// Сервис зависит от интерфейса
type RepoService struct {
    repo repository.RepoRepository
}

Ручной mock

Простой mock без библиотек:

go
type MockRepoRepository struct {
    repos map[string]*models.Repo
}

func NewMockRepoRepository() *MockRepoRepository {
    return &MockRepoRepository{
        repos: make(map[string]*models.Repo),
    }
}

func (m *MockRepoRepository) Create(ctx context.Context, repo *models.Repo) error {
    m.repos[repo.ID] = repo
    return nil
}

func (m *MockRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) {
    repo, ok := m.repos[id]
    if !ok {
        return nil, nil
    }
    return repo, nil
}

Mock с поведением

Mock может имитировать ошибки:

go
type MockRepoRepository struct {
    CreateFunc func(ctx context.Context, repo *models.Repo) error
    GetByIDFunc func(ctx context.Context, id string) (*models.Repo, error)
}

func (m *MockRepoRepository) Create(ctx context.Context, repo *models.Repo) error {
    if m.CreateFunc != nil {
        return m.CreateFunc(ctx, repo)
    }
    return nil
}

func (m *MockRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) {
    if m.GetByIDFunc != nil {
        return m.GetByIDFunc(ctx, id)
    }
    return nil, nil
}

// Использование в тесте
mockRepo := &MockRepoRepository{
    GetByIDFunc: func(ctx context.Context, id string) (*models.Repo, error) {
        return nil, errors.New("connection refused")
    },
}

Fake vs Mock

ТипНазначениеПример
MockПроверяет взаимодействияtestify/mock
FakeУпрощённая рабочая реализацияIn-memory БД
StubВозвращает фиксированные данныеФиксированный ответ

Fake - упрощённая, но рабочая реализация. Например, in-memory хранилище вместо PostgreSQL.

Mock - проверяет что методы были вызваны с правильными аргументами.


5.4 Тестирование с testify

Пакет assert

testify/assert упрощает проверки:

go
import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestRepo_Validate(t *testing.T) {
    repo := NewRepo("my-project", "user-123")

    err := repo.Validate()
    assert.NoError(t, err)
    assert.Equal(t, "my-project", repo.Name)
    assert.NotEmpty(t, repo.ID)
    assert.True(t, repo.CreatedAt.After(time.Time{}))
}

Основные assertions

ФункцияНазначение
assert.EqualРавенство
assert.NotEqualНеравенство
assert.NilЗначение nil
assert.NotNilЗначение не nil
assert.TrueБулево true
assert.FalseБулево false
assert.NoErrorОшибка nil
assert.ErrorОшибка не nil
assert.ErrorIsОшибка совпадает с sentinel
assert.ContainsСодержит элемент
assert.LenДлина коллекции

Пакет require

testify/require останавливает тест при первой ошибке (в отличие от assert который продолжает):

go
import (
    "github.com/stretchr/testify/require"
)

func TestRepoService_GetRepo(t *testing.T) {
    svc := NewRepoService(mockRepo)

    repo, err := svc.GetRepo(context.Background(), "repo-123")
    require.NoError(t, err)  // останавливает тест если ошибка
    require.NotNil(t, repo)  // останавливает тест если nil

    assert.Equal(t, "repo-123", repo.ID)
}

Правило: require для предусловий, assert для проверок результата.

Пакет mock

testify/mock создаёт моки с проверкой вызовов:

go
import (
    "github.com/stretchr/testify/mock"
)

type MockRepoRepository struct {
    mock.Mock
}

func (m *MockRepoRepository) Create(ctx context.Context, repo *models.Repo) error {
    args := m.Called(ctx, repo)
    return args.Error(0)
}

func (m *MockRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*models.Repo), args.Error(1)
}

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

go
func TestRepoService_GetRepo(t *testing.T) {
    // Arrange
    mockRepo := new(MockRepoRepository)
    expectedRepo := &models.Repo{ID: "repo-123", Name: "test"}

    mockRepo.On("GetByID", mock.Anything, "repo-123").
        Return(expectedRepo, nil)

    svc := NewRepoService(mockRepo)

    // Act
    repo, err := svc.GetRepo(context.Background(), "repo-123")

    // Assert
    assert.NoError(t, err)
    assert.Equal(t, expectedRepo, repo)
    mockRepo.AssertExpectations(t)  // проверяет что все вызовы были
}

mock.Anything и конкретные аргументы

go
// Любой аргумент
mockRepo.On("GetByID", mock.Anything, "repo-123").Return(repo, nil)

// Конкретный аргумент
mockRepo.On("GetByID", mock.Anything, mock.MatchedBy(func(id string) bool {
    return strings.HasPrefix(id, "repo-")
})).Return(repo, nil)

Проверка вызовов

go
// Проверяет что метод был вызван
mockRepo.AssertCalled(t, "GetByID", mock.Anything, "repo-123")

// Проверяет что метод НЕ был вызван
mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)

// Проверяет количество вызовов
assert.Equal(t, 1, len(mockRepo.Calls))

5.5 Race detector

Что такое race condition

Race condition - состояние гонки, когда две горутины одновременно обращаются к общей памяти без синхронизации, и хотя бы одна запись.

Запуск с race detector

bash
go test -race ./...

Race detector находит гонки во время выполнения тестов. Он увеличивает потребление памяти в 5-10 раз и замедляет тесты, поэтому запускайте его в CI, а не при каждой локальной сборке.

Пример гонки

go
// ГОНКА: две горутины пишут в мапу без синхронизации
func TestRace(t *testing.T) {
    m := make(map[int]int)

    go func() {
        m[1] = 1  // запись
    }()

    m[1] = 2  // запись

    time.Sleep(time.Second)

}

// Race detector выдаст:
// WARNING: DATA RACE
// Write at 0x... by goroutine 1: ...
// Write at 0x... by main goroutine: ...

Как исправить

go
// Вариант 1: мьютекс
var mu sync.Mutex
m := make(map[int]int)

go func() {
    mu.Lock()
    defer mu.Unlock()
    m[1] = 1
}()

mu.Lock()
m[1] = 2
mu.Unlock()

// Вариант 2: каналы
ch := make(chan int)
go func() {
    ch <- 1
}()
<-ch

Правило

Всегда запускайте go test -race в CI. Если race detector находит гонку - это баг который нужно исправить до merge.


5.6 Fuzz-тестирование

Что такое fuzz-тестирование

Fuzz-тестирование автоматически генерирует случайные входные данные и проверяет что функция не падает и не нарушает инварианты.

Доступно с Go 1.18+.

Синтаксис

go
func FuzzParseRepoName(f *testing.F) {
    // Seed corpus - начальные значения
    f.Add("my-project")
    f.Add("test_repo")
    f.Add("")

    f.Fuzz(func(t *testing.T, name string) {
        // Тестируемая функция не должна падать
        repo, err := ParseRepo(name)
        if err != nil {
            return  // ошибка - нормальный результат
        }

        // Инвариант: если нет ошибки, имя не пустое
        if repo.Name == "" {
            t.Error("repo name should not be empty")
        }
    })

}

Запуск

bash
# Короткий fuzz (по умолчанию 5 секунд)
go test -fuzz=FuzzParseRepoName ./internal/models/

# Длительный fuzz
go test -fuzz=FuzzParseRepoName -fuzztime=60s ./internal/models/

Когда использовать fuzz

  • Парсинг входных данных
  • Валидация пользовательского ввода
  • Кодирование/декодирование
  • Математические функции
  • Обработка edge cases

5.7 Покрытие кода

Замер покрытия

bash
# Покрытие в процентах
go test -cover ./...

# Подробный профиль покрытия
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

# Покрытие конкретного пакета
go test -coverprofile=coverage.out ./internal/service/
go tool cover -func=coverage.out

Интерпретация

Покрытие показывает процент строк кода, выполненных при тестах.

ВАЖНО: высокое покрытие не гарантирует качество тестов. Можно покрыть код без assertions.

Ориентиры

ПокрытиеОценка
Ниже 50%Недостаточно
50-70%Минимум для production
70-80%Хорошо
80-90%Отлично
Выше 90%Избыточно (обычно)

Что покрывать обязательно

  • Бизнес-логику (service, usecase)
  • Обработку ошибок
  • Edge cases
  • Критичные пути

Что можно не покрывать

  • Простые getters/setters
  • Auto-generated код
  • main.go (entry point)
  • Простые DTO мапперы

5.8 Лучшие практики тестирования

Структура теста: Arrange-Act-Assert

go
func TestRepoService_CreateRepo(t *testing.T) {
    // Arrange - подготовка
    mockRepo := new(MockRepoRepository)
    mockRepo.On("Create", mock.Anything, mock.Anything).Return(nil)
    svc := NewRepoService(mockRepo)

    // Act - действие
    repo, err := svc.CreateRepo(context.Background(), req)

    // Assert - проверка
    assert.NoError(t, err)
    assert.NotNil(t, repo)
    mockRepo.AssertExpectations(t)
}

Один тест - одна идея

go
// ПЛОХО: один тест проверяет всё
func TestEverything(t *testing.T) { ... }

// ПРАВИЛЬНО: отдельные тесты для разных случаев
func TestRepoService_CreateRepo_Success(t *testing.T) { ... }
func TestRepoService_CreateRepo_ValidationError(t *testing.T) { ... }
func TestRepoService_CreateRepo_AlreadyExists(t *testing.T) { ... }

Независимость тестов

Тесты не должны зависеть друг от друга. Каждый тест:

  • Создаёт свои зависимости
  • Не полагается на порядок выполнения
  • Очищает ресурсы через t.Cleanup

Не тестируйте реализацию, тестируйте поведение

go
// ПЛОХО: проверяем что вызван конкретный метод
assert.Equal(t, 1, mockRepo.CallCount("Create"))

// ПРАВИЛЬНО: проверяем результат
assert.NoError(t, err)
assert.Equal(t, expectedRepo, repo)

Используйте t.Parallel() для ускорения

go
func TestRepo_Validate(t *testing.T) {
    t.Parallel()  // тест может выполняться параллельно
    // ...
}

Используйте t.Cleanup для ресурсов

go
func TestWithTempFile(t *testing.T) {
    tmpFile := filepath.Join(t.TempDir(), "test.txt")
    // t.TempDir() автоматически удалится после теста

    // ...

}

Тестируйте ошибки

go
func TestRepoService_GetRepo_NotFound(t *testing.T) {
    mockRepo := new(MockRepoRepository)
    mockRepo.On("GetByID", mock.Anything, "repo-123").
        Return(nil, nil)

    svc := NewRepoService(mockRepo)
    repo, err := svc.GetRepo(context.Background(), "repo-123")

    assert.Nil(t, repo)
    assert.ErrorIs(t, err, ErrRepoNotFound)
}

Привязка к шаблону проекта

Где тесты в шаблоне

text
internal/models/repo_test.go
internal/models/user_test.go
internal/service/repo_service_test.go
internal/service/user_service_test.go
internal/usecase/create_repo_usecase_test.go
internal/adapters/postgres/repo_repository_test.go
internal/handlers/http/api/v1/handler_test.go

Что изучаем на примере шаблона

  1. Юнит-тесты для моделей (models)
  2. Юнит-тесты для сервисов с моками (service)
  3. Интеграционные тесты для репозиториев (adapters/postgres)
  4. HTTP-тесты для хэндлеров (handlers)
  5. Использование testify/assert и testify/mock

Задание: изучите тесты в шаблоне

  • Какие паттерны используются (table-driven, mock)?
  • Как мокируются зависимости?
  • Есть ли интеграционные тесты с БД?
  • Какое покрытие кода?

💱 Компромиссы

  • assert vs require: assert продолжает тест и показывает все упавшие проверки разом, но после сломанного предусловия сыплются бессмысленные ошибки; require останавливает тест на месте — чище, но остальных проверок в этом прогоне не увидим.
  • Mock vs Fake: mock из testify/mock проверяет взаимодействия (AssertCalled) и точен, но хрупок к рефакторингу и толкает к тестам реализации; fake вроде InMemoryRepoRepository ведёт себя как настоящая зависимость, но его код растёт вместе с интерфейсом.
  • Table-driven vs отдельные тесты: таблица собирает все случаи в одном месте и расширяется одной строкой, но каждый кейс делит общую логику прогона; отдельные функции гибче в подготовке, но дублируют каркас и разбросаны по файлу.
  • go test -race в CI vs локально: detector ловит гонки до merge, поэтому живёт в CI, но раздувает память в 5–10 раз и замедляет тесты; без него быстрые локальные прогоны, но гонка в тестах не видна вовсе.
  • Покрытие 90%+ vs 70–80% на важном: гонка за цифрой тратит время на getters и main.go и всё равно не гарантирует проверок — код можно покрыть без единого assert; фокус на бизнес-логике и ошибках полезнее, но метрика в отчёте выглядит скромнее.

Практическое задание

Задание 1: Юнит-тесты для модели

Напишите тесты для модели Repo:

go
// internal/models/repo_test.go
func TestNewRepo(t *testing.T) { ... }
func TestRepo_Validate(t *testing.T) { ... }  // table-driven

Задание 2: Тесты для сервиса с моками

Напишите тесты для RepoService:

go
// internal/service/repo_service_test.go
func TestRepoService_GetRepo_Success(t *testing.T) { ... }
func TestRepoService_GetRepo_NotFound(t *testing.T) { ... }
func TestRepoService_CreateRepo_Success(t *testing.T) { ... }
func TestRepoService_CreateRepo_AlreadyExists(t *testing.T) { ... }

Задание 3: In-memory fake

Реализуйте in-memory fake для RepoRepository:

go
// internal/repository/memory/repo_repository.go
type InMemoryRepoRepository struct {
    mu    sync.RWMutex
    repos map[string]*models.Repo
}

Напишите тесты использующие fake вместо mock.

Задание 4: Fuzz-тест

Напишите fuzz-тест для валидации имени репозитория:

go
func FuzzRepoName(f *testing.F) {
    f.Add("my-project")
    f.Add("")
    f.Add(strings.Repeat("a", 200))
    f.Fuzz(func(t *testing.T, name string) {
        repo := &Repo{Name: name, OwnerID: "user-1"}
        // Validate не должен падать
        _ = repo.Validate()
    })
}

Задание 5: Покрытие

  • Запустите go test -cover ./...
  • Добейтесь покрытия 70% и выше для internal/service/
  • Сгенерируйте HTML-отчёт: go tool cover -html=coverage.out

Задание 6: Race detector

  • Запустите go test -race ./...
  • Убедитесь что нет race conditions
  • Если есть - исправьте с мьютексами или каналами

Quiz "Проверь себя"

1. Что проверяет go test -race?
2. Как fuzz-тестирование помогает найти баги?
3. Когда использовать require, а когда assert (testify)?
4. Что верно про race detector? — выберите все подходящие
5. Чем mock отличается от fake?
6. Что такое table-driven тесты и зачем они нужны?
7. Гарантирует ли покрытие 80% качество тестов?
8. Как запустить только один тест из пакета?

✅ Итоги модуля

Студент должен:

  • Писать юнит-тесты с testing
  • Использовать table-driven тесты
  • Создавать моки с testify/mock
  • Использовать assert и require
  • Запускать тесты с race detector
  • Писать fuzz-тесты
  • Измерять покрытие кода
  • Применять лучшие практики (AAA, независимость, поведение)
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 6

Следующий модуль: Модуль 6. Пакеты и организация проекта - дизайн пакетов, internal, clean folder structure, написание переиспользуемого кода. Привязка к структуре internal/ шаблона.