Как стать автором
Обновить
444.95
OTUS
Цифровые навыки от ведущих экспертов

100 % cover, 0 % спокойствия

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров1.2K

Привет, Хабр!

Сегодня я хочу поговорить о том, как мы все иногда очарованы показателями тестового покрытия в Go — и как же часто эти проценты лукаво нам подмигивают. Казалось бы, влепили go test -cover, получили любимые цифры, приближающиеся к 100%, и можно выдохнуть. Но, увы, не всё так радужно, как хочется. На самом деле заветная сотка покрытия далеко не всегда означает, что ваш код действительно покрыт тестами.

Как работает go test -cover

Для начала вспомним, что происходит, когда мы запускаем go test -cover. Go автоматически инструментирует наш код, добавляя специальные счётчики покрытия в нужные места. В момент прохождения теста по какому‑то участку кода, соответствующий счётчик отмечается. А потом результаты суммируются, выводя общее значение покрытия строк кода, по которым тест прошёл.

Можно копнуть чуть глубже. Когда вы запускаете, например:

go test -cover -coverprofile=coverage.out

Go создаёт файл coverage.out, в котором описано, какие строки кода были выполнены. Там указываются пакеты, файлы, номера строк и покрытие. Затем, чтобы визуализировать это дело, можно использовать команду:

go tool cover -html=coverage.out

И получить наглядный отчёт в браузере: какие строки вошли в покрытие зелёным, а какие остались угрюмо‑белыми. Однако покрытие строк в Go обычно не означает покрытие всей логики.

Пример №1. Изящно прикрытая ветка

Предположим, есть функция:

func CheckStatus(code int) string {
    if code == 200 {
        return "OK"
    } else {
        return "Error"
    }
}

И тест к ней:

func TestCheckStatus(t *testing.T) {
    result := CheckStatus(200)
    if result != "OK" {
        t.Errorf("expected OK, got %s", result)
    }
}

Если я запущу go test -cover, мы увидим, что покрытие внутри этого файла может быть 100%. Но на самом деле мы не проверили ветку else. Код этой ветки формально считаться покрытым, конечно, не будет (потому что строки в else не выполнились), так что здесь Go‑coverage ещё не врёт. Но стоит нам чуть иначе оформить функцию, и дело обстоит куда хитрее.

Пример №2. Покрытие одной строкой

Иногда любят делать вот так:

func CheckStatusOneLine(code int) string {
    if code == 200 { return "OK" }; return "Error"
}

Написав всё в одну строку, мы даём Go шанс посчитать, что у нас всё покрылось. Ведь теперь условие if code == 200 { return "OK" } и return "Error" фактически могут засчитаться одним куском кода (зависит от того, как именно Go суммирует покрытие в конкретном месте). Т.е если мы не осознанно запустили тест для ветки, cover может засчитать нам «отмечена часть строки» и ради удобства (или внутренних соглашений) окрасить её полностью — хотя реально return "Error" мы так и не протестировали.

Разумеется, в более ранних версиях Go таких казусов было больше. Со временем логика покрытия улучшилась.

Как cover считает строки

Go‑coverage ориентируется на регионы кода, разбивая строку на несколько сегментов, если там есть ветвления. Например, такая строка может считаться двумя сегментами:

if condition { return 1 } else { return 0 }

И если тест покрыл оба варианта, оба сегмента будут засчитаны как покрытые. Но вся эта региональная история не всегда работает так, как мы интуитивно ожидаем. Это особенно заметно, когда в одной строке много логики или когда у нас вложенные вызовы.

Задумка хороша: Go пытается прикинуть, что в каждом участке кода, где может быть условная ветка, нужно отдельное покрытие. Но бывают случаи, когда Go «склеивает» несколько условий в единое целое.

Плюс, есть ситуации, когда конструкция выглядит ветвящейся, но cover не может корректно её «разбить». Мы об этом поговорим дальше.

