voidlearn
 

5.6Тесты в CI

Сопоставим локальный запуск и CI, затем настроим быстрые и полные проверки без маскировки flaky результатов.

Что сможешь объяснить после этой главы
  • Что go test кладёт в ключ кэша, откуда берётся (cached) и когда эта строчка врёт.
  • Чем -p отличается от -parallel и от t.Parallel() и почему в контейнере с одним ядром оба флага по умолчанию равны двум.
  • Как разложить прогон на «быстрое на merge request» и «долгое ночью», не потеряв компиляцию файлов под билд-тегом.
  • Как искать мигающий тест, зачем карантину срок и сколько стоит автоперезапуск упавшего.
  • Что делать с прогоном, который красный только в CI, когда на раннер не пускают.

Устройство пайплайна GitLab здесь не разбирается: стадии, needs, rules, кэш, артефакты и services: лежат в теме «CI/CD и деплой», глава 4.2.

Чужая машина: что именно отличается

Разницу стоит выписать один раз и сверяться с ней всякий раз, когда «локально работает». Правая колонка снята в контейнере golang:1.27.1-alpine с --cpus=1 на Docker Desktop 29.8, левая — на macOS arm64 с Go 1.27.1.

ЧтоНоутбукРаннер
Ядра и GOMAXPROCS NumCPU=14 GOMAXPROCS=14 NumCPU=4 GOMAXPROCS=2 — рантайм смотрит на лимит cgroup
Кэш сборки и тестов копится месяцами, повтор почти бесплатный пустой, пока его не перенесли между джобами
Файловая система APFS, регистр имени по умолчанию не различает overlay поверх ext4: Prod.yaml и prod.yaml — разные файлы
Часовой поясMSK+0300UTC+0000
Тулчейн GOTOOLCHAIN=auto, недостающую версию докачает в официальном образе golang стоит GOTOOLCHAIN=local
Сосединикогоещё несколько джобов на том же железе

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

Кэш тестов: ключ и дыры в нём

go test запоминает успешные результаты пакетов и при повторе печатает (cached), не запуская бинарь. Провалы не кэшируются: красный пакет перезапускается, пока не позеленеет.

$ go test ./...
ok  	demo/price	0.268s
$ go test ./...
ok  	demo/price	(cached)
$ go test -count=1 ./...
ok  	demo/price	0.175s

Ключ складывается из двух половин. Первая, test ID, считается до прогона: хеш тестового бинаря (содержимое пакета, все зависимости, флаги сборки вроде -race и -tags) плюс флаги командной строки. Флаги не любые, а только кэшируемые; в Go 1.27.1 go help test перечисляет такой набор:

-benchtime, -coverprofile, -cpu, -failfast, -fullpath, -list, -outputdir,
-parallel, -run, -short, -skip, -timeout, -v

Любой флаг вне списка отключает кэш целиком. Поэтому go test -bench=. не кэшируется ни разу, а -count=1 стал идиомой для «выполни по-настоящему»: самого счётчика в списке нет.

Вторая половина, input ID, считается уже по результату прогона. Тестовый бинарь во время работы ведёт test log: пакет testing/internal/testdeps перехватывает обращения к окружению и к файлам и пишет их построчно. Вот лог теста, который делает os.Stat("testdata"), читает testdata/golden.json и смотрит REPORT_MODE:

$ go test -c -o /tmp/reporttest.bin ./report/
$ cd report && REPORT_MODE=short /tmp/reporttest.bin -test.testlogfile=/tmp/tl.txt >/dev/null
$ cat /tmp/tl.txt
# test log
stat testdata
open testdata/golden.json
getenv REPORT_MODE

Дальше go досчитывает по этому списку input ID: для переменной берёт текущее значение, для файла — размер, права и время изменения. Содержимое файлов не хешируется, только stat. Безусловно добавляется GODEBUG, его читает сам рантайм мимо лога. Есть и грубая страховка: если прочитанный тестом файл моложе двух секунд, результат не кладётся в кэш вовсе — mtime могло не успеть измениться. Всё решение печатает GODEBUG=gocachetest=1, вплоть до строки file used as input is too new. Профиль покрытия кэш держит рядом с выводом, поэтому -coverprofile на закэшированном прогоне тоже отработает. А значение переменной входит в ключ, и кэш помнит несколько вариантов сразу:

$ APP_REGION=eu go test ./env/
ok  	demo/env	0.171s
$ APP_REGION=eu go test ./env/
ok  	demo/env	(cached)
$ APP_REGION=us go test ./env/
ok  	demo/env	0.206s
$ APP_REGION=eu go test ./env/
ok  	demo/env	(cached)        # запись для eu никуда не делась

Теперь про дыры. В test log попадают getenv, open, stat и chdir, и только они. Причём даже из них go учитывает не всё: пути вне корня модуля (а также вне GOPATH и GOROOT) он записывает, но перепроверять не станет. Комментарий в cmd/go так и говорит: do not recheck files outside the module, GOPATH, or GOROOT root. Тест читает конфиг, который лежит рядом с репозиторием, и сверяет содержимое:

$ go test -run TestOutside ./file/
ok  	demo/file	0.317s
$ go test -run TestOutside ./file/
ok  	demo/file	(cached)
$ echo v2 > /tmp/shared/config.txt        # мир изменился
$ go test -run TestOutside ./file/
ok  	demo/file	(cached)
$ go test -count=1 -run TestOutside ./file/
--- FAIL: TestOutside (0.00s)
    outside_test.go:11: config = "v2\n", хотим v1
