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+0300 | UTC+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
красное». Часы туда же: тест «до конца месяца» первого числа возьмётся из кэша.
Юнит-тесты, которые читают только свой пакет и свои testdata, кэш отслеживает
честно, и -count=1 там просто выбрасывает выигрыш. А всё, что смотрит наружу
процесса — база в контейнере, брокер, чужой HTTP, файл вне модуля, — обязано идти с
-count=1. Ставь флаг в конкретной джобе, а не в GOFLAGS на весь
пайплайн. И помни, что GOCACHE хранит результаты тестов вместе с объектными
файлами: перенёс каталог между джобами ради быстрой компиляции — перенёс и все
(cached).
(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
--cpus | 0.5 | 1 | 1.5 | 2 | 2.5 | 3.4 |
|---|---|---|---|---|---|---|
GOMAXPROCS | 2 | 2 | 2 | 2 | 3 | 4 |
runtime.NumCPU() при этом честно показывает четыре: он отвечает на вопрос «сколько
ядер видно», а не «сколько разрешено съесть». Периодическое обновление при смене лимита
выключается через GODEBUG=updatemaxprocs=0, а явный GOMAXPROCS в
окружении перебивает и то, и другое.
В Kubernetes лимиту cgroup соответствует resources.limits.cpu. Поле
requests.cpu рантайм не видит вовсе. Под без limits.cpu получает
GOMAXPROCS по числу ядер узла: на узле с 64 ядрами это 64 планировщиковых потока у
пода, которому выделили полядра. Так же ведёт себя и раннер CI в Kubernetes без
limits.cpu. До Go 1.25 рантайм на cgroup не смотрел вовсе — оттуда и пошла привычка
ставить GOMAXPROCS руками или тянуть uber-go/automaxprocs.
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" с текстом падения, и по этим отчётам раз в
неделю собирается список самых мигающих, на которые заводят тикеты.»
Почему абсолютное число не работает воротами
Замер на модуле из 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 не должны молча пропускаться.