Что cover не видит

  • Паники в горутинах. Если внутри горутины возникает паника, которая не влияет на основной поток, и мы не видим результатов этой паники в тесте, cover нам может ничего об этом не сказать. Строки вроде «зайдите сюда и упадите» — частенько остаются фантомами в отчёте, ведь Go‑coverage не понимает, что функция работала не полностью корректно.

  • recover внутри defer. Подобные конструкции также могут вести себя загадочно с точки зрения покрытия. Да, строка с recover() может быть «выполнена», но логика в defer может быть неполностью задетектирована инструментом.

  • Генераторы кода. Если у вас есть код, который генерируется, а затем компилируется, coverage может не включать эти авто‑сгенерённые файлы (зависит от того, как вы их подключаете и тестируете). Если скрипты генерации не учитывают флаги -cover, вы можете получать неполные результаты. Вроде мелочь, но потом хватаешься за голову, почему в файлах реальной бизнес‑логики 100%, а в автогенерированных protobuf'ах — 0%.

  • reflect. Если мы используем reflect для динамического вызова методов или функций, возможно, нам придётся отдельно потестить сами вызовы. Инструментация покрытия может пропустить участки, которые вызываются «опосредованно», ведь Go вставляет счётчики в компилируемый код. А если код вызывается не напрямую, а через reflect.Call, можно остаться на бобах.

  • Тонкости c interface{}. Бывает, что мы имеем дело с динамическими типами, и иногда, если компилятор не добавил счётчики туда, где мы фактически вызываем тот или иной метод — этот метод может остаться неучтённым.

Где ложное чувство безопасности

Вы можете воодушевлённо заявить: «У нас 100% coverage, всё прекрасно». Но гарантий здесь мало. Представим, что ваша логика кода:

  1. Делится на несколько параллельных веток выполнения (goroutines).

  2. Активно использует отложенные вызовы (defer) с хитрой обработкой.

  3. Явно оформлена так, что в одной строке скрывается несколько логических веток.

Всё это может привести к тому, что нам Go радостно скажет «100%». Но по факту мы можем не проверить ни конкуренцию, ни крайние кейсы, ни работу в условиях, где одна из веток может никогда не вызваться при тестовом прогоне.

Классический пример с гонками

Допустим, есть такой сервис:

type Service struct {
    count int
    mu    sync.Mutex
}

func (s *Service) Inc() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.count++
}

func (s *Service) Get() int {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.count
}

И тест:

func TestService(t *testing.T) {
    s := &Service{}
    s.Inc()
    if s.Get() != 1 {
        t.Error("expected count to be 1")
    }
}

Вроде всё покрыто. Но попробуйте запустить несколько горутин, конкурирующих за вызов s.Inc(), и вы поймёте, что этот код может работать корректно, а может и нет, если логика внутри была чуть сложнее (скажем, мы где‑то «забыли» Lock()). Покрытие вернёт 100%, а тест может ничего не сказать про настоящие проблемные кейсы, вроде гонок данных.

Как следствие, вы расслабитесь, решив, что раз «тесты ок», значит всё прекрасно. А оно может быть не так. Вот поэтому 100% покрытия — не подтверждение корректности concurrency‑сценариев.

Чего ждать от go test cover и как жить дальше

Не зацикливаться на 100%

Часто я замечаю, что новички или менеджеры считают: «100% coverage — это показатель качества». Но важнее — наличие продуманных тест‑кейсов, которые покрывают реальные сценарии использования кода. Лучше иметь 70–80% покрытия, но при этом проверять всё, чем 100% проформы.

Пользоваться разными режимами cover

-covermode=set|count|atomic — переключение между разными режимами подсчёта.

  • set просто отмечает, что строка была выполнена.

  • count ведёт счёт, сколько раз строка была выполнена.

  • atomic — то же самое, но с атомарными операциями (важно при параллельном тестировании).

Поэкспериментируйте с режимами, чтобы понять, где у вас действительно многоразовые вызовы.

coverpkg и coverprofile

Используйте -coverpkg и указывайте список пакетов, которые хотите покрыть. По дефолту, если тесты лежат в одном пакете, а логика в другом, мы можем пропустить часть кода. Например:

go test -coverpkg=./... -coverprofile=coverage.out ./...

Так вы более полно учитываете всё, что у вас задействовано. Конечно, надо понимать, что некоторые инфраструктурные пакеты (вроде cmd, или внешних библиотек) мы не тестируем напрямую. Но это уже вопрос структуры проекта.

Проверять тесты на качество, а не только на количество

  • Пишите тесты, которые действительно проверяют разные ветви.

  • Делайте table‑driven тесты, где вы перечисляете несколько входных вариантов и ожидаемые выходные результаты.

  • Пишите тесты, которые моделируют разные уровни нагрузки (включая конкурентные вызовы).

  • Пробуйте negative‑тесты, когда вы подсовываете невалидные данные.

«Превентивная паранойя» в тестах