FAIL
FAIL	demo/file	0.164s
FAIL

Тест падает, а go test перед этим напечатал ok. С сетью ещё проще: соединений в логе нет в принципе. Локальный сервис, переставший отдавать {"rate":10}, даёт ту же пару «(cached), а под -count=1 красное». Часы туда же: тест «до конца месяца» первого числа возьмётся из кэша.

Где ставить -count=1, а где не надо

Юнит-тесты, которые читают только свой пакет и свои testdata, кэш отслеживает честно, и -count=1 там просто выбрасывает выигрыш. А всё, что смотрит наружу процесса — база в контейнере, брокер, чужой HTTP, файл вне модуля, — обязано идти с -count=1. Ставь флаг в конкретной джобе, а не в GOFLAGS на весь пайплайн. И помни, что GOCACHE хранит результаты тестов вместе с объектными файлами: перенёс каталог между джобами ради быстрой компиляции — перенёс и все (cached).

Ключ кэша go test: две половины и то, что мимо них проходит Половина первая: test ID, считается до прогона Тестовый бинарь пакет, все зависимости, флаги сборки: -race, -tags Кэшируемые флаги -run -short -v -timeout -parallel -cpu -coverprofile test ID хеш обеих коробок Половина вторая: input ID, считается по test log после прогона # test log stat testdata open testdata/golden.json getenv REPORT_MODE значение переменной целиком, у файла — размер, права, mtime, и GODEBUG всегда input ID хеш test log Запись в GOCACHE вывод теста и профиль покрытия Чего в ключе нет — и поэтому кэш этого не заметит Ответ по сети соединений в test log нет, ответ сервиса на ключ не влияет Файл вне корня модуля в лог попал, но перепроверять его go не станет Часы и часовой пояс тест «до конца месяца» первого числа берётся из кэша $ go test ./net/ ok demo/net (cached) — а -count=1 на том же месте даёт FAIL
Ключ кэша тестов. Первая половина считается из бинаря и флагов (на схеме их часть, полный список выше), вторая — из того, что тест потрогал во время прогона. Всё, чего нет ни в той, ни в другой, меняется незаметно, и тогда (cached) значит «тест не выполнялся», а не «тест прошёл».

Что гонять на merge request, а что ночью

Единый прогон растёт, пока не станет невыносимым, а дальше красный пайплайн начинают перезапускать не глядя. Разделение держит обратную связь в пределах пары минут и сохраняет медленные проверки. Инструментов два, и они не взаимозаменяемы. -short ставит флаг, который тест читает сам через testing.Short(): файл компилируется и линкуется, тест пропускает себя. Билд-тег исключает файл из сборки целиком (сам тег разобран в главе 5.2) — быстрее, но за это приходит счёт: файл, который никто не собирает, не проверяет и компилятор.

$ go build ./... && go test -short -count=1 ./internal/store/
ok  	shop/internal/store	0.506s
$ go vet ./internal/store/
$ go vet -tags=integration ./internal/store/
# shop/internal/store
# [shop/internal/store]
vet: internal/store/pg_integration_test.go:13:8: undefined: repo

Переименовали метод репозитория, обновили все вызовы, кроме тех, что под тегом, — и узнали об этом ночью, когда интеграционная джоба впервые попробовала собраться. Лечит одна строка в быстрой джобе: go vet -tags=integration,e2e ./... компилирует всё, ничего не запуская.

stages: [test]

unit:                                    # на каждый MR: только быстрое
  stage: test
  script:
    - go vet -tags=integration ./...
    - go test -short -count=1 ./...

integration:                             # ночью: всё, что поднимает контейнеры
  stage: test
  script:
    - go test -tags=integration -count=1 -timeout=20m ./...
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"

flaky-hunt:                              # ночью же: повторы на поиск мигающих
  stage: test
  script:
    - go test -short -count=10 -shuffle=on -timeout=30m ./...
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"

Проверить, что джобы отбираются как задумано, можно не трогая настоящий GitLab: gitlab-ci-local разбирает тот же файл и печатает состав пайплайна. На обычном пуше в списке остаётся одна unit, с CI_PIPELINE_SOURCE=schedule — все три.

Три уровня параллельности и GOMAXPROCS в контейнере

Слово «параллельно» в go test означает три разные вещи, и путаница между ними стоит либо времени, либо падений на общей базе. Механика t.Parallel() разобрана в главе 5.1; здесь важно, как три уровня складываются в число на часах CI.

  • -p N задаёт, сколько тестовых бинарей, то есть пакетов, бежит одновременно. Это отдельные процессы. По умолчанию GOMAXPROCS.
  • -parallel N ограничивает число тестов внутри одного бинаря, идущих разом. Влияет только на те, что позвали t.Parallel(). Тоже GOMAXPROCS.
  • t.Parallel() записывает конкретный тест во второй пункт. Без него -parallel не делает ничего.

Замер: четыре пакета, в каждом четыре теста по 300 мс, итого 4,8 секунды «работы». В наборе par каждый тест зовёт t.Parallel(), в наборе seq — нет. Всё в контейнере golang:1.27.1-alpine с --cpus=1, кэш сборки прогрет.

