Модуль: Тестирование · Уровень: Middle+/Senior
TL;DR#
go test -cover показывает процент покрытых строк (точнее — операторов/блоков), -coverprofile=cover.out пишет профиль для go tool cover -html/-func. Покрытие говорит «этот код исполнялся в тестах», но НЕ «он протестирован»: высокий процент при отсутствии ассертов, непокрытые ветки ошибок, неучтённые конкурентные пути — всё это даёт ложную уверенность. go test -race включает детектор гонок (ThreadSanitizer): инструментирует доступы к памяти и ловит data race в рантайме на тех путях, что реально исполнились — обязателен в CI для конкурентного кода. Флаки-тесты (недетерминированно падающие) — следствие гонок, зависимости от времени/порядка/сети, общего состояния; их находят повторными прогонами (-count, -race, -shuffle) и устраняют, а не ретраят вслепую.
Теория#
Измерение покрытия#
go test -cover ./... # процент в выводе
go test -coverprofile=cover.out ./... # профиль
go tool cover -func=cover.out # построчно по функциям + total
go tool cover -html=cover.out # визуализация в браузере
go test -covermode=atomic -coverprofile=c.out # потокобезопасный режим (нужен с -race)covermode:
set(по умолчанию без -race): покрыт/не покрыт блок (булево).count: сколько раз исполнен блок.atomic: как count, но потокобезопасно — обязателен вместе с-raceи для конкурентного кода, иначе счётчики сами станут гонкой.
Что считается: Go меряет покрытие на уровне базовых блоков (операторов), не строк буквально и не веток. a && b — частичное покрытие условий не отражается напрямую; обе ветки if нужно вызвать отдельно.
Покрытие пакета чужими тестами: -coverpkg=./... учитывает, что тесты пакета A покрывают код пакета B (полезно для интеграционных тестов, меряющих покрытие всей системы).
Что покрытие НЕ значит#
- Исполнено ≠ проверено. Тест без ассертов даёт 100% покрытия и ничего не гарантирует:
func TestParse(t *testing.T) {
Parse("input") // вызвали, не проверили результат — 100% покрытия, 0% ценности
}- Покрытие строк ≠ покрытие веток/условий.
if a || bможет быть «покрыт» при одной комбинации, а баг — в другой. - Не покрыты пути ошибок. Часто 80% — это happy path; именно error handling (редкие ветки) ломается в проде и остаётся непокрытым.
- Не отражает конкурентность. Покрытие не видит interleavings горутин — гонка может быть в «покрытом» коде.
- Не ловит отсутствующую логику. Покрытие меряет существующий код; пропущенную проверку (которой нет) оно не покажет — для этого нужны mutation testing и продуманные кейсы.
- Цель-метрика деградирует (закон Гудхарта). Жёсткий порог «90%» провоцирует тесты-пустышки ради цифры. Покрытие — диагностический сигнал (что вообще не тронуто), а не цель.
Разумное применение: смотреть на непокрытые участки (особенно error-ветки и новый код в diff), а не молиться на общий процент.
Детектор гонок (-race)#
go test -race ./...
go run -race ./cmd/app
go build -race -o app ./cmd/app- Основан на ThreadSanitizer: инструментирует обращения к памяти и синхронизацию, во время выполнения отслеживает happens-before и сообщает о data race — конкурентном доступе к одной памяти, где хотя бы один доступ на запись, без синхронизации.
- Ловит только реально исполнившиеся пути. Гонка в ветке, которую тест не вызвал, не будет найдена. Поэтому -race + хорошее покрытие конкурентных путей + повторные прогоны.
- Накладные расходы: ~5-10x по CPU и ~5-10x по памяти. Не для прода; для CI и стресс-тестов.
- Находит data races, не logic races. Корректная синхронизация (мьютекс) уберёт предупреждение детектора, но логическая гонка (неверный порядок операций под локом, TOCTOU) останется — её детектор не видит.
Типичная находка:
func TestCounter(t *testing.T) {
var c int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); c++ }() // DATA RACE: c++ без синхронизации
}
wg.Wait()
// -race немедленно сообщит о гонке на c
}-race в CI#
- Стандартная практика: отдельный прогон
go test -race ./...в CI (часто с-count=1чтобы обойти кэш и с-shuffle=on). - Из-за overhead иногда выносят в отдельный job или ночной прогон, но для конкурентного кода — обязателен.
- Помните: зелёный -race не доказывает отсутствие гонок — лишь что на исполненных путях их не нашли. Это вероятностный/динамический инструмент.
Флаки-тесты#
Тест, который недетерминированно то проходит, то падает без изменений кода. Причины:
- Data race — наиболее частая; лечится -race + фикс синхронизации.
- Зависимость от времени:
time.Sleepдля ожидания, таймауты, завязка наtime.Now(). Лечитсяassert.Eventually/polling, инъекцией Clock, отказом от sleep. - Зависимость от порядка тестов: общее глобальное состояние, незачищенные ресурсы. Лечится изоляцией,
t.Cleanup,-shuffle=onдля выявления. - Внешние зависимости: сеть, реальные сервисы, файловая система, рандомные порты. Лечится testcontainers с wait-стратегиями, моками сети.
- Параллелизм с общим состоянием:
t.Parallelна общей мапе/БД. Лечится изоляцией данных. - Недетерминированный порядок мап в ассертах. Лечится сортировкой или
ElementsMatch.
Инструменты выявления:
go test -race -count=100 ./pkg/... # повторные прогоны выявляют редкие падения
go test -shuffle=on ./... # рандомизирует порядок тестов и пакетов
go test -run TestFlaky -count=1000 -race -timeout=5mАнти-паттерн: автоматический retry флаки-тестов как «решение». Retry маскирует реальный баг (часто гонку, которая выстрелит и в проде) и эродирует доверие к suite. Флаки нужно диагностировать (systematic debugging, -race, -shuffle, повтор) и устранять причину. Допустимо карантинить (пометить, исключить из gate) с заведённым тикетом — но не вечно ретраить.
Подводные камни / gotchas#
- Высокое покрытие без ассертов — иллюзия. Тест, вызвавший код, но не проверивший результат, бесполезен. Покрытие этого не показывает.
- Покрытие строк ≠ ветвей/условий.
||/&&, ранниеreturn, error-ветки требуют отдельных кейсов. - Порог покрытия как gate провоцирует тесты-пустышки (Гудхарт). Лучше смотреть покрытие diff/новых строк и непокрытые error-пути.
-raceбез-covermode=atomicпри сборе покрытия даёт гонку в самих счётчиках покрытия. С -race используйте atomic.- Зелёный -race ≠ нет гонок. Динамический детектор видит только исполненные interleavings; редкая гонка может не проявиться в прогоне.
- -race overhead (5-10x CPU/RAM) — нельзя в прод и осторожно с таймаутами в CI (тесты идут медленнее, увеличивайте
-timeout). - Кэш тестов маскирует флаки:
go testкэширует успешные результаты.-count=1отключает кэш, чтобы реально перезапустить. - Retry флаки вместо фикса прячет реальные баги (часто продовые гонки) и разрушает доверие к CI.
t.Parallel+ общее состояние — источник и гонок, и флаки. Изолируйте.- mutation testing не входит в стандартный тулчейн — высокое покрытие не значит, что тесты ловят регрессии; для этого нужны отдельные инструменты/дисциплина.
Вопросы на собеседовании#
В: Что именно меряет go test -cover и чего оно НЕ значит?
О: Меряет долю исполненных базовых блоков (операторов) кода во время тестов. Не значит, что код протестирован: тест без ассертов даёт высокое покрытие при нулевой ценности; не отражает покрытие веток/условий, путей ошибок, конкурентных interleavings и отсутствующей логики. Это сигнал «что не тронуто», а не гарантия корректности.
В: Зачем -covermode=atomic и когда он обязателен?
О: Это потокобезопасный режим счётчиков покрытия. Обязателен вместе с -race и для конкурентного кода: иначе инкремент счётчиков покрытия из нескольких горутин сам станет data race и исказит и покрытие, и вывод детектора.
В: Как работает -race и каковы его ограничения?
О: Это ThreadSanitizer: инструментирует доступы к памяти и синхронизацию, в рантайме строит happens-before и сообщает о data race на реально исполненных путях. Ограничения: ловит только то, что выполнилось (редкие interleavings может пропустить), не видит логических гонок при корректной синхронизации, даёт 5-10x overhead — не для прода. Зелёный прогон не доказывает отсутствие гонок.
В: Почему 90% покрытия может быть хуже 70%? О: Если 90% набраны тестами без ассертов или дублирующими happy path, а 70% — осмысленными тестами с проверкой граничных и error-веток. Покрытие как жёсткая цель (закон Гудхарта) провоцирует тесты-пустышки ради цифры. Важнее качество кейсов и покрытие критичных/ошибочных путей.
В: Что такое флаки-тест и каковы типичные причины? О: Тест, недетерминированно проходящий/падающий без изменений кода. Причины: data race, зависимость от времени (sleep/таймауты), от порядка тестов (общее состояние), внешние зависимости (сеть, ФС), параллелизм с общим состоянием, недетерминированный порядок мап. Корень часто — реальный баг, не «случайность».
В: Как выявлять флаки-тесты?
О: Повторными прогонами go test -count=N (отключает кэш), под -race (ловит гонки), -shuffle=on (рандомизирует порядок, вскрывает зависимость от порядка), увеличенным -timeout. Затем systematic debugging причины.
В: Почему retry флаки-теста — плохое решение? О: Retry маскирует реальную проблему — чаще всего data race, которая выстрелит и в продакшене, — и разрушает доверие к suite (люди игнорируют падения). Допустим временный карантин с тикетом, но решение — диагностировать и устранить причину, а не ретраить вечно.
В: Покрывает ли coverage конкурентные баги?
О: Нет. Покрытие меряет, что блок кода исполнился, но не учитывает порядок чередования горутин (interleavings). Гонка может жить в полностью «покрытом» коде. Для конкурентности нужны -race, стресс-прогоны -count, продуманные конкурентные сценарии.
На что копают на senior+#
- Зрелое отношение к метрике: покрытие как диагностика, а не KPI; внимание к diff-покрытию и error-путям; осознание закона Гудхарта.
- Branch/condition vs line coverage: понимание, что Go меряет блоки, и почему
&&/||и error-ветки требуют отдельных кейсов; знание про mutation testing как более честную метрику. - Модель памяти Go: что такое data race vs logic race, happens-before, почему -race динамический и вероятностный, atomic-режим покрытия.
- CI-инженерия: -race как отдельный gate,
-count=1против кэша,-shuffle, таймауты под overhead, политика по флаки (карантин+тикет, не вечный retry). - Диагностика флаки: systematic debugging, воспроизведение через повтор/shuffle, устранение источников недетерминизма (время, порядок, общее состояние, сеть).
- Тестируемость: инъекция Clock/рандома, изоляция состояния,
t.Cleanup, отказ от sleep в пользу polling/Eventually.