1. Что такое coverage и почему это не “процент счастья”
Coverage часто воспринимают как школьную оценку: «у меня 92% — я молодец», «у меня 37% — я плохой». На практике это вредная привычка, потому что coverage в Go отвечает на более узкий и честный вопрос: выполнялись ли операторы вашего кода во время выполнения тестов. Не выполнялись — значит тесты туда не ходили; выполнялись — значит ходили. Это не говорит, что проверки в тестах были умными, и уж точно не гарантирует отсутствие багов.
В Go, когда мы включаем coverage, go test инструментирует код и считает покрытие по statements (операторам). Поэтому ветвления (if, switch) почти всегда дают типичную картину: вы протестировали «зелёную дорожку», а «красная» (ошибки, edge cases, ранние return) осталась неисполненной — и coverage это честно подсветит. В этом смысле coverage — отличный способ напомнить: «Эй, а ты точно проверял ошибочные случаи, или только надеялся на доброту вселенной?»
Перед тем как выбирать режимы coverage, полезно понимать философию измерения. Coverage в Go по умолчанию считает statements, то есть операторы. Это не совсем то же самое, что «покрытие веток» (branch coverage) в некоторых других экосистемах. Ветвления влияют на coverage опосредованно: если у вас есть if, то операторы внутри одной ветки будут считаться покрытыми только если вы туда реально заходили.
Для новичка это отличная модель, потому что она совпадает с интуицией: «я писал код — тест должен туда зайти». Особенно хорошо coverage подсвечивает те места, которые чаще всего забывают тестировать: ранние возвраты, обработку ошибок, default в switch, проверки входных данных. Если вы пишете Go в его обычном стиле (где много if err != nil { return ... }), coverage очень быстро показывает, какие return по ошибке вы ни разу не проходили тестами.
2. go test -cover: быстрый “термометр” покрытия
Когда вы добавляете к запуску тестов флаг -cover, go test начинает считать, сколько операторов (statements) было выполнено во время тестов, и печатает процент в консоль. Это самый быстрый способ получить первичный сигнал: тесты вообще куда‑то ходят или только компилируются и делают вид, что заняты. Такой запуск особенно полезен, когда вы только что добавили новую функцию и хотите понять, не забыли ли написать тесты совсем.
Покажу на маленьком кусочке нашего учебного приложения (пусть это будет пакет todo, где мы парсим статус задачи из строки). Код нарочно сделаем с ветвлением: это удобно для демонстрации coverage.
package todo
import (
"fmt"
"strings"
)
type Status int
const (
StatusTodo Status = iota
StatusDone
)
func ParseStatus(s string) (Status, error) {
switch strings.TrimSpace(strings.ToLower(s)) {
case "todo":
return StatusTodo, nil
case "done":
return StatusDone, nil
default:
return 0, fmt.Errorf("unknown status: %q", s)
}
}
Теперь тест, который специально покрывает не всё (мы начнём с частичного покрытия, чтобы увидеть смысл процентов):
package todo
import "testing"
func TestParseStatus_Todo(t *testing.T) {
st, err := ParseStatus("todo")
if err != nil || st != StatusTodo {
t.Fatalf("expected todo, got %v, err=%v", st, err)
}
}
Запуск:
go test -cover
В выводе вы увидите строку в стиле «coverage: N% of statements». Это именно “N% операторов выполнились” — не “N% правильности”. Такой процент — хороший термометр: показывает температуру, но не ставит диагноз.
3. -coverprofile: “процент в консоли” vs “данные в файл”
Процент в консоли — это приятно, но он почти бесполезен, если вы хотите понять, что именно не покрыто. Для этого Go умеет сохранять подробные данные покрытия в файл — профиль покрытия (coverage profile). Делается это флагом -coverprofile.
Ключевой момент: если вы используете -coverprofile=..., то go test автоматически включает coverage‑анализ. То есть вам не нужно писать и -cover, и -coverprofile вместе — достаточно -coverprofile.
Пример команды:
go test -coverprofile=coverage.out
В результате вы получаете два полезных эффекта одновременно: в консоль печатается процент, а в файл coverage.out записывается профиль покрытия, который потом можно анализировать инструментами (в том числе визуально). Детальный разбор чтения профиля — отдельная тема, но важно уже сейчас привыкнуть к дисциплине: “процент посмотрел → профиль сохранил”.
И ещё одна практическая деталь из реальной жизни. Когда вы начинаете чинить покрытие, вы часто делаете такой цикл: дописал тест → прогнал тесты → снова прогнал. Если вы видите, что прогон “мгновенный”, и у вас есть подозрение, что сработал кеш go test, то вы уже знаете лекарство: -count=1. Coverage‑профиль, конечно, тоже можно пересобирать принудительно этим способом, если сомневаетесь, что изменения реально исполнились.
4. -covermode: set, count, atomic
Флаг -covermode отвечает за то, как именно считать покрытие: просто факт выполнения, количество выполнений или количество выполнений с корректным подсчётом в параллельных сценариях. В Go есть три режима: set, count, atomic. Если говорить совсем коротко: set отвечает на вопрос “выполнялся ли оператор”, count — “сколько раз выполнялся”, atomic — “как count, но с точным подсчётом в параллельных программах”.
Вот компактная табличка (её реально полезно держать в голове):
| -covermode | Что хранит | Когда выбирать | Цена/нагрузка |
|---|---|---|---|
| set (по умолчанию) | Выполнялся/не выполнялся | Почти всегда по умолчанию, особенно для обучения и обычных unit‑тестов | Дешево |
| count | Сколько раз выполнялся оператор | Когда хотите “тепловую карту”: какие места бегают часто, какие редко | Дешево (обычно достаточно) |
| atomic | Сколько раз, но корректно при параллельном выполнении | Когда важны точные счётчики при конкурентном/параллельном выполнении | Дороже: использует атомарные операции, может быть заметно “тяжелее” |
Режим set: стандартный и “не раздражает”
set — режим по умолчанию. Он отвечает на вопрос: “этот оператор выполнялся хотя бы раз?”. Для большинства учебных и прикладных ситуаций этого хватает за глаза. Вы, как разработчик, чаще всего хотите именно это: быстро увидеть “красные” места, то есть те куски, куда тесты вообще не заходили.
Включить set явно можно так (хотя это и не обязательно, потому что он и так дефолтный):
go test -covermode=set -coverprofile=coverage.out
Это удобно, когда вы пишете скрипты/команды одинакового вида и хотите всегда явно указывать режим.
Режим count: когда вы хотите понимать “как часто”
count вместо “да/нет” хранит, сколько раз выполнялся оператор. Это уже не только про “покрыто/не покрыто”, но и про “как распределяется активность”. Важно не путать это с профилированием производительности: coverage не измеряет время. Но как “тепловая карта” частоты выполнения count бывает полезен: например, вы видите, что какая-то часть кода исполняется тысячу раз в одном тесте, и понимаете, что это очень горячее место логики.
Команда:
go test -covermode=count -coverprofile=coverage.out
И важная деталь: процент покрытия при set и count не меняется, потому что “покрыт ли оператор” — это отдельная метрика от “сколько раз покрыт”.
Режим atomic: точные счётчики при параллельности
atomic — это тот же count, но с корректным подсчётом, когда один и тот же код может выполняться одновременно. Он использует атомарные операции (sync/atomic) и поэтому может быть ощутимо дороже.
На текущем этапе вам достаточно запомнить “зачем он существует” и “почему не надо включать его просто потому что звучит круто”. Это как надеть каску, бронежилет и взять огнетушитель, чтобы сходить за хлебом: формально безопаснее, но продавец начнёт задавать вопросы.
Команда выглядит так:
go test -covermode=atomic -coverprofile=coverage.out
5. Мини‑сценарий: как coverage помогает найти забытый тест
Теперь сделаем то, что обычно происходит в реальном проекте: добавим ещё один тест и посмотрим, как меняется покрытие смыслово.
У нас была функция ParseStatus с тремя логическими путями: "todo", "done" и ошибка (default). Сейчас тест покрывает только "todo". Давайте добавим тест на "done" (и уже заметим, что ошибки всё ещё не покрыты).
package todo
import "testing"
func TestParseStatus_Done(t *testing.T) {
st, err := ParseStatus("done")
if err != nil || st != StatusDone {
t.Fatalf("expected done, got %v, err=%v", st, err)
}
}
Запускаем:
go test -cover
Процент вырастет. Но что важно: даже если процент стал “красивее”, вы всё ещё не проверили путь с ошибкой, а именно он чаще всего ломается в проде первым. Поэтому добавим тест на неправильный ввод, чтобы покрыть default:
package todo
import "testing"
func TestParseStatus_Unknown(t *testing.T) {
_, err := ParseStatus("i am not a status")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
Теперь вы увидите, что coverage приближается к 100% по этой функции. И это тот редкий случай, когда высокий coverage действительно совпадает со здравым смыслом: функция маленькая, путей мало, тесты проверили все пути.
При этом важно держать в голове взрослую мысль: даже если coverage 100% на функции, тест всё ещё может быть слабым. Например, можно написать тест, который вызывает функцию, но ничего не проверяет. Coverage будет счастлив, а баги — тоже.
6. Рабочие команды, которые удобно держать под рукой
Покрытие удобно использовать как повторяемый ритуал, а не как “раз в год перед релизом”. Поэтому полезно иметь пару стандартных команд.
Вот базовый набор, который чаще всего закрывает 90% потребностей:
go test -cover
go test -coverprofile=coverage.out
go test -covermode=count -coverprofile=coverage.out
Первая команда даёт быстрый сигнал “вообще что-то покрывается?”. Вторая сохраняет профиль. Третья сохраняет профиль с подсчётом “сколько раз”, если вам нужно не только “красное/зелёное”, но и “насколько зелёное”.
7. Типичные ошибки при работе с coverage
Ошибка №1: воспринимать coverage как доказательство корректности тестов.
Самая популярная ловушка — думать, что “80% coverage” означает “программа на 80% правильная”. Coverage измеряет факт исполнения операторов, а не качество утверждений в тестах. Можно добиться высокого coverage тестами, которые вообще ничего не проверяют, и получить идеально “покрытый” мусор.
Ошибка №2: забывать про -coverprofile и жить только с процентом.
Процент в консоли — это полезный сигнал, но он не отвечает на главный вопрос: что именно не покрыто. Без -coverprofile вы быстро упираетесь в ситуацию “процент низкий, но где именно — непонятно”. Профиль — это способ превратить покрытие из абстрактного числа в диагностические данные.
Ошибка №3: думать, что -coverprofile нужно использовать вместе с -cover, иначе coverage не включится.
Это мелочь, но она постоянно всплывает у новичков. На самом деле -coverprofile сам включает coverage‑анализ, то есть -cover становится избыточным.
Ошибка №4: включать -covermode=atomic “на всякий случай”.
atomic нужен для точного подсчёта в параллельных сценариях и может быть дорогим. Если вы пишете обычные unit‑тесты без параллельного выполнения, count почти всегда достаточно, а set — тем более.
Ошибка №5: пытаться “добить процент” вместо того, чтобы покрывать смысловые ветки.
Иногда люди начинают писать тесты ради чисел, особенно когда в команде есть KPI “не меньше 85%”. Это приводит к странным тестам, которые вызывают код без проверки результата, лишь бы строчки стали зелёными. Гораздо полезнее идти от логики: покрывайте ветки с ошибками, edge cases и инварианты функций, а процент пусть будет следствием, а не целью.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