# каждый из 4 пакетов: 4 теста по 300 мс, все зовут t.Parallel
go test ./par...                     1.63 s
go test -p 1 ./par...                3.02 s
go test -p 1 -parallel 1 ./par...    5.50 s
go test -p 4 -parallel 4 ./par...    0.70 s
# те же 16 тестов, но t.Parallel никто не зовёт
go test ./seq...                     2.81 s
go test -p 1 ./seq...                5.50 s
go test -parallel 1 ./seq...         2.80 s
go test -p 4 ./seq...                1.61 s

-parallel 1 на наборе seq не поменял ничего: параллелить было нечего. -p 1 даёт худшее время на обоих наборах, потому что пакеты пошли по очереди. А -p 4 -parallel 4 на одном ядре ускорил прогон вдвое-втрое: эти тесты спят, а не считают, и слотов им нужно больше, чем ядер. У тестов, упирающихся в процессор, всё наоборот: лишние слоты множат переключения, и те же флаги прогон замедляют. Замер снят на ожидании, на счётные тесты его правило не переносится.

Откуда взялись двойки по умолчанию, видно из того же контейнера. С Go 1.25 рантайм на Linux смотрит на лимит CPU своей cgroup и берёт минимум из числа логических ядер и этого лимита; дробное округляется вверх, а ниже двух не опускается.

# procs печатает NumCPU, GOMAXPROCS и часовой пояс; программа в три строки
$ GOOS=linux GOARCH=arm64 go build -o procs .
$ docker run --rm --cpus=1.5 -v "$PWD":/app alpine sh -c 'cat /sys/fs/cgroup/cpu.max; /app/procs'
150000 100000
NumCPU=4 GOMAXPROCS=2 TZ=UTC+0000
$ docker run --rm --cpus=1.5 -v "$PWD":/app -e GODEBUG=containermaxprocs=0 alpine /app/procs
NumCPU=4 GOMAXPROCS=4 TZ=UTC+0000
--cpus0.511.522.53.4
GOMAXPROCS222234

runtime.NumCPU() при этом честно показывает четыре: он отвечает на вопрос «сколько ядер видно», а не «сколько разрешено съесть». Периодическое обновление при смене лимита выключается через GODEBUG=updatemaxprocs=0, а явный GOMAXPROCS в окружении перебивает и то, и другое.

Рантайм смотрит на limit, а не на request

В Kubernetes лимиту cgroup соответствует resources.limits.cpu. Поле requests.cpu рантайм не видит вовсе. Под без limits.cpu получает GOMAXPROCS по числу ядер узла: на узле с 64 ядрами это 64 планировщиковых потока у пода, которому выделили полядра. Так же ведёт себя и раннер CI в Kubernetes без limits.cpu. До Go 1.25 рантайм на cgroup не смотрел вовсе — оттуда и пошла привычка ставить GOMAXPROCS руками или тянуть uber-go/automaxprocs.

Что во что вкладывается: раннер, пакеты, тесты, потоки Контейнер раннера: --cpus=1 /sys/fs/cgroup/cpu.max = 100000 100000 → GOMAXPROCS = 2 NumCPU по-прежнему 4: рантайм берёт минимум из ядер и лимита, дробное вверх, ниже двух не опускается Уровень 1 — процессы. go test -p 2: два тестовых бинаря разом par1.test бежит par2.test бежит par3.test ждёт слота -p par4.test ждёт слота -p Уровень 2 — тесты внутри бинаря. -parallel 2, и только у тех, кто позвал t.Parallel() TestA бежит TestB бежит TestC на барьере TestD на барьере Уровень 3 — потоки рантайма в каждом процессе: GOMAXPROCS = 2 P0 P1 больше не будет: лимит cgroup один на весь контейнер Замер: 4 пакета x 4 теста x 300 мс по умолчанию 1.63 s -p 1 3.02 s -p 1 -parallel 1 5.50 s -p 4 -parallel 4 0.70 s тесты спят, а не считают, поэтому слотов нужно больше ядер Общая база на пакет ломается именно уровнем 1: -p умножает число одновременных подключений на число пакетов, и до -parallel дело не доходит. Отсюда -p 1 в интеграционной джобе, а не -parallel 1.
Три уровня параллельности. Лимит cgroup задаёт GOMAXPROCS, GOMAXPROCS задаёт умолчания -p и -parallel, а t.Parallel() решает, попадёт ли конкретный тест во второй уровень. Числа справа сняты в контейнере с одним ядром.

Шардирование и заранее собранные бинари

Когда на одной машине быстрее уже не сделать, прогон режут на несколько джобов. Go-сторона тут простая: список пакетов даёт go list, дальше его надо разложить. GitLab выдаёт джобе номер и общее число через CI_NODE_INDEX и CI_NODE_TOTAL (ключ parallel: — глава 4.2).

shard:
  parallel: 3
  script:
    - go list ./... | awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX - 1" > pkgs.txt
    - go test -count=1 -timeout=10m $(tr '\n' ' ' < pkgs.txt)

Работает, но делит по числу пакетов, а не по времени: на семи пакетах, где один спит две секунды, шарды вышли 2,41 / 0,40 / 0,42 с, и два джоба ждут третий. Чтобы раскладывать по времени, нужны длительности прошлого прогона; их отдаёт go test -json событием pass, а дальше хватает жадной раскладки: идём от самого долгого и кладём каждый в наименее загруженный шард.

