---
title: "Тесты в CI: гонки, покрытие и Testify"
description: "go тесты ci: шаг с -race и coverprofile, честное покрытие кода go через go tool cover, кэш прогонов, flaky и retries, вывод assert в отчётах и спор о пороге."
course: "Testify: тестирование Go с assert и require"
url: "https://golangmaster.ru/courses/testify/lessons/24-ci-pokrytie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тесты в CI: гонки, покрытие и Testify

*Курс «Testify: тестирование Go с assert и require», урок 24 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testify/lessons/24-ci-pokrytie/*

Пайплайн для go тесты ci собирается из трёх вещей: прогон go test с -race, профиль покрытия и вменяемая реакция на красный тест. Testify здесь без отдельного моста: его вывод уезжает в t.Error, и CI честно видит FAIL с diff. Ниже — команда, флаги, кэш, flaky и спор о пороге покрытия.

Пятница, вечер: пайплайн красный, а тот же коммит у всех зелёный. Открываете лог — DATA RACE в тесте, который никто не трогал месяц. Это не заговор CI: раннер нагружен соседними джобами, планировщик крутится иначе, и гонка, которую у вас пронесло сотню прогонов, в пайплайне выстрелила с первого раза. Хорошая новость: -race поймал её до продакшена. Плохая: если гонки в CI-прогоне нет, вы всё равно летите вслепую — то, что зелёный, ещё не значит, что код корректен. Ниже — минимальный честный прогон тестов для пайплайна: команда, флаги, покрытие, кэш, flaky и то, как во всём этом выглядит вывод testify.

### Команда прогона: go test -race и coverprofile

Ядро go тесты ci — одна команда: go test -race -coverprofile=coverage.out ./... Троеточие берёт все пакеты модуля, а не только тот, что меняли, -race включает детектор гонок, -coverprofile пишет профиль покрытия в файл. Про -race честно: детектор не бесплатный — по документации память может расти в пять-десять раз, время исполнения в два-двадцать. Для юнит-тестов это копейки, и они дешевле ночной гонки в проде: детектор ловит конкурентный доступ к памяти, который горутины устраивают молча, а механика этих горутин — отдельный урок в go-advanced. Провал любого теста — ненулевой код выхода: именно на него смотрит CI, всё остальное — раскраска лога.

### Шаг тестов в пайплайне

```go
go test -race -count=1 -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
```

Одна команда делает три дела: гоняет все пакеты с детектором гонок, отключает кэш флагом -count=1 и пишет профиль покрытия в файл. Затем -func печатает процент по каждой функции и итог, а -html собирает интерактивную карту строк в отдельный файл артефактом. Точно так же это выглядит и в локальном прогоне: песочница пайплайны не исполняет.

### Проверка: зачем -race, если он медленный

**Вопрос:** Зачем тащить -race в CI, если он замедляет прогон?

- ✅ Гонки зависят от расписания: на другой машине тот же код падает иначе
- Без -race CI не умеет запускать горутины
- -race ускоряет тесты за счёт агрессивного кэша
- Без него не работает -coverprofile

Детектор гонок не чинит код, а добавляет проверки синхронизации в рантайм: конкурентный доступ фиксируется в момент, когда записи совпали. Какое пересечение случится — решает расписание горутин, и на другом железе оно другое. Поэтому зелёный без -race ничего не доказывает.

### Покрытие кода go: -func, -html и честный процент

Профиль покрытия считает операторы: какая доля строк исполнилась под тестами. go tool cover -func=coverage.out печатает процент по каждой функции и итог, -html рисует карту файла, где зелёное исполнялось, а серое — нет. Теперь честно о цифрах: сто процентов не цель. Покрытие говорит «строка исполнилась», но не «результат проверен»: можно вызвать функцию и не сравнить ничего — процент вырастет, качество нет. Осмысленная цель — держать высоким критичный путь: деньги, доступы, парсинг внешних данных, ветки ошибок. Вспомогательная обвязка и типовые геттеры в процентах не нуждаются. Смотреть на total и радоваться — самообман; смотреть, какие именно строки остались серыми, — работа на десять минут, которая находит мёртвые ветки быстрее любого ревью.

### Testify в отчётах: вывод уезжает в t.Error

Testify в CI не нужен отдельный мост. Провал ассерта — это t.Errorf внутри: тест помечается проваленным, go test печатает FAIL: TestXxx с вашим diff, а на выходе процесс уходит с ненулевым кодом. assert после провала тест не прерывает: остальные проверки кейса отработают, и в отчёте соберутся все провалы разом — иногда это экономит итерацию отладки. require рвёт тест на первом провале — правило выбора разобрано в [уроке про assert и require](/courses/testify/lessons/03-assert-require/). А будет ли в логе CI понятная строка или загадочное not equal, решают сообщения — стиль msgAndArgs отрабатывали в уроке про сообщения. CI видит всё это без спецобработки: FAIL и код выхода — стандартная реакция testing, testify просто наполняет её содержимым.

### Гонка, которую ловит -race

```go
func TestCollector(t *testing.T) {
        var got []string
        var wg sync.WaitGroup

        for _, task := range tasks {
                wg.Add(1)
                go func(id string) {
                        defer wg.Done()
                        got = append(got, id) // data race
                }(task)
        }
        wg.Wait()

        assert.Len(t, got, len(tasks))
}
```

Несколько горутин делают append в один срез: растут и заголовок, и массив — одновременная запись это гонка. wg.Wait синхронизирует завершение горутин, но не доступ внутри цикла. Под -race прогон падает с WARNING: DATA RACE и указанием обеих точек; без него тест может проходить месяцами. Такой пакет видит и локальный go test — песочница горутины не исполняет.

### Что вернёт прогон под -race?

```go
func TestCounter(t *testing.T) {
        var n int
        var wg sync.WaitGroup
        for i := 0; i < 50; i++ {
                wg.Add(1)
                go func() {
                        defer wg.Done()
                        n++ // без синхронизации
                }()
        }
        wg.Wait()
        fmt.Println(n)
}
```

**Что выведет программа?**

- ✅ WARNING: DATA RACE, тест провален
- Ровно 50: инкремент int атомарен сам по себе
- Число меньше 50, но гонку детектор не увидит
- Паника: n нельзя захватывать в горутину

Инкремент не атомарен: чтение, сложение и запись от пятидесяти горутин пересекаются. Детектор включён флагом -race — пересечение записи фиксируется, и прогон краснеет независимо от финального числа. Без -race напечаталось бы что-то около пятидесяти, иногда ровно пятьдесят: потерянные обновления и есть главный след гонки.

### Кэш go test: -count=1 и testing.Short

go test кэширует результаты: если пакет и его зависимости не менялись, повторный прогон печатает ok (cached) и не исполняет ничего. У себя на машине это скорость; в пайплайне с прогретым кэшем сборщика — риск взять старое зелёное. Флаг -count=1 отключает кэш: каждая попытка честно исполняется, а -count=5 запустит тест пять раз подряд — быстрый способ вылова flaky без всего пайплайна. Для длинных тестов есть вилка testing.Short: внутри теста if testing.Short() { t.Skip(...) }, а короткий прогон с -short идёт на каждый коммит, полный — по расписанию или перед релизом. И тонкость про testify: медленный тест со sleep кэш не волнует — он останется медленным в любом профиле, и лечить его надо не флагами, а ожиданием факта.

### Флаг — что делает

| Флаг | Что делает | Когда нужен |
| --- | --- | --- |
| -race | Включает детектор гонок | Каждый прогон с горутинами — то есть почти любой |
| -coverprofile=file | Пишет профиль покрытия | Шаг тестов в CI, дальше -func или -html |
| -count=1 | Отключает кэш результатов | Пайплайн с прогретым кэшем сборщика |
| -short | Включает testing.Short | Быстрый прогон на каждый коммит |
| -run REGEX | Фильтр тестов по имени | Точечная отладка одного кейса |
| -timeout 5m | Таймаут на пакет тестов | Страховка от зависшей горутины: по умолчанию 10 минут |
| -v | Подробный вывод | Разбор, что именно исполнилось |

### Flaky: retries против честного ожидания

Flaky-тест краснеет без причины и зеленеет при повторе. Первое желание — retries: перезапускать красный до зелёного и наслаждаться тишиной. Это затычка: ретрай не чинит причину, а прячет — под шум проползает настоящий баг, у которого тоже «иногда краснеет». Честный путь — убрать недетерминизм: sleep перед проверкой горутины заменить на ожидание факта через Eventually — механика в [уроке про Eventually](/courses/testify/lessons/20-eventually/), — общий срез в параллельных сабтестах на канал или мьютекс, как в [уроке про мьютексы](/courses/go-advanced/lessons/08-muteksy/), зависимость от сети — на заглушку. Ретрай допустим как временная мера с тикетом и датой сноса. Правило простое: зелёный со второго раза — это красный, который вам пока повезло не увидеть.

> **Зелёный на ноутбуке, красный в CI.** Кейс: тест гонялся у разработчика сотню раз — зелёный; в пайплайне -race поймал DATA RACE на append в горутине из первого дня. Объяснение машин: у ноутбучного прогона другое расписание — ядра, загрузка, тайминги, и пересечение записей не совпало ни разу. Детектор гонок не «стал строже в CI» — он одинаковый; совпасть записям на другой машине стало вероятнее. Мораль: красный с -race в CI — не флейк и не каприз пайплайна, это настоящий баг, который у вас просто ещё не случился. Чинится синхронизацией, а не ретраями.

> **Спор: порог покрытия в CI.** Одна школа ставит гейт: покрытие ниже восьмидесяти процентов — пайплайн красный. Другая против: процент набирается мусорными тестами без проверок, а честная непокрытая ветка ошибки наказывает. Моя граница: порог вреден как абсолют, полезен как храповик — не даёт покрытию упасть ниже достигнутого и ловит большие куски кода без единого теста. На критичные пакеты ставлю требование жёстче и смотрю -html глазами: какие именно строки серые. Процент отвечает «сколько исполнилось», но не «насколько можно верить отчёту» — это разные вопросы, и второй важнее.

### Вилка testing.Short в тесте

```go
func TestSMTPRoundtrip(t *testing.T) {
        if testing.Short() {
                t.Skip("полный прогон SMTP-заглушки — вне -short")
        }

        assert.Eventually(t, func() bool {
                return outbox.HasMail("welcome")
        }, 3*time.Second, 20*time.Millisecond,
                "письмо не ушло за три секунды")
}
```

Быстрый прогон с -short скипает тяжёлый тест честно: в отчёте стоит SKIP, а не молчаливое исчезновение. Сама проверка — Eventually с тиком и дедлайном: письмо может дойти не мгновенно, и тест ждёт факт, а не sleep. Сообщение в msgAndArgs уезжает в лог CI, если дедлайн истёк. Это тестовый пакет: песочница заглушку SMTP не поднимает, прогоны даны как ориентир.

### Проверка: что увидит CI

**Вопрос:** Тест с assert.Equal провалился на третьей из десяти проверок. Что увидит CI?

- ✅ FAIL теста и продолжение: остальные проверки отработают, в отчёте все провалы
- Остановку всего пакета на первой провалке
- Зелёный статус: assert не влияет на результат
- Панику: после провала assert тест продолжать нельзя

assert помечает тест проваленным через t.Errorf и возвращается — выполнение идёт дальше, поэтому одна тест-функция может собрать несколько отчётов за прогон. require повёл бы себя иначе: FailNow рвёт тест сразу. Пакет в целом падает по коду выхода, а не по месту провала.

### Правила прогона в CI

- Базовая команда: go test -race -count=1 -coverprofile=coverage.out ./...
- -race на каждый прогон: гонка зависит от расписания, зелёный без детектора ничего не доказывает.
- Покрытие — по критичному пути: 100% не цель, непокрытые ветки ошибок дороже геттеров.
- Кэш отключён -count=1; длинные тесты — вилка testing.Short и отдельный полный прогон.
- assert в отчёте CI выглядит штатно: FAIL, diff и ненулевой код выхода.
- Retries — временная мера с тикетом; честный фикс — убрать недетерминизм из теста.

### Соберите шаг тестов

Допишите флаги: детектор гонок, отчёт покрытия по функциям и прогон без кэша.

```go
go test ___ -coverprofile=coverage.out ./...
go tool cover -___=coverage.out
go test -short -___=1 ./...
```

Подсказка: Гонки ловит -race, по функциям процент печатает -func, а кэш отключает -count=1.

Правильные ответы в пропусках: `-race`, `func`, `count`

### Почините гонку в сабтесте

Расставьте строки фикса: мьютекс вокруг записи и проверки после ожидания.

Правильный порядок строк:

1. `var mu sync.Mutex`
2. `mu.Lock()`
3. `got = append(got, task)`
4. `mu.Unlock()`
5. `wg.Wait()`
6. `assert.Len(t, got, len(tasks))`

### Карточки: тесты в CI

- **-race** — Детектор гонок: ловит конкурентный несинхронизированный доступ к памяти. Цена — рост памяти и времени в разы; гонка зависит от расписания горутин.
- **-coverprofile** — Пишет файл профиля покрытия; смотреть через go tool cover -func (проценты по функциям) или -html (карта строк).
- **-count=1** — Отключает кэш результатов go test: пакет перепрогоняется даже без изменений; -count=5 — пять попыток для вылова flaky.
- **testing.Short** — Возвращает true под флагом -short; длинные тесты уходят в t.Skip, полный прогон остаётся отдельным шагом.
- **assert и t.Error** — Провал ассерта — это t.Errorf: тест помечен проваленным, go test печатает FAIL и уходит с ненулевым кодом; assert не прерывает тест.
- **retries** — Перезапуск красного теста до зелёного: затычка против честного фикса недетерминизма; допустим как временная мера с тикетом.

Прогон в CI — та же testing-машина, что и у вас на машине: go test с флагами, код выхода и отчёт. Testify встраивается в неё без моста — ассерты наполняют FAIL содержимым, а моки и suite живут в тех же тест-функциях. Полезная связка на выходе: -race ловит то, что планировщик обходит, покрытие показывает серые зоны, кэш не подсовывает старое. Дальше по курсу — последние темы: чем закрыть тестирование HTTP-заглушек и что забрать с собой в собственный проект. Карта всех уроков — на странице [testify-курса](/courses/testify/). Привычка на неделю: каждый красный прогон в CI разбирайте без ретрая — сначала причина, потом кнопка перезапуска.

## Коротко: что запомнить

- Шаг тестов: go test -race -count=1 -coverprofile=coverage.out ./... и ненулевой код выхода при провале.
- -race ловит конкурентный доступ; цена — память и время в разы, для юнит-тестов это норм.
- Покрытие считает исполненные операторы: строка исполнилась не значит, что результат проверен.
- Критичный путь важнее процента: 100% не цель, серые строки ошибок — сигнал.
- Кэш go test отключает -count=1; длинные тесты — вилка testing.Short и отдельный полный прогон.
- assert уезжает в отчёт штатно: FAIL, diff, все провалы кейса разом.
- Retries — затычка с тикетом; честный фикс — убрать недетерминизм: Eventually вместо sleep, синхронизация вместо вольных горутин.

## Домашнее задание

Соберите шаг тестов из урока: go test -race -count=1 -coverprofile=coverage.out ./... плюс отчёт через go tool cover -func. Откройте -html на самом непокрытом пакете и выпишите две серые строки, которые важнее геттеров. Найдите flaky-тест с ретраями в своём пайплайне и замените его причину: Eventually вместо sleep или мьютекс вокруг записи.

## Вопросы и ответы по уроку

### Почему тест зелёный на моей машине и красный в CI?

Чаще всего — расписание горутин и -race: у вас пересечение записей ни разу не совпало, на загруженном агенте CI совпало, и детектор честно краснит. Вторая причина — кэш: у вас прогон печатает ok (cached) без исполнения. Начните диагностику с -race и -count=1 — обычно вопрос закрывается.

### Какой процент покрытия считать нормальным?

Универсального числа нет: сто процентов достижимы и бессмысленны, если проверки пустые. Я смотрю на критичный путь — платёжные и авторизационные ветки, парсинг внешних данных: там держу высокий процент и читаю -html, какие строки серые. Обвязке и типовым геттерам хватает минимума. Полезнее порога-абсолюта — храповик: не падать ниже достигнутого.

### Testify-тесты как-то особенно настраивать в CI?

Нет: провал ассерта — это обычный t.Errorf, go test печатает FAIL и уходит с ненулевым кодом, любой пайплайн это понимает. Единственная настройка — культура сообщений: msgAndArgs с контекстом делают лог читаемым. Отдельно следите, чтобы тесты не зависели от сети и времени: они ломаются раньше, чем testify успевает помочь.
