Покрытие кода тестами в Go: от «зелёной галочки» к реальному качеству
В мире Go-разработки фраза «у меня 95% покрытия» звучит гордо. Но часто за этой цифрой скрывается пустота: тесты проверяют только счастливый путь, а граничные случаи и ошибки остаются за бортом. В этой статье мы разберёмся, как измерять покрытие кода тестами в Go по-настоящему, какие метрики действительно важны, и какие инструменты помогут превратить тестирование из формальности в мощный инструмент контроля качества.
Мы не будем пересказывать документацию. Вместо этого — живые примеры, ловушки и практические советы для тех, кто пишет на Go и хочет спать спокойно.
Почему просто 80% — это плохо?
Допустим, у вас есть функция:
func ProcessOrder(amount float64, discount bool) (float64, error) {
if amount <= 0 {
return 0, errors.New("amount must be positive")
}
if discount {
amount *= 0.9
}
return amount, nil
}
Вы написали один тест: ProcessOrder(100, true) → 90, nil. Покрытие? 100% строк. Но вы не проверили случай с amount <= 0 и случай без скидки. Формально — покрытие отличное. Реально — баги гарантированы. Вот почему метрики покрытия кода тестами в Go нужно рассматривать в связке с другими показателями.
Инструменты покрытия: от простого к сложному
1. Встроенный go test -cover
Самый простой способ узнать покрытие кода тестами в Go — флаг -cover:
go test -cover ./...
Вывод:
ok myproject/orders 0.234s coverage: 72.3% of statements
Но это лишь общая цифра. Чтобы увидеть, какие строки не покрыты, используйте -coverprofile:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
Откроется браузер с подсветкой: зелёное — покрыто, красное — нет. Это первый шаг к пониманию метрик покрытия Go.
2. go tool cover: режимы и нюансы
go tool cover поддерживает три режима:
- set — была ли строка выполнена хоть раз (по умолчанию)
- count — сколько раз выполнилась каждая строка
- atomic — count, но с атомарными счётчиками для параллельных тестов
Для анализа покрытия тестами Golang в многопоточном коде используйте atomic, иначе рискуете получить некорректные счётчики из-за race condition.
go test -covermode=atomic -coverprofile=coverage.out ./...
3. Race detector — скрытое покрытие
Гонки данных — одна из главных проблем Go-кода. Даже если покрытие строк 100%, race detector может найти ошибки, которые тесты не заметили. Запускайте тесты с флагом -race:
go test -race -cover ./...
Важно: race detector замедляет тесты в 2-20 раз, но для критичных модулей это обязательная практика. Включайте его в CI для ключевых пакетов.
Метрики, которые действительно имеют значение
Процент покрытия строк — лишь вершина айсберга. Вот что нужно отслеживать:
1. Покрытие ветвлений (branch coverage)
Стандартный go test -cover считает строки, а не ветки. Для покрытия кода тестами Go на уровне ветвлений используйте gocov или go-test-coverage. Пример:
go get github.com/axw/gocov/gocov
gocov test ./... | gocov report
Вы увидите не только строки, но и количество непокрытых условий. Например, для функции с двумя if-ами — 4 ветки, из которых покрыты 2.
2. Покрытие мутациями (mutation testing)
Самый честный способ оценить качество тестов — мутационное тестирование Go. Инструмент (например, go-mutesting) вносит мелкие изменения в код (меняет > на <, убирает return) и проверяет, «убивают» ли ваши тесты эти мутации.
go get github.com/zimmski/go-mutesting/...
go-mutesting ./...
Если мутация выжила — тест не проверяет этот сценарий. Цель: мутационный балл > 80% для критических модулей.
3. Покрытие по пакетам и функциям
Не гонитесь за общим процентом. Лучше установите пороги для каждого пакета. Например:
- Утилитарные функции: ≥ 90%
- Бизнес-логика: ≥ 80%
- HTTP-обработчики: ≥ 70% (из-за сложности тестирования)
Это даст реальные метрики покрытия Go, а не «среднюю температуру по больнице».
Практический пример: от 60% к 95% с умом
Рассмотрим реальный код. Допустим, у нас есть сервис расчёта доставки:
type DeliveryService struct {
db Database
geo GeoProvider
logger Logger
}
func (s *DeliveryService) CalculateCost(orderID string) (float64, error) {
order, err := s.db.GetOrder(orderID)
if err != nil {
s.logger.Error("failed to get order", "id", orderID, "err", err)
return 0, fmt.Errorf("get order: %w", err)
}
distance, err := s.geo.Distance(order.From, order.To)
if err != nil {
s.logger.Error("failed to calc distance", "err", err)
return 0, fmt.Errorf("distance: %w", err)
}
// Базовый тариф
cost := 10.0 + distance*0.5
// Скидка для постоянных клиентов
if order.IsLoyal {
cost *= 0.9
}
// Ночной тариф
if time.Now().Hour() >= 22 || time.Now().Hour() < 6 {
cost *= 1.5
}
return cost, nil
}
Начальное покрытие — 60% (проверен только счастливый путь). Чтобы поднять до 95%, нужно:
- Тест на ошибку БД — mock для
s.db.GetOrderвозвращает ошибку. - Тест на ошибку гео