$ go test -count=1 -json ./... \
    | jq -r 'select(.Action=="pass" and .Test==null) | "\(.Elapsed) \(.Package)"' \
    | sort -rn | tee times.txt
2.262 shop/internal/store
0.328 shop/internal/rules
0.263 shop/internal/report
0.249 shop/internal/auth
0.248 shop/internal/price
0.247 shop/internal/mail
0.197 shop/internal/discount
$ awk -v n=3 '{best=1; for(i=2;i<=n;i++) if(load[i]<load[best]) best=i;
               load[best]+=$1; pkg[best]=pkg[best]" "$2}
              END{for(i=1;i<=n;i++) printf "шард %d (%.2f s):%s\n", i, load[i], pkg[i]}' times.txt
шард 1 (2.26 s): shop/internal/store
шард 2 (0.77 s): shop/internal/rules shop/internal/price shop/internal/discount
шард 3 (0.76 s): shop/internal/report shop/internal/auth shop/internal/mail

Потолок при этом один: быстрее самого долгого пакета шардирование не сделает. Если пакет идёт двадцать минут, чинить надо пакет, а не пайплайн.

Бинари можно собрать заранее, одной джобой на весь прогон. С Go 1.21 go test -c берёт несколько пакетов сразу, а -o указывает на каталог, и тогда на каждый пакет получается свой pkg.test: после go test -c -o ./testbin/ ./... в каталоге лежат auth.test, mail.test, price.test и так далее. Джоба сборки складывает каталог в артефакт, джобы прогона его забирают и ничего не компилируют. Выигрыш появляется, только когда компиляция занимает заметную долю прогона; при исправно переносимом GOCACHE разница тонет в шуме. И помни про рабочую директорию: testdata бинарь ищет от текущего каталога, а не от того, где лежит сам.

Мигающие тесты: поиск, карантин, цена перезапуска

Мигающий тест дорог не минутами. Он ломает единственное, ради чего пайплайн и существует: связь «красный — значит я что-то сломал». Через месяц жизни с двумя-тремя флаками команда жмёт «перезапустить» не читая, и настоящая поломка проезжает вместе с ними. Ищут тремя рычагами. Порядок: -shuffle=on перемешивает верхнеуровневые тесты внутри бинаря и печатает seed, которым порядок воспроизводится. Классика жанра — пакет с общим registry, где TestGet опирается на то, что TestPut отработал раньше:

$ go test ./order/
ok  	flaky/order	0.255s
$ go test -count=1 -shuffle=on ./order/
-test.shuffle 1789939733488646000
--- FAIL: TestGet (0.00s)
    store_test.go:12: rate = 0, ok = false
FAIL
FAIL	flaky/order	0.184s
FAIL
$ go test -count=1 -shuffle=1789939733488646000 ./order/     # тот же порядок
-test.shuffle 1789939733488646000
--- FAIL: TestGet (0.00s)
    store_test.go:12: rate = 0, ok = false
FAIL
FAIL	flaky/order	0.198s
FAIL

Перемешиваются только верхнеуровневые тесты одного бинаря. Сабтесты идут в порядке регистрации, так что зависимость между строками таблицы -shuffle не поймает; между пакетами тоже ничего не перемешивается, это разные процессы. Seed печатается вместе с остальным выводом пакета, так что на зелёном прогоне без -v ты его не увидишь.

Повторы: -count=N гоняет один и тот же бинарь N раз подряд и отличает «упало однажды» от «падает раз в двадцать». Нагрузка: чаще всего в бэкенде мигает тест, который ждёт фоновую работу по часам, вроде time.Sleep(20 * time.Millisecond) после w.Submit(...). Воркер считает около десяти миллисекунд, запас двукратный, и на свободной машине тест железно зелёный: go test -count=200 ./timing/ даёт ok flaky/timing 4.599s. Теперь то же самое в контейнере с --cpus=1, рядом с которым крутятся восемь занятых циклов — грубая, но честная имитация раннера с соседями:

$ for i in 1 2 3; do go test -count=100 ./timing/ 2>&1 | grep -c "FAIL: TestWorker"; done
7
7
14
$ go test -count=20 ./timing/
--- FAIL: TestWorker (0.08s)
    worker_test.go:15: обработано 0 задач, хотим 1
FAIL
FAIL	flaky/timing	1.294s
FAIL

Точного числа тут нет. Девять замеров подряд дали семь падений на сотню в лучшем заходе и шестнадцать в худшем, следующая девятка начиналась с двойки, а без нагрузки не упало ни разу за двести прогонов. Тест не «иногда ломается», он меряет загрузку чужой машины. Чинится это не увеличенным Sleep, а синхронизацией: канал, sync.WaitGroup, ожидание с явным дедлайном (приёмы разобраны в главе 5.2).

Найденный флак редко чинится в тот же день. Пока он живой, у него два пути: остаться красным и приучить команду не смотреть на пайплайн либо уйти в карантин. Карантин без срока превращается в вечный t.Skip, а пропущенный тест зелёный, и никто о нём не вспомнит. Значит, срок ставим прямо в коде:

