Модуль: Тестирование · Уровень: 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.