Если функция может вернуть несколько вариантов ответа (не только «OK» и «Error», а «OK», «Error», «Timeout», «Partial» и т. д.), не ограничивайтесь проверкой одного пути. Старайтесь, чтобы тесты охватывали все ветви дерева решений. Но не превращайте это в бездумное покрытие, иначе рискуете сделать гигантский, но пустой набор тестов, которые ничего полезного не проверяют.

Пример

Представим ситуацию. У вас — нормальный такой сервис с микросервисной архитектурой. Всё по классике: CI/CD, юниты, линтеры, покрытие гонят через go test -cover. Где‑то наверху — менеджеры, которым важно видеть красивые цифры в отчётах: 90%+ покрытия, иначе не пройдёт аудит.

Разработчики, естественно, пишут тесты, как могут. Проходит месяц. Потом другой. Сервис начинает работать под более высокой нагрузкой — на него падает очередной релиз с новым трафиком. И вдруг: неожиданно — HTTP 500, очередь задач переполняется, данные теряются. Паника. Начинается разбор, а потом приходит инсайд: ни один из существующих тестов не воспроизводит этот сценарий, потому что нестандартные ветви вообще не были затронуты. И, да, coverage.out всё это время честно показывал 95%.

Допустим, есть менеджер конфигураций:

type ConfigManager struct {
    data map[string]string
    mu   sync.RWMutex
}

func NewConfigManager() *ConfigManager {
    return &ConfigManager{
        data: make(map[string]string),
    }
}

func (cm *ConfigManager) Set(key, value string) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    cm.data[key] = value
}

func (cm *ConfigManager) Get(key string) (string, bool) {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    val, ok := cm.data[key]
    return val, ok
}

И вот тест, как его часто пишут вначале:

func TestConfigManager(t *testing.T) {
    cm := NewConfigManager()
    cm.Set("host", "localhost")

    if val, ok := cm.Get("host"); !ok || val != "localhost" {
        t.Errorf("expected 'localhost', got '%s'", val)
    }
}

После такого теста go test -cover с радостью сообщит: 100%. Всё прекрасно? Почти.

Теперь зададимся вопросами:

  • Что если Set должен вести себя иначе при пустом ключе?

  • А если Get вызывается конкурентно из 10 горутин? Всё ли там ок?

  • Или, допустим, вы добавили метод Delete:

func (cm *ConfigManager) Delete(key string) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    delete(cm.data, key)
}

И не написали на него тест. Строчка одна. Покрытие по ней — ноль. Но Go может спокойно склеить её с другой строкой логики, и в визуализации go tool cover всё будет выглядеть ок — даже если ветка реально не исполнялась.

А теперь представьте, что Delete добавляется в Set:

func (cm *ConfigManager) Set(key, value string) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    if value == "" {
        delete(cm.data, key)
        return
    }
    cm.data[key] = value
}

Если тесты проверяют только установку, но ни разу не вызывают Set("", ""), то блок delete может никогда не исполняться, но cover покажет, будто он на месте.

Вот вам и 100». Только вот по‑настоящему важные ветки логики никто не трогал.


Заключение

Не бойтесь иметь 80–90% покрытия — главное, чтобы покрывались все важные ветви, все критичные кейсы. Тесты нужны не для красоты и гонки за процентами, а для уверенности, что код стабилен и при нагрузке, и при неожиданных входных данных, и при очередном рефакторинге — всё это будет работать.

Так что go test -cover — это классная функция, но это лишь один из множества индикаторов, а не священный Грааль надёжности. Пишите тесты грамотно, учитывайте реальные сценарии.


Вопросы о том, как работает веб и что стоит за взаимодействием клиента и сервера, могут быть нетакими очевидными, как кажутся на первый взгляд. Если вы хотите углубиться в фундаментальные принципы, которые стоят за архитектурой веб‑приложений, а также понять, как эти принципы реализуются в реальной разработке, приглашаю на открытый урок в Otus «Что такое сайты и где они обитают?».

В ходе встречи 17 апреля в 20:00 мы разберемся, как устроены клиент‑серверные взаимодействия, какие архитектурные модели существуют и как их эффективно применять при проектировании своих приложений. Полученные знания помогут вам сделать осознанный выбор при разработке и анализе архитектуры любых веб‑сервисов.

Записывайтесь на странице курса «Автоматизированное тестирование веб-сервисов на Go»

Теги:
Хабы:
+5
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS

Истории