// Quarantine пропускает мигающий тест до даты until (формат 2006-01-02).
// После этой даты тест падает: карантин без срока превращается в вечный skip.
func Quarantine(t *testing.T, issue, until string) {
    t.Helper()
    deadline, err := time.Parse("2006-01-02", until)
    if err != nil {
        t.Fatalf("карантин %s: непонятная дата %q", issue, until)
    }
    if time.Now().After(deadline) {
        t.Fatalf("карантин %s истёк %s: почини тест или удали его", issue, until)
    }
    t.Skipf("карантин до %s, тикет %s", until, issue)
}
$ go test -count=1 -v ./quarantine/
=== RUN   TestPaymentRetry
    example_test.go:6: карантин до 2026-12-31, тикет ORD-1841
--- SKIP: TestPaymentRetry (0.00s)
=== RUN   TestLegacyImport
    example_test.go:11: карантин ORD-1102 истёк 2026-03-01: почини тест или удали его
--- FAIL: TestLegacyImport (0.00s)
FAIL
FAIL	flaky/quarantine	0.304s
FAIL

Тикет обязателен: без него через полгода никто не вспомнит, что за тест и почему он спит. Зовут Quarantine первой строкой, до любой подготовки, иначе карантинный тест продолжит поднимать контейнеры впустую.

Остаётся автоперезапуск. gotestsum --rerun-fails=3 на том самом воркере под нагрузкой сначала печатает DONE 1 tests, 1 failure, потом PASS timing.TestWorker (re-run 1), итог DONE 2 runs, 2 tests, 1 failure и код выхода 0. Пайплайн зелёный, а в rerun.xml осталось <testsuites tests="2" failures="1"> вместе с текстом падения. Тут и проходит граница между «меньшим злом» и вредом. Перезапуск терпим, когда его факт виден: попал в отчёт, посчитался, оказался в списке «топ-10 мигающих за неделю», по которому заводят тикеты. Перезапуск вреден, когда он прячет падение: retry: 2 на джобе гоняет её целиком и не оставляет следа о том, какой именно тест мигал.

Порядок действий с мигающим тестом
  • Воспроизвести: -count=20, -shuffle=on, прогон в контейнере под нагрузкой, -race отдельной джобой. Пока не воспроизводится — чинить нечего.
  • Не вышло за полчаса — карантин со сроком и тикетом, дальше по плану.
  • Чинить причину, а не симптом: увеличенный Sleep переносит падение на более загруженный раннер, а не убирает его.
  • После починки прогнать -count=50 под той же нагрузкой, на которой падало.
  • Мигает третий раз за квартал и не чинится — удалить. Тест, которому не верят, стоит времени и даёт ложное спокойствие.

Отчёты и покрытие

Простыня из go test в логе джобы читается плохо. Машиночитаемый вывод даёт -json, на нём стоят все репортеры. gotestsum читает этот поток, печатает компактную сводку и пишет JUnit-XML, по которому GitLab показывает в merge request новые падения отдельно от старых, сравнивая отчёт исходной ветки с отчётом целевой: так это описано в документации GitLab 19.4. Как отчёт подключается к джобе и как рядом живёт Cobertura для покрытия, разобрано в теме «CI/CD и деплой», глава 4.2.

В самом XML есть тонкость. Табличный тест с двумя строками, одна из которых сломана, приезжает как <testsuites tests="3" failures="2">: сабтесты и их родитель считаются отдельными случаями. Числа в отчёте MR поэтому не совпадают с числом написанных тестов, и сравнивать ветки имеет смысл по именам, а не по количеству. Вторая тонкость касается кэша: gotestsum всего лишь обёртка над go test, и закэшированный пакет приедет в отчёт со всеми своими PASS, хотя бинарь не запускался.

$ gotestsum --format testname -- ./price/
PASS price.TestTotal (0.00s)
PASS price (cached)

DONE 1 tests in 0.000s

Теперь покрытие. С -race оно считается только атомарным инкрементом, и другой режим отвергается сразу; с простым -cover режим подбирается сам, так что -covermode=atomic руками нужен, только когда профиль пишется из нескольких джобов и потом склеивается.

$ go test -race -covermode=count ./price/
-covermode must be "atomic", not "count", when -race is enabled
$ go test -race -cover ./price/
ok  	demo/price	1.206s	coverage: 100.0% of statements

Само покрытие разобрано в главе 5.1; в CI добавляется вопрос, что ставить воротами. Абсолютный процент на большом репозитории почти не двигается. Вот замер на модуле из 403 операторов, куда добавили функцию на шесть операторов и не написали к ней тестов:

$ go tool cover -func=before.out | tail -1
total:					(statements)	99.8%
$ go tool cover -func=after.out | tail -1
total:					(statements)	98.3%

Порог «не ниже 98» такое пропустит. А посчитай то же самое для репозитория в сорок тысяч операторов: те же шесть строк сдвинут число на сотые доли, ниже шума от любого порога. Покрытие диффа отвечает на другой вопрос: покрыто ли то, что принёс этот merge request. Считается оно из профиля и git diff; скрипт на awk умещается в экран — читает диф, запоминает номера добавленных строк и сверяет их с диапазонами из профиля.

$ git diff -U0 main...HEAD -- '*.go' > changed.diff
$ go test -count=1 -coverprofile=cover.out ./internal/discount/
ok  	shop/internal/discount	0.381s	coverage: 25.0% of statements
$ awk -v module=shop -f diffcover.awk changed.diff cover.out
покрытие диффа: 0 из 6 строк, 0.0%
Что отвечать про порог покрытия

«Абсолютный порог на большом репозитории не ловит ничего: шесть непокрытых строк двигают число на сотые доли, а удалённый мёртвый код его, наоборот, поднимает. Мы смотрим на покрытие диффа: новый код в merge request должен быть покрыт, и это видно сразу. Сам порог остаётся мягкой проверкой, а не блокирующей: как только за него начинают бороться, появляются тесты без единого ассерта, и метрика ломается за неделю.»

Красное только в CI

Самый неприятный отчёт: у тебя зелено, в пайплайне красно, на раннер не пускают. Разбирать надо от общего к частному, и первым шагом идёт не чтение кода, а повтор окружения.

$ docker run --rm -v "$PWD":/src -w /src golang:1.27.1-alpine go test -count=1 ./...

Один этот шаг закрывает большую часть случаев: почти все они про различия из таблицы в начале главы. Вот тест, который кладёт фикстуру Prod.yaml, а Load внутри приводит имя к нижнему регистру и читает prod.yaml:

$ go test -count=1 ./...             # macOS, APFS
ok  	onlyci/config	0.312s
$ docker run --rm -v "$PWD":/src -w /src golang:1.27.1-alpine go test ./...
--- FAIL: TestLoad (0.00s)
    config_test.go:17: Load: open /tmp/TestLoad683829207/001/prod.yaml: no such file or directory
FAIL
FAIL	onlyci/config	0.001s
FAIL

Тот же класс дают MSK против UTC в тестах на границы суток, локали при сортировке строк и порядок файлов в каталоге. Все ловятся одним прогоном в контейнере.

Вторым по частоте идут разные тулчейны. Подняли строку go в go.mod, локально собралось, в CI нет: умолчания различаются. Установленный Go идёт с GOTOOLCHAIN=auto и докачивает нужную версию, официальный образ ставит local и отказывается.

$ go env GOTOOLCHAIN                                  # на ноутбуке
auto
$ docker run --rm golang:1.27.1-alpine go env GOTOOLCHAIN
local
$ docker run --rm -v "$PWD":/src -w /src golang:1.27.1-alpine go test ./...
go: go.mod requires go >= 1.28.0 (running go 1.27.1; GOTOOLCHAIN=local)

Лечится синхронной сменой образа либо явным GOTOOLCHAIN в переменных пайплайна; второе надёжнее, версия компилятора задана в одном месте.

Остались таймауты. Когда тест завис, go test по своему -timeout печатает список выполняющихся тестов и полный дамп горутин.

$ go test -count=1 -timeout=3s ./hang/
panic: test timed out after 3s
	running tests:
		TestWaitsForever (3s)
…
flaky/hang.TestWaitsForever(0x703e3c332248?)
	/src/hang/hang_test.go:7 +0x30
testing.tRunner(0x703e3c332248, 0x2bef30)
	/usr/local/go/src/testing/testing.go:2193 +0xc4
created by testing.(*T).Run in goroutine 1
	/usr/local/go/src/testing/testing.go:2258 +0x3b8

Но только если -timeout успеет раньше лимита джобы. Иначе на месте дампа окажется Terminated с кодом выхода 143, и о том, что висело, не останется ни строчки. По умолчанию у go test десять минут: ставь -timeout явно и заведомо меньше лимита.

Вопросы

5
Суть: ключ состоит из хеша тестового бинаря с кэшируемыми флагами и хеша того, что тест потрогал во время прогона: переменных окружения и файлов внутри модуля. Сеть, файлы вне модуля и часы в ключ не входят, поэтому там (cached) означает «не выполнялся», а не «прошёл».

Две половины ключа

Первая считается до прогона: содержимое пакета и всех зависимостей, флаги сборки (-race, -tags) и флаги командной строки из списка кэшируемых — в Go 1.27.1 это -benchtime, -coverprofile, -cpu, -failfast, -fullpath, -list, -outputdir, -parallel, -run, -short, -skip, -timeout, -v. Любой флаг вне списка выключает кэш, на этом и держится идиома -count=1.

Вторая считается после прогона по test log: бинарь через testing/internal/testdeps записывает каждый getenv, open, stat и chdir. Дальше go берёт текущие значения переменных и stat файлов — размер, права, mtime, не содержимое. GODEBUG добавляется всегда, его читает рантайм мимо лога.

Где ключ дырявый

Сетевых соединений в логе нет вовсе: сервис начал отдавать другое, а go test всё равно печатает ok (cached). Файлы вне корня модуля (и вне GOPATH, GOROOT) в лог попадают, но go сознательно их не перепроверяет. Часы никого не интересуют. Отсюда практика: -count=1 в джобе с интеграционными тестами, а не в GOFLAGS на весь пайплайн — чистые юнит-тесты кэш отслеживает честно.

Два побочных эффекта, о которых спрашивают следом

Кэшируются только успехи, провал всегда перезапускается. И GOCACHE хранит результаты тестов вместе с объектными файлами, так что перенос каталога между джобами тянет за собой все (cached). Отладить решение помогает GODEBUG=gocachetest=1: он печатает обе половины ключа и строку file used as input is too new, если прочитанный файл моложе двух секунд — тогда результат в кэш не кладётся вовсе.

