Цели модуля
После модуля студент сможет:
- Писать юнит-тесты с пакетом 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:
gopackage 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 | Создать временную директорию |
Подтесты
Подтесты позволяют группировать связанные проверки:
gofunc 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") } }) }
Запуск конкретного подтеста:
bashgo test -run TestRepo_Validate/valid_repo ./internal/models/
5.2 Table-driven тесты
Паттерн
Table-driven тесты определяют набор случаев в виде таблицы. Каждый случай - структура с входными данными и ожидаемым результатом.
gofunc 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 без библиотек:
gotype 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 может имитировать ошибки:
gotype 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 упрощает проверки:
goimport ( "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 который продолжает):
goimport ( "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 создаёт моки с проверкой вызовов:
goimport ( "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
gofunc 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
bashgo 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+.
Синтаксис
gofunc 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
gofunc 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() для ускорения
gofunc TestRepo_Validate(t *testing.T) { t.Parallel() // тест может выполняться параллельно // ... }
Используйте t.Cleanup для ресурсов
gofunc TestWithTempFile(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "test.txt") // t.TempDir() автоматически удалится после теста // ... }
Тестируйте ошибки
gofunc 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) }
Привязка к шаблону проекта
Где тесты в шаблоне
textinternal/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
Что изучаем на примере шаблона
- Юнит-тесты для моделей (models)
- Юнит-тесты для сервисов с моками (service)
- Интеграционные тесты для репозиториев (adapters/postgres)
- HTTP-тесты для хэндлеров (handlers)
- Использование testify/assert и testify/mock
Задание: изучите тесты в шаблоне
- Какие паттерны используются (table-driven, mock)?
- Как мокируются зависимости?
- Есть ли интеграционные тесты с БД?
- Какое покрытие кода?
💱 Компромиссы
assertvsrequire: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-тест для валидации имени репозитория:
gofunc 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 "Проверь себя"
go test -race?✅ Итоги модуля
Студент должен:
- Писать юнит-тесты с testing
- Использовать table-driven тесты
- Создавать моки с testify/mock
- Использовать assert и require
- Запускать тесты с race detector
- Писать fuzz-тесты
- Измерять покрытие кода
- Применять лучшие практики (AAA, независимость, поведение)
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 6
Следующий модуль: Модуль 6. Пакеты и организация проекта - дизайн пакетов, internal, clean folder structure, написание переиспользуемого кода. Привязка к структуре internal/ шаблона.