Суть: сначала померить, где уходит время, потом разделить прогон на быстрый и ночной, потом разобраться с параллельностью и только в последнюю очередь резать по джобам. Шардирование не ускорит прогон быстрее самого долгого пакета.

Померить и разделить

go test -json отдаёт длительность каждого пакета событием pass, и распределение обычно оказывается очень неровным. Дальше надо отделить компиляцию от самих тестов: если GOCACHE не переносится между джобами, половина времени уходит на сборку, и лечится это настройкой кэша раннера.

На merge request остаётся то, что укладывается в пару минут: go test -short плюс go vet -tags=integration, чтобы файлы под тегом продолжали компилироваться. Контейнеры и повторы на поиск мигающих уезжают в ночной пайплайн.

Параллельность

Три уровня и три флага: -p считает пакеты (процессы), -parallel — тесты внутри бинаря, а t.Parallel() записывает тест в этот второй уровень. Оба флага по умолчанию равны GOMAXPROCS, а он с Go 1.25 берётся из лимита cgroup. Замер на четырёх пакетах по четыре теста в контейнере с --cpus=1:

go test ./par...                     1.63 s
go test -p 1 ./par...                3.02 s
go test -p 1 -parallel 1 ./par...    5.50 s
go test -p 4 -parallel 4 ./par...    0.70 s

Замер снят на тестах, которые ждут: им слотов нужно больше числа ядер. Счётные от лишних слотов только замедляются. А на общей базе опасен именно -p: он умножает число одновременных подключений на число пакетов, и до -parallel дело не доходит.

Шардирование

Резать по джобам стоит, когда на одной машине выжато всё. Деление по NR % CI_NODE_TOTAL простое, но раскладывает по числу пакетов, а не по времени: на семи пакетах, где один идёт две секунды, шарды вышли 2,41 / 0,40 / 0,42 с. Честнее раскладывать жадно, по длительностям прошлого прогона. Потолок всё равно один: самый долгий пакет.

Суть: сначала добиться воспроизведения — повторами, перемешиванием порядка и прогоном в контейнере под нагрузкой. Пока не воспроизводится, тест уходит в карантин со сроком и тикетом, а не остаётся красным и не удаляется молча.

Воспроизвести

Повторы (-count=20) отличают «упало однажды» от «падает раз в двадцать». -shuffle=on ловит зависимость через общее состояние пакета и печатает seed, которым повторяется ровно тот же порядок; перемешиваются при этом только верхнеуровневые тесты одного бинаря, сабтесты идут в порядке регистрации. Нагрузку даёт контейнер с --cpus=1 и занятые рядом с ним процессы.

На тесте, который ждёт воркера через time.Sleep(20ms) при работе на десять миллисекунд, разница убедительная: двести прогонов на свободной машине дают ноль падений, а сто прогонов в контейнере с одним ядром и восемью занятыми циклами роняют его то дважды, то шестнадцать раз; доля плывёт от захода к заходу, точного числа тут не бывает. Плюс -race отдельной джобой: гонка часто и есть причина, а детектор ловит только реально выполнившееся, поэтому его гоняют с -count и на разных -cpu.

Пока не починен

Карантин со сроком в коде, а не закомментированный тест и не t.Skip без даты. После срока хелпер падает сам, и карантин не превращается в вечный Skip:

    example_test.go:6: карантин до 2026-12-31, тикет ORD-1841
--- SKIP: TestPaymentRetry (0.00s)
    example_test.go:11: карантин ORD-1102 истёк 2026-03-01: почини тест или удали его
--- FAIL: TestLegacyImport (0.00s)
Как отвечать про автоматический перезапуск

«Перезапуск джобы целиком прячет флак без следа, и через месяц красный пайплайн перестают читать. Перезапуск отдельного теста через gotestsum --rerun-fails терпим при одном условии: факт перезапуска остаётся в отчёте. У нас он остаётся: код выхода нулевой, а в JUnit-XML лежит failures="1" с текстом падения, и по этим отчётам раз в неделю собирается список самых мигающих, на которые заводят тикеты.»

Суть: абсолютный порог на большом репозитории почти не двигается и ловит не то; полезнее покрытие диффа: покрыт ли код, который принёс этот merge request. Жёсткий порог любого вида за пару недель порождает тесты без ассертов.

Почему абсолютное число не работает воротами

Замер на модуле из 403 операторов, куда добавили функцию на шесть операторов и не написали к ней тестов: go tool cover -func показал 99,8 % до и 98,3 % после. Порог «не ниже 98» это пропустил, а на репозитории в сорок тысяч операторов те же шесть строк сдвинут число на сотые доли. Обратная сторона: удалённый мёртвый код процент поднимает, и порог радуется рефакторингу, который тестов не добавил.

Что ставить вместо

Покрытие диффа считается из профиля и git diff -U0: берём строки, добавленные в этой ветке, и смотрим, сколько из них исполнялось.

$ git diff -U0 main...HEAD -- '*.go' > changed.diff
$ go test -count=1 -coverprofile=cover.out ./internal/discount/
ok  	shop/internal/discount	0.381s	coverage: 25.0% of statements
$ awk -v module=shop -f diffcover.awk changed.diff cover.out
покрытие диффа: 0 из 6 строк, 0.0%

Число понятное, и действие тоже: либо напиши тест на то, что добавил, либо объясни в обсуждении, почему не надо. Рядом держат мягкое правило «абсолютный процент не падает».

Мелочи, на которых спотыкаются

  • С -race режим подсчёта обязан быть атомарным: go test -race -covermode=count отвергается с -covermode must be "atomic", not "count", when -race is enabled. Простой -cover выбирает режим сам.
  • Профиль лежит в кэше рядом с выводом, поэтому на закэшированном пакете файл восстановится и цифра не изменится.
  • Сгенерированный код, моки, DTO и main.go из подсчёта исключают, иначе метрика меряет объём генератора.
Суть: не читать код, а воспроизводить окружение по слоям — тот же образ, те же лимиты, те же флаги. Почти все такие падения объясняются различиями машины: регистр в именах файлов, часовой пояс, число ядер, версия тулчейна.

Слой 1: тот же образ

$ docker run --rm -v "$PWD":/src -w /src golang:1.27.1-alpine go test -count=1 ./...

Ловит целый класс сразу. Тест кладёт фикстуру Prod.yaml, код читает prod.yaml: на APFS это один файл, на ext4 — два, и в контейнере тот же прогон даёт config_test.go:17: Load: open …/001/prod.yaml: no such file or directory. Сюда же часовой пояс (MSK+0300 против UTC+0000) и разные умолчания тулчейна: на ноутбуке GOTOOLCHAIN=auto докачивает нужную версию, в официальном образе стоит local, и после подъёма строки go в go.mod джоба падает с go.mod requires go >= 1.28.0 (running go 1.27.1; GOTOOLCHAIN=local).

Слой 2: те же ограничения

Если не повторилось, добавляем то, чего нет на ноутбуке: --cpus=1 (а с ним GOMAXPROCS=2 и другие умолчания -p и -parallel), соседнюю нагрузку, -count=20, -shuffle=on, -race. Тест, который ждёт фон через Sleep, проявится именно здесь.

Слой 3: то, что видно только из джобы

Остаются переменные окружения, секреты, DNS между контейнерами, права на каталог. Снаружи их не воспроизвести, значит нужны артефакты: go test -json в файл, логи зависимостей, дампы. Файлы из теста складывают в t.ArtifactDir() (Go 1.26, разобран в главе 5.1). А -v включай точечно, вторым прогоном по упавшему пакету: на всём репозитории лог джобы обрежется раньше, чем дойдёт до интересного.

Дамп горутин приезжает не всегда

Когда тест висит, go test по своему -timeout печатает список выполняющихся тестов и полный дамп горутин, ровно то, что нужно для разбора. Но если лимит джобы короче, процесс прибьют раньше, и в логе останется только Terminated с кодом 143. У go test таймаут по умолчанию десять минут: ставь его явно и заведомо меньше лимита джобы.

Что стоит запомнить

  • CI cache key должен учитывать входы, влияющие на результат.
  • Параллельность тестов и процессов складывается.
  • Flaky test требует локализации и явного статуса.

Частые ошибки

  • Повторный прогон не превращает нестабильность в надёжность.
  • Не кэшируй внешнее состояние как постоянную истину.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Разбери проверку

Оба job не задают stage. В какую stage GitLab их поместит и при каком условии runner запустит их одновременно?

test:
  script:
    - go test ./...
lint:
  script:
    - golangci-lint run
Ответ и разбор

Обе jobs попадают в default stage test. Jobs этой stage могут выполняться параллельно, если есть свободные runner slots и их не сериализуют явные настройки. needs задаёт зависимости в графе выполнения; resource_group может сериализовать jobs. GitLab dependencies: управляет скачиванием artifacts и само по себе порядок выполнения не меняет. Параллельность процессов внутри одной job от этого тоже не становится ограниченной одним ядром.

Код-ревью

2. Найди дефект и исправь

Build cache не переиспользуется между ветками. Как изменить key/fallback, чтобы учитывать Go version и зависимости и при этом переиспользовать безопасные результаты?

cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .cache/go-build/
Ответ и разбор

Ключ кэша меняется для каждой ветки, поэтому одинаковые зависимости и Go build cache не переиспользуются между ветками. Сначала проверь, что GOCACHE действительно указывает на этот path. Если shared cache политики позволяют, задай основной key по версии Go и go.sum, а branch key оставь fallback. Не включай mutable результаты теста как источник истины.

Применение

3. Реши сценарий

Локально тесты проходят, CI падает только под --cpus=1. Как отличить реальную гонку от неустойчивой временной зависимости?

Ответ и разбор

Повтори тесты при ограниченных CPU с -race и многократным запуском, найди использование глобального состояния, Sleep и внешнего времени. Затем синхронизируй события явными каналами/locks или подмени часы на контролируемые. Повышение timeout допустимо только если он отражает реальный контракт, а не маскирует зависимость от расписания.

Применение

4. Проверь альтернативу

У MR pipeline мало времени. Что оставить в быстрой проверке, а что перенести в ночной job без потери быстрого сигнала о контрактном риске?

Ответ и разбор

В MR оставь форматирование/lint, build, unit tests и быстрые contract/integration проверки критичных границ. Ночной прогон расширь race/repetition, fuzzing, полный интеграционный набор и долгие benchmarks. Результаты ночного прогона должны быть видимы и flaky tests не должны молча пропускаться.