5.1Юнит-тесты
Проследим путь от исходника теста до запуска и разберём параллельность, cleanup и ограничения coverage.
Что происходит, когда ты набираешь go test
go test и компилирует, и генерирует код. Он собирает три группы файлов в один
временный бинарь и запускает его:
- Сам пакет -
order.go,package order. - Внутренние тесты лежат в
order_test.goсpackage order. Видят неэкспортируемые идентификаторы, могут тестировать приватную функцию напрямую. - Внешние тесты объявлены в отдельном файле, например
order_ext_test.go, какpackage order_test. Единственный случай, когда в одной директории вместе собираются два имени пакета. Видят только публичный API, зато могут импортировать пакеты, которые импортируютorder, и циклов не возникает. Так тестируетсяnet/httpизнутри стандартной библиотеки.
Дальше go test генерирует файл _testmain.go: в нём func main(),
списки найденных TestXxx, BenchmarkXxx, FuzzXxx,
ExampleXxx и вызов testing.MainStart, а за ним m.Run() или твой
TestMain. Никакой рефлексии в рантайме,
никакого сканирования - список тестов фиксируется на этапе сборки, поэтому «динамически
зарегистрировать тест» в Go можно только сабтестом через t.Run.
Имя теста ищется по префиксу: после Test должна идти не строчная буква.
func Testfoo не запустится. Раньше это случалось молча - классическая потеря часа
жизни; с Go 1.24 go test сам гоняет vet-анализатор tests, и он такое имя
ловит. Тест
запускается из директории своего пакета, поэтому относительные пути вида testdata/x.json
всегда работают.
tRunner
с предустановленным defer. Отсюда два следствия: Cleanup переживает
t.Fatal, а вызывать t.Fatal из чужой горутины нельзя - Goexit
убьёт её, а не тест.Анатомия: t.Run, t.Helper, t.Cleanup и компания
func TestOrderService(t *testing.T) {
// 1. Изолируем окружение: после теста значение восстановится само.
t.Setenv("APP_ENV", "test") // паникует, если тест параллельный
dir := t.TempDir() // своя директория, удалится сама
ctx := t.Context() // Go 1.24: отменяется перед Cleanup
db := openDB(t, dir)
t.Cleanup(func() { db.Close() }) // не defer: разница видна на параллельных сабтестах
// 2. Сабтест: своё имя, свой *testing.T, своя горутина.
t.Run("создание заказа", func(t *testing.T) {
got, err := New(db).Create(ctx, "user-1")
requireNoErr(t, err) // хелпер, см. ниже
if got.Status != "new" {
t.Errorf("status = %q, хотим %q", got.Status, "new")
}
})
t.Run("пустой пользователь", func(t *testing.T) {
_, err := New(db).Create(ctx, "")
if !errors.Is(err, ErrNoUser) {
t.Fatalf("err = %v, хотим ErrNoUser", err)
}
})
}
// С t.Helper() строка ошибки укажет на вызывающего, а не на эту функцию.
func requireNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
// $ go test -v
// === RUN TestOrderService
// === RUN TestOrderService/создание_заказа
// === RUN TestOrderService/пустой_пользователь
// --- PASS: TestOrderService (0.00s)
// --- PASS: TestOrderService/создание_заказа (0.00s)
// --- PASS: TestOrderService/пустой_пользователь (0.00s)
// PASS
// ok order 0.273s
//
// Сабтест печатается отдельной строкой «=== RUN» с именем через слэш;
// пробелы в имени заменяются на подчёркивания. Время у каждого своё,
// а последнее число меняется от запуска к запуску.
| Метод | Что делает на самом деле | Подвох |
|---|---|---|
t.Run(name, f) |
Запускает f в новой горутине и блокируется до её завершения |
Если f вызовет t.Parallel() - Run вернётся сразу, не дожидаясь |
t.Helper() |
Помечает функцию как хелпер: при выводе ошибки её кадр пропускается | Пометка живёт на *testing.T, а не на горутине: работает и когда хелпер зовут из побочной горутины. Ставить принято первой строкой, главное - раньше, чем хелпер сообщит об ошибке |
t.Cleanup(f) |
Кладёт f в стек, который разматывается LIFO после теста и всех его сабтестов |
Это не defer: ждёт параллельных детей |
t.TempDir() |
Новая уникальная директория, удаляется в Cleanup | Каждый вызов - новая директория, а не одна и та же |
t.Setenv(k,v) |
Ставит переменную окружения и восстанавливает прежнюю в Cleanup | Паникует в паре с t.Parallel() - окружение процессное, общее |
t.Chdir(dir) |
Go 1.24: меняет рабочую директорию с возвратом | Так же несовместим с t.Parallel() |
t.Context() |
Go 1.24: контекст, отменяемый прямо перед запуском Cleanup | Не заменяет context.WithTimeout для проверки таймаутов |
t.Attr(k,v) |
Go 1.25: печатает в лог теста пару ключ-значение как атрибут - машиночитаемую метку
теста (номер тикета, окружение, версия схемы). Видна в go test -json |
Ключ без пробелов, значение без переводов строки. Смысл ключей не задан - его придумывает твоя CI-система |
t.Output() |
Go 1.25: io.Writer, пишущий в тот же поток, что и t.Log,
но без файла-строки и без автоматического перевода строки |
Ровно то, чего не хватало, чтобы отдать *testing.T в
slog.New(slog.NewTextHandler(t.Output(), nil)) - раньше приходилось писать
переходник |
t.ArtifactDir() |
Go 1.26: директория, куда тест складывает файлы-артефакты - дампы, скриншоты, HAR-логи, тела ответов | Без флага -artifacts это временная директория, которую удалят после теста.
С флагом - сохранится |
t.Error / t.Fatal |
Error - пометить провал и продолжить; Fatal - пометить и runtime.Goexit() |
Fatal из побочной горутины убьёт только её: тест помечен проваленным, но не остановлен и может зависнуть |
t.Log |
Копит вывод в буфер теста | Видно только с -v (тогда печатается сразу, мимо буфера) либо если тест упал |
t.Skip |
Goexit с пометкой SKIP |
Пропущенный тест зелёный в CI - «скипнул и забыл» живёт годами |
t.ArtifactDir() и флаг -artifacts для дампов, которые CI должен сохранить
Артефактом может быть дамп упавшего ответа, скриншот из браузерного теста, HAR-лог, профиль, сгенерированный
отчёт. До 1.26 канонического места для них не было - писали в t.TempDir()
(удаляется сразу) или руками в /tmp (не подбирается CI и никогда не убирается).
func TestRenderPDF(t *testing.T) {
got, err := Render(fixtureInvoice(t))
if err != nil { t.Fatal(err) }
if !bytes.Equal(got, want) {
// Кладём фактический результат рядом: в CI его можно скачать и открыть.
path := filepath.Join(t.ArtifactDir(), "actual.pdf")
if err := os.WriteFile(path, got, 0o644); err != nil { t.Fatal(err) }
t.Fatalf("рендер разошёлся с эталоном, факт сохранён в %s", path)
}
}
$ go test ./... # ArtifactDir = временная папка, удалится
$ mkdir -p ./out
$ go test -artifacts -outputdir=./out ./... # файлы останутся в ./out/_artifacts/
Три детали, на которых спотыкаются. Первая: без флага -artifacts метод всё
равно работает - просто отдаёт временную директорию, которую удалят после теста. Поэтому код теста
один для обоих режимов, а сохранять ли файлы, решает флаг.
Вторая: директория из -outputdir должна существовать заранее, иначе
прогон падает с mkdir ... no such file or directory. Третья: у каждого теста
и каждого сабтеста своя уникальная директория, и директория сабтеста лежит не внутри
родительской, а рядом с ней. В -json путь приезжает отдельным событием
{"Action":"artifacts","Test":"...","Path":"..."}, по нему CI-система и собирает файлы.
t.Attr и t.Output - две мелочи, которые упрощают жизнь в CI
t.Attr(key, value)вешает на тест машиночитаемую метку. В выводе с-vэто строка=== ATTR TestFoo issue PROJ-123, а вgo test -jsonприходит отдельное событие{"Action":"attr","Key":"issue","Value":"PROJ-123"}. Смысл ключей стандарт не задаёт, их придумывает твоя CI-система или тест-репортер - номер тикета, имя окружения, версия схемы БД, владелец теста. Раньше метки гоняли черезt.Logи регулярку, теперь есть штатный канал.t.Output()возвращаетio.Writerв тот же поток, что иt.Log, но без префикса «файл:строка» и без автоматического перевода строки. С ним логгер приложения можно отдать прямо в тест.slog.New(slog.NewTextHandler(t.Output(), nil))- и логи сервиса едут в вывод того теста, который их вызвал, а не в общийstderr, где при-parallelвсё перемешано.
t.Fatal, t.FailNow, require.* из testify - всё это в итоге
зовёт runtime.Goexit(). Вызванные внутри go func(){ ... }(), они убивают
эту горутину, но не тест: провал запишется, а тело теста пойдёт дальше. В лучшем случае тест
покраснеет, доработав до конца на заведомо плохих данных, в худшем -
висит до -timeout (10 минут по умолчанию), потому что кто-то ждал результат из убитой
горутины. Правило: из побочных горутин можно только t.Error/t.Log,
а лучше - вернуть ошибку в канал и упасть уже в горутине теста.
TestMain: единственная точка глобального setup
var testDB *sql.DB
func TestMain(m *testing.M) {
// flag.Parse() вызывать не нужно, m.Run() сделает это сам,
// но свои флаги надо объявить до m.Run().
ctx := context.Background()
ctr, dsn := startPostgres(ctx) // контейнер поднимается один раз на пакет
testDB = mustOpen(dsn)
mustMigrate(testDB)
code := m.Run() // здесь бегут все тесты пакета
testDB.Close()
ctr.Terminate(ctx) // os.Exit не выполнит defer, убираем руками
os.Exit(code)
}
Если TestMain объявлен, тестовый бинарь вызывает его вместо прямого запуска тестов.
С Go 1.15 os.Exit(code) необязателен: если TestMain просто вернётся,
обёртка сама передаст результат m.Run() в os.Exit. Но привычка писать
os.Exit живёт, и вместе с ней живёт баг: os.Exit не выполняет
defer. Контейнер остаётся висеть, файлы не удаляются.
TestMain заводит общее на весь пакет состояние, которое связывает тесты между
собой и с порядком запуска. Оставляй в нём только то, что действительно дорого
поднимать на каждый тест: контейнер с БД, миграции, goleak.VerifyTestMain(m). Остальное
уходит в фабрики-хелперы с t.Cleanup. И проверяй пакет флагом -shuffle=on:
если тесты падают при перемешивании, значит они завязаны на порядок.
Табличные тесты: как их пишут, чтобы они не сгнили
Табличный тест - это один слайс структур-случаев (та самая «таблица») и один цикл, который
прогоняет каждую строку через одну и ту же проверку. Новый случай добавляется строкой в
таблице, копировать функцию не нужно, а сам тест читается как список требований к коду.
Держится он на разделении данных и проверки, и отсюда главное правило: тело
цикла должно быть единственным путём исполнения. Если внутри появились if tc.kind == ...,
таблица распалась на два разных теста и её пора разделить.
// --- код под тестом -------------------------------------------------
var (
ErrLoginEmpty = errors.New("login is empty")
ErrLoginTooLong = errors.New("login is too long")
ErrBadEmail = errors.New("email is invalid")
ErrWeakPassword = errors.New("password is too weak")
)
type Signup struct {
Login, Email, Password string
}
func Validate(s Signup) error {
switch {
case s.Login == "":
return ErrLoginEmpty
case utf8.RuneCountInString(s.Login) > 32:
return fmt.Errorf("%w: %d рун", ErrLoginTooLong, utf8.RuneCountInString(s.Login))
case !strings.Contains(s.Email, "@"):
return ErrBadEmail
case len(s.Password) < 8:
return ErrWeakPassword
}
return nil
}
// --- тест -----------------------------------------------------------
func TestValidate(t *testing.T) {
valid := Signup{Login: "gopher", Email: "g@example.com", Password: "hunter22"}
// Хелпер-мутатор копирует валидный объект и вносит одну правку,
// так негативный кейс отличается от позитивного ровно одним полем.
with := func(f func(*Signup)) Signup { s := valid; f(&s); return s }
tests := []struct {
name string
in Signup
want error // nil = ожидаем успех
}{
{"валидный", valid, nil},
{"логин пустой", with(func(s *Signup) { s.Login = "" }), ErrLoginEmpty},
{"логин ровно 32 руны", with(func(s *Signup) { s.Login = strings.Repeat("я", 32) }), nil},
{"логин 33 руны", with(func(s *Signup) { s.Login = strings.Repeat("я", 33) }), ErrLoginTooLong},
{"email без собаки", with(func(s *Signup) { s.Email = "gexample.com" }), ErrBadEmail},
{"пароль 7 символов", with(func(s *Signup) { s.Password = "1234567" }), ErrWeakPassword},
{"пароль ровно 8", with(func(s *Signup) { s.Password = "12345678" }), nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := Validate(tc.in)
// errors.Is, а не err == tc.want и не сравнение строк:
// ErrLoginTooLong обёрнут через %w и содержит количество рун.
if !errors.Is(err, tc.want) {
t.Fatalf("Validate() = %v, хотим %v", err, tc.want)
}
})
}
}
- Границы, а не «пример из середины»: 32 и 33 руны, 7 и 8 символов. Баги живут на границах.
- Руны, а не байты:
len("яяя") == 6. Кейс с кириллицей ловит эту ошибку. - Одно отличие от валидного объекта: негативный кейс проверяет ровно одну причину отказа, а не «всё сломано сразу».
errors.Isвместо==: код может обернуть ошибку контекстом, и тест от этого падать не должен. Сравнениеerr.Error() == "login is empty"сочтут красным флагом.- Имена кейсов по-человечески: они попадают в вывод и в
-run(пробелы превращаются в подчёркивания:-run 'TestValidate/логин_33_руны').
Когда сравниваются структуры, а не ошибки, reflect.DeepEqual даёт бесполезное
«not equal». Стандартом де-факто стал github.com/google/go-cmp: он печатает diff
и умеет игнорировать поля.
if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(Order{}, "CreatedAt", "ID")); diff != "" {
t.Errorf("Order mismatch (-want +got):\n%s", diff)
}
t.Parallel: что происходит на самом деле
t.Parallel() ничего не отправляет в фон, он означает «поставь меня на паузу
и разбуди вместе с остальными». Внутри метод делает две вещи: сигналит родителю, что тот может продолжать,
и блокируется на канале-барьере родителя. Родительский t.Run возвращает управление
немедленно, цикл идёт дальше, и так все сабтесты копятся на барьере. Барьер закрывается только
тогда, когда тело родительской функции полностью вернулось.
defer и t.Cleanup: первый закрывает ресурс до того, как параллельные
сабтесты им воспользуются, второй ждёт их завершения.// Плохо: база закроется в t1
func TestRepo(t *testing.T) {
db := openDB(t)
defer db.Close()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// sql: database is closed
_ = db.PingContext(t.Context())
})
}
}
// Хорошо: Cleanup дождётся детей (t3)
func TestRepo(t *testing.T) {
db := openDB(t)
t.Cleanup(func() { db.Close() })
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// соединение живо
_ = db.PingContext(t.Context())
})
}
}
Что выведет код?
Модуль с go 1.21 в go.mod, запуск go test -v:
func TestSum(t *testing.T) {
cases := []struct {
name string
a, b, want int
}{
{"два плюс два", 2, 2, 4},
{"два плюс три", 2, 3, 6}, // намеренно сломанный кейс
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.a + tc.b; got != tc.want {
t.Errorf("%s: %d, хотим %d", tc.name, got, tc.want)
}
})
}
}
До Go 1.22 переменная цикла tc заводится одна на весь цикл. Имя сабтеста
вычисляется в момент вызова t.Run, поэтому имена два_плюс_два и
два_плюс_три корректные. А тела запускаются после завершения цикла (момент t2
на схеме), когда tc уже указывает на последний элемент. Вывод:
$ go test -v
=== RUN TestSum
=== RUN TestSum/два_плюс_два
=== PAUSE TestSum/два_плюс_два
=== RUN TestSum/два_плюс_три
=== PAUSE TestSum/два_плюс_три
=== CONT TestSum/два_плюс_два
=== CONT TestSum/два_плюс_три
=== NAME TestSum/два_плюс_два
sum_test.go:17: два плюс три: 5, хотим 6
=== NAME TestSum/два_плюс_три
sum_test.go:17: два плюс три: 5, хотим 6
--- FAIL: TestSum (0.00s)
--- FAIL: TestSum/два_плюс_два (0.00s)
--- FAIL: TestSum/два_плюс_три (0.00s)
FAIL
exit status 1
FAIL sum 0.348s
Под -v текст t.Errorf печатается не внутри блока
--- FAIL, а отдельно, под маркером === NAME: параллельные
сабтесты пишут в общий поток вперемешку, и === NAME помечает, чья это строка. Порядок
строк от запуска к запуску разный: если ошибка идёт сразу за строкой «=== CONT» того же
сабтеста, метки «=== NAME» перед ней нет. Без -v вывод короче и текст ошибки вложен прямо в
--- FAIL:
$ go test
--- FAIL: TestSum (0.00s)
--- FAIL: TestSum/два_плюс_два (0.00s)
sum_test.go:17: два плюс три: 5, хотим 6
--- FAIL: TestSum/два_плюс_три (0.00s)
sum_test.go:17: два плюс три: 5, хотим 6
FAIL
exit status 1
FAIL sum 0.172s
До 1.22 это лечили строкой tc := tc первой в теле цикла. С Go 1.22 переменная
цикла создаётся заново на каждой итерации, и код работает как ожидается - но только если в
go.mod написано go 1.22 или новее: семантика привязана к языковой
версии файла, а не к версии тулчейна. Поэтому старый модуль, собранный свежим компилятором,
ведёт себя по-старому. go vet (анализатор loopclosure) ловит именно этот
паттерн с t.Parallel.
-parallel N(по умолчаниюGOMAXPROCS) задаёт, сколько тестов внутри одного бинаря могут бежать одновременно. Это семафор: сабтест, разбуженный барьером, ещё ждёт свободного слота.-p N(тожеGOMAXPROCS) ограничивает число пакетов, которые собираются и тестируются параллельно приgo test ./.... Разные бинари, разные процессы. Интеграционные тесты на общей БД в CI обычно взрывает как раз он, а не-parallel.- Верхнеуровневые тесты без
t.Parallelвыполняются строго по очереди, и каждый успевает дождаться своих параллельных детей. Поэтому параллельные сабтесты разных последовательных родителей друг с другом не пересекаются. На эту гарантию можно опираться, когда фикстуры общие.
- Тест трогает процессное состояние:
os.Setenv,os.Chdir, глобальные переменные,prometheus.DefaultRegisterer,flag, подменённыйhttp.DefaultTransport. Рантайм это даже проверяет:t.Setenvв параллельном тесте паникует. - Тесты делят одни и те же строки в БД или общий брокер: параллельность превращает их в генератор случайных падений.
- Тест измеряет время (таймауты, дедлайны, rate limiter): под нагрузкой от соседей
измерение поплывёт. Бенчмарки параллелят отдельно - через
b.RunParallel. - Тестов мало и они быстрые: параллельность добавит 0 мс выигрыша и класс новых багов. Смысл появляется там, где тесты ждут сеть, диск или контейнеры.
Пункт про «тест измеряет время» распространяется и на замеры аллокаций.
testing.AllocsPerRun(runs, f) считает, сколько раз в среднем f выделяет
память в куче, и для этого он на время замера ставит GOMAXPROCS в 1. Если рядом
бежит параллельный тест, он аллоцирует в тот же момент - и число уезжает. Отсюда флейк:
локально «2 аллокации», в CI то 2, то 3.
Go 1.25 закрыл эту дыру грубо и правильно: AllocsPerRun теперь паникует
с текстом testing: AllocsPerRun called during parallel test, если в момент вызова
выполняется хоть один параллельный тест. Учитываются только бегущие параллельные
тесты. Сабтест, который вызвал t.Parallel() и спит на барьере, ещё не запущен -
паники не будет. А вот два верхнеуровневых
t.Parallel()-теста, один из которых меряет аллокации, уронят прогон сразу. Хватит
и второго в одиночку: он сам считается бегущим параллельным тестом.
func TestA(t *testing.T) { t.Parallel(); time.Sleep(200 * time.Millisecond) }
func TestB(t *testing.T) {
t.Parallel()
// panic: testing: AllocsPerRun called during parallel test
_ = testing.AllocsPerRun(10, func() { _ = make([]byte, 16) })
}
Вторая новость касается b.Loop(), формы бенчмарка из Go 1.24
(for b.Loop() { ... } вместо for range b.N: сама сбрасывает таймер
после setup и не даёт компилятору выкинуть тело цикла как мёртвый код). За эту защиту сначала
платили оптимизациями: в 1.24–1.25 наличие b.Loop мешало компилятору инлайнить код
в теле цикла, и бенчмарк заодно мерил накладные расходы на вызов. Результат искажался ровно
в ту сторону, которую обычно и оптимизируют. В Go 1.26 это исправили отдельным
пунктом релиза: b.Loop больше не мешает инлайну. Вместо запрета оптимизаций
компилятор точечно оборачивает результаты присваиваний и вызовов внутри тела цикла
в runtime.KeepAlive - код живой, но инлайнится. Проверить
можно одной командой go test -bench . -gcflags=-m: в выводе должно быть
inlining call to ... для функции из тела цикла.
Покрытие: как считается и почему врёт
Go меряет покрытие операторов. При сборке с -cover
исходник ещё до компилятора переписывается: каждая функция разбивается на базовые блоки, и в начало
каждого блока вставляется инкремент счётчика. В конце прогона счётчики сериализуются в профиль. Отсюда режимы:
| Флаг | Что делает |
|---|---|
-covermode=set | По умолчанию: блок посещали или нет (bool) |
-covermode=count | Сколько раз посещали - видно горячие и «единожды задетые» ветки |
-covermode=atomic | То же, но атомарным инкрементом: счёт не теряется, когда код бежит в нескольких горутинах. Обязателен с -race |
-coverpkg=./... | Считать покрытие чужих пакетов: интеграционный тест в tests/ покрывает internal/ |
-coverprofile=c.out | Записать профиль; дальше go tool cover -func=c.out или -html=c.out |
$ go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./...
$ go tool cover -func=cover.out | tail -1 # итоговый процент
$ go tool cover -html=cover.out -o cover.html # построчная раскраска
$ go build -cover -o app ./cmd/app # Go 1.20+: покрытие обычного бинаря
$ mkdir -p /tmp/cov # каталог должен существовать заранее
$ GOCOVERDIR=/tmp/cov ./app # прогнали e2e, собрали счётчики
$ go tool covdata percent -i=/tmp/cov
Правильный ответ звучит как «числа зависят от слоя». Для чистой доменной логики, парсеров,
валидаторов и расчётов нормально 90 % и выше, там это дёшево. Хендлерам и репозиториям хватает
60–80 %, выше начинается тестирование фреймворка. Сгенерированный код, моки, DTO, main.go
и обвязку миграций исключают из подсчёта, иначе метрика превращается в шум. Полезнее абсолютного
процента правило «не падает»: покрытие нового кода в PR не ниже текущего.
Гонка за 100 % рождает тесты на геттеры и тесты вида «вызвали функцию, ошибку не проверили».
Мутационное тестирование: инструмент вносит в код мелкие правки (> → >=,
+ → -, удаление вызова) и смотрит, упадёт ли хоть один тест. Если код
изменён, а тесты зелёные - покрытие было декоративным. В Go это go-mutesting и
gremlins. Медленно, поэтому применяют точечно к критичному пакету. Ответ «мы смотрим
не на процент покрытия, а на mutation score в биллинге» на собесе звучит сильно.
testify: assert vs require
Разница ровно одна: assert.X при провале зовёт t.Errorf и возвращает
false, тест продолжается. require.X зовёт t.FailNow()
(по сути runtime.Goexit()), и тест обрывается на этой строке.
func TestGetUser(t *testing.T) {
u, err := svc.Get(ctx, 42)
require.NoError(t, err) // без этого проверки ниже упадут на nil pointer dereference
require.NotNil(t, u) // предусловие: дальше без объекта смысла нет
assert.Equal(t, "gopher", u.Login) // а это независимые проверки:
assert.Equal(t, 42, u.ID) // хотим увидеть все расхождения за один прогон
assert.True(t, u.Active)
}
require ставят на предусловия, после провала которых остальной тест либо
упадёт паникой, либо напечатает бессмысленный мусор (err, nil-указатель, длина
слайса перед индексацией). assert оставляют для независимых утверждений о результате.
Тест, который весь состоит из require, отдаёт по одному расхождению за прогон;
тест, который весь на assert, падает паникой на первом же nil.
requireиз побочной горутины зовёт тот жеGoexit, что иt.Fatal: убьёт горутину, а не тест. В горутинах толькоassert.assert.Equalчувствителен к типу:assert.Equal(t, 1, int64(1))падает, потому что под капотомObjectsAreEqualсравнивает черезreflect.DeepEqualпосле проверки байтов. Для «равны по значению» естьassert.EqualValues, но чаще правильнее привести типы явно.assert.Nil≠assert.NoError:assert.Nilидёт через рефлексию и проходит для типизированного nil-указателя внутри интерфейса, хотяerr != nilв бою вернётtrue. Для ошибок годятся толькоNoError/ErrorIs/ErrorAs.
У противоположного лагеря своя позиция: команда Go и большая часть стандартной библиотеки
принципиально обходятся без testify и пишут if got != want { t.Errorf(...) } плюс
cmp.Diff для структур. Аргументы: нет магии в стектрейсах, сообщение об ошибке пишет
автор теста, порядок аргументов в assert.Equal(t, want, got) постоянно путают местами.
На собесе полезно назвать оба подхода и сказать, что важнее единообразие внутри репозитория.
Время, случайность, конкурентность: три источника недетерминизма
Тест обязан быть функцией от входа. Всё, что приходит «из внешнего мира» (time.Now(),
rand, планировщик горутин), надо либо инжектировать, либо накрыть детерминированной
синхронизацией.
Время: инъекция часов
// Узкий интерфейс: столько методов, сколько реально нужно коду.
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type Session struct {
clock Clock
ttl time.Duration
}
func (s *Session) Expired(issuedAt time.Time) bool {
return s.clock.Now().Sub(issuedAt) > s.ttl
}
// --- в тесте ---
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (f *fakeClock) Now() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.now }
func (f *fakeClock) Advance(d time.Duration) { f.mu.Lock(); f.now = f.now.Add(d); f.mu.Unlock() }
func TestSessionExpired(t *testing.T) {
start := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)
clk := &fakeClock{now: start}
s := &Session{clock: clk, ttl: 30 * time.Minute}
if s.Expired(start) { t.Fatal("свежая сессия не должна протухнуть") }
clk.Advance(31 * time.Minute) // мгновенно, без единого Sleep
if !s.Expired(start) { t.Fatal("через 31 минуту сессия обязана протухнуть") }
}
Готовые реализации: k8s.io/utils/clock/testing, github.com/jonboulle/clockwork
(умеют фейковые таймеры и тикеры; популярный раньше github.com/benbjohnson/clock заархивирован в 2023 году). Если код зависит от таймаута, делай таймаут параметром
конфигурации, а не константой в теле функции: тест поставит 10 мс вместо 30 секунд.
В Go 1.24 появился экспериментальный пакет testing/synctest (за
GOEXPERIMENT=synctest), а в Go 1.25 стал стабильным и доступен без всяких
GOEXPERIMENT; API устоялся на двух функциях, Test и Wait. Код запускается
в «пузыре», внутри которого время фиктивное и прыгает вперёд мгновенно, как только все
горутины пузыря надёжно заблокированы. time.Sleep(time.Hour) внутри пузыря
отрабатывает за наносекунды, а synctest.Wait() ждёт, пока все горутины пузыря
не встанут на блокировку, и получается надёжная точка синхронизации вместо
time.Sleep(100*time.Millisecond). Именно этого не хватало, чтобы тестировать
таймауты, ретраи с backoff и context-дедлайны.
Go 1.27 добавил третью функцию, synctest.Sleep(d): это
time.Sleep(d) плюс synctest.Wait() одним вызовом. Нужна она из-за
неприятной мелочи: если тест и тестируемый код спят одинаковое время, часы пузыря
двигаются один раз и будят обоих - а кто из них побежит первым, не определено. Тест почти
всегда хочет побежать вторым: сначала система под тестом дорабатывает то, что
проснулось по таймеру, и снова встаёт на блокировку, а проверка идёт только потом.
Sleep так и поступает: зовёт Wait() сразу после пробуждения. Отсюда
правило: внутри пузыря пиши synctest.Sleep, а не time.Sleep.
// Go 1.25
func TestRetryBackoff(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewClient(WithBackoff(time.Second, 5))
done := make(chan error, 1)
go func() { done <- c.Do(context.Background()) }()
// Пока тест ждёт результат, спят все горутины пузыря, и его часы
// перескакивают через паузы бэкоффа: реально никто не ждёт.
if err := <-done; err == nil {
t.Fatal("ждали ошибку после исчерпания попыток")
}
})
}
// Go 1.27: synctest.Sleep поспит и дождётся, пока все успокоятся
func TestCacheTTL(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewCache(time.Minute) // внутри фоновая горутина выкидывает протухшее по таймеру
defer c.Close() // без этого горутина переживёт тест, и Test упадёт с deadlock
c.Set("k", "v")
synctest.Sleep(time.Minute) // ровно time.Sleep(time.Minute) + synctest.Wait()
// уборщик проснулся от того же тика часов пузыря и уже отработал.
// С голым time.Sleep порядок пробуждения двух горутин не определён, и тест флейкает.
if _, ok := c.Get("k"); ok {
t.Fatal("ключ должен был протухнуть")
}
})
}
Случайность
- Инжектируй источник: поле
rnd *rand.Randили интерфейсShuffler. Тест получаетrand.New(rand.NewSource(42))с детерминированной последовательностью. - Глобальный
math/randс Go 1.20 сам засеивается случайно при старте (rand.Seedобъявлен устаревшим, а с Go 1.24 вообще ничего не делает), так что «зафиксировать seed глобально» больше не вариант. Вmath/rand/v2(Go 1.22) функцииSeedнет вовсе. crypto/randподменяется черезio.Reader-параметр:io.ReadFull(r, buf)в своём коде, а в тестеbytes.Readerс фиксированными байтами. В стандартных crypto-пакетах с Go 1.26 часть функций (генерация ключей RSA, ECDSA и ECDH, подпись ECDSA и ещё несколько) такой параметр игнорирует, для них естьtesting/cryptotest.SetGlobalRandom.- Если нужна именно случайность, это уже property-based подход: фаззинг (см. ниже) либо
testing/quick(заморожен, но рабочий).
Конкурентность
-raceобязателен в CI. Детектор ловит только те гонки, которые реально произошли в этом прогоне. Стоит он замедления в 2–20 раз и роста памяти, зато ложных срабатываний почти нет. Отсюда практика:go test -race -count=5 ./...ночью.- Никаких
time.Sleepдля «подождать, пока горутина доработает». На загруженном CI-раннере Sleep даёт флак, на свободном теряет секунды. Синхронизируй каналом,sync.WaitGroupилиerrgroup. - Любое ожидание в тесте ограничивай таймаутом:
select { case <-done: case <-time.After(2*time.Second): t.Fatal("таймаут") }. Без этого упавший тест вешает весь бинарь до-timeout, а потом печатает дамп всех горутин. -cpu=1,4прогоняет тест при разныхGOMAXPROCS- иногда вылезают сценарии, невидимые на одном ядре.
Golden files и фикстуры
Golden file («эталон») берут, когда ожидаемый результат не влезает литералом в код:
отрендеренный HTML, сгенерированный SQL, JSON-ответ API, форматированный отчёт. Эталон
лежит рядом в testdata/, тест сравнивает выход с файлом, а флаг
-update перезаписывает эталон.
var update = flag.Bool("update", false, "перезаписать golden-файлы")
func TestRenderInvoice(t *testing.T) {
got := Render(fixtureInvoice(t))
golden := filepath.Join("testdata", "invoice.golden.json")
if *update {
if err := os.WriteFile(golden, got, 0o644); err != nil { t.Fatal(err) }
}
want, err := os.ReadFile(golden)
if err != nil { t.Fatal(err) }
if diff := cmp.Diff(string(want), string(got)); diff != "" {
t.Errorf("рендер разошёлся с эталоном (-want +got):\n%s", diff)
}
}
$ go test ./render -run TestRenderInvoice -update
$ git diff render/testdata/ # обязательный шаг: глазами посмотреть, что изменилось
Директория с именем testdata игнорируется инструментами Go: её не обходит
./..., файлы в ней не компилируются, туда можно положить хоть битый Go-код (что
и делают тесты компилятора). А раз тест запускается из директории пакета, путь
testdata/x.json работает без всяких runtime.Caller. Для эталонов лучше
os.ReadFile, чем //go:embed: embed вшивает содержимое на этапе сборки,
и флаг -update в том же прогоне уже ничего не изменит.
- Ритуал
-update: сломал логику, перегенерировал эталон, зелёно, поехали в прод. Лечится только код-ревью диффаtestdata/и читаемым форматом (отформатированный JSON построчно, а не одна строка на 40 КБ). - Нестабильные поля:
time.Now(), UUID, порядок обхода мапы. Перед сравнением их нормализуют фиксированными часами, детерминированным генератором ID и сортировкой ключей (json.Marshalсортирует ключи мапы, но не элементы слайса). - Golden на маленькое значение: если ожидание помещается в одну строку, файл только прячет смысл. Golden оправдан от десятков строк.
Фикстуры описывают входные данные. Подходов два: файлы в testdata/
(JSON-пейлоады, SQL-дампы, примеры протокола) и билдеры в коде - функция-конструктор
с валидным объектом по умолчанию и точечными правками. Билдеры почти всегда лучше: тест читается
как «валидный заказ, но со скидкой 100 %», а не «двадцать полей, из которых важно одно».
Общий глобальный набор фикстур на весь пакет считается антипаттерном: он связывает тесты друг
с другом, и через год никто не знает, кто на какое поле опирается.
Fuzzing: тесты, которые придумывают вход сами
С Go 1.18 фаззинг встроен в go test. Пишется цель FuzzXxx(f *testing.F),
а в ней семена через f.Add и сама проверка через f.Fuzz. Движок
coverage-guided: он мутирует байты входа, следит за счётчиками покрытия и оставляет в корпусе
те входы, которые открыли новый код. Поэтому фаззинг и не сводится к «случайным данным»:
он целенаправленно лезет вглубь.
func FuzzParseRange(f *testing.F) {
// Семенной корпус: реальные примеры и заведомо злые.
f.Add("bytes=0-499")
f.Add("bytes=-1")
f.Add("")
f.Add("bytes=9223372036854775807-9223372036854775807")
f.Fuzz(func(t *testing.T, in string) {
r, err := ParseRange(in)
if err != nil {
return // отказать на мусоре можно, паниковать нельзя
}
// Эти свойства должны выполняться для любого принятого входа:
if r.Start < 0 || r.End < r.Start {
t.Fatalf("ParseRange(%q) вернул некорректный диапазон %+v", in, r)
}
// Round-trip: разобрали, собрали обратно, снова разобрали, результат тот же.
again, err := ParseRange(r.String())
if err != nil || again != r {
t.Fatalf("round-trip сломался: %q -> %+v -> %q", in, r, r.String())
}
})
}
Запускается фаззинг отдельным флагом и только по одной цели за прогон - движку нужен весь процесс целиком:
$ go test ./httpx -run '^$' -fuzz FuzzParseRange -fuzztime 60s
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 14 workers
fuzz: minimizing 72-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzParseRange (0.11s)
--- FAIL: FuzzParseRange (0.00s)
range_test.go:19: ParseRange("bytes=10-0") вернул некорректный диапазон bytes=10-0
Failing input written to testdata/fuzz/FuzzParseRange/c55933f636ba0316
To re-run:
go test -run=FuzzParseRange/c55933f636ba0316
FAIL
exit status 1
FAIL app/httpx 0.303s
$ go test ./httpx # найденный вход теперь прогоняется как обычный кейс
Корпусов у фаззера два. Семенной складывается из f.Add в коде и файлов
в testdata/fuzz/FuzzXxx/; он лежит в репозитории и прогоняется при обычном
go test, без всякого -fuzz. Сгенерированный корпус живёт в кеше сборки
($GOCACHE/fuzz), в git не попадает и между машинами не переносится. Найдя
падение, фаззер минимизирует вход и записывает его в testdata/fuzz/. Этот файл
надо закоммитить, и краш превратится в постоянный регрессионный тест.
- Не паникует. Дешевле и ценнее инварианта нет, половина находок именно такие: индекс за границей слайса, разыменование nil, деление на ноль.
- Round-trip:
Decode(Encode(x)) == x,Parse(v.String()) == v. - Differential: сравнить свою быструю реализацию с медленной эталонной или со стандартной библиотекой - они обязаны давать один результат на любом входе.
- Инварианты домена: длина результата не больше исходной, сумма сохранилась, отсортировано.
Типы аргументов f.Fuzz ограничены списком: []byte, string,
все int*/uint*, float32/64, bool,
rune, byte. Структуру напрямую фаззить нельзя - генерируют
[]byte и разбирают его в структуру внутри цели. Сигнатуры f.Add и
f.Fuzz должны совпадать по типам, иначе тест не запустится: с Go 1.24 расхождение
ловит vet-анализатор tests, который go test гоняет сам. Сама цель обязана быть
детерминированной и без побочных эффектов: писать в общий файл или в реальную БД внутри
f.Fuzz нельзя, воркеров несколько. И -fuzz работает только с одной целью
за прогон, поэтому в CI это отдельный ночной job с -fuzztime, а не часть обычного
go test ./....
Фаззить стоит там, где вход приходит извне и представим как байты:
парсеры (JSON/YAML/protobuf-обёртки, свои DSL, заголовки HTTP, формат файла), декодеры, санитайзеры,
нормализация путей и URL, разбор пользовательских выражений, всё вокруг безопасности. Бизнес-логика
с десятью доменными структурами фаззится плохо: больше кода на генерацию входа, чем самой проверки,
и большая часть сгенерированных входов отсекается валидацией на первой строке. В стандартной
библиотеке фаззинг нашёл десятки багов в encoding/*, net/http и
image/* - почти все именно этого класса.
Вопросы
8TestXxx(t *testing.T), которую
go test находит на этапе сборки и запускает в отдельной горутине;
t.Run заводит вложенную горутину-подтест, t.Helper правит номер строки
в отчёте, t.Cleanup кладёт функцию в стек отката именно этого теста.Механика
go test компилирует пакет вместе с _test.go-файлами и генерирует
_testmain.go со списком найденных тестов. Рефлексии в рантайме нет, поэтому
динамически тест регистрируется только через t.Run. Имя ищется по
префиксу Test + не-строчная буква: func Testfoo не запустится (с Go 1.24
на это ругнётся vet-анализатор tests, который go test гоняет сам).
Каждый тест выполняется в своей горутине, и отсюда главное правило:
t.Fatal внутри вызывает runtime.Goexit() и завершает горутину теста.
Позвал t.Fatal из побочной горутины - убил её, а тест поехал дальше и, скорее всего,
упал паникой строчкой ниже. Из побочных горутин можно только t.Error.
t.Run - сабтесты
t.Run(name, func(t *testing.T)) создаёт дочерний *testing.T и по умолчанию
блокируется до его завершения (если только ребёнок не позвал t.Parallel).
Что это даёт:
- Изоляция провала:
t.Fatalв сабтесте обрывает только его, остальные кейсы таблицы отработают. - Фильтрация: в
go test -run 'TestValidate/пустой_email'флаг-runпринимает имена через/, по уровням вложенности. Пробелы в имени заменяются на_, дубликаты получают суффикс#01. - Общий setup: код до первого
t.Runвыполняется один раз на всю группу.
t.Helper
Вызов t.Helper() в первой строке функции помечает её как вспомогательную. При провале
testing идёт вверх по стеку и печатает первую строку, которая не помечена
хелпером. Без этого все ошибки показывают одну и ту же строку внутри assertUser,
и непонятно, какой из двадцати вызовов сломался. Пометка хранится на самом *testing.T,
а не на горутине, поэтому она действует и когда хелпер зовут из побочной горутины: отчёт всё равно
покажет строку вызова, а не строку внутри хелпера. Толку от этого, правда, немного - «строкой вызова»
там окажется тело go func().
func mustUser(t *testing.T, id int) User {
t.Helper() // без этой строки отчёт покажет строку с t.Fatal ниже,
u, err := repo.Get(ctx, id) // а не строку вызова mustUser в самом тесте
if err != nil {
t.Fatalf("mustUser(%d): %v", id, err)
}
return u
}
t.Cleanup vs defer
t.Cleanup(f) кладёт f в стек (LIFO) конкретного *testing.T
и вызывает его, когда этот тест и все его подтесты, включая параллельные, завершились. Разница
с defer принципиальна в двух местах:
- Хелперы:
deferвнутриsetupDB(t)сработает при выходе изsetupDB, то есть до начала самого теста, аt.Cleanupпосле него. Поэтому фабрики ресурсов пишут именно черезCleanup. - Параллельные подтесты:
deferв родителе выполняется, когда родитель вернул управление, - а параллельные дети в этот момент ещё не начинались. Отсюда баг «база закрыта, тест падает».t.Cleanupродителя ждёт всех детей.
Назвать TestMain(m *testing.M), единственную точку setup на весь пакет: она обязана
вызвать m.Run(). Передавать код в os.Exit с Go 1.15 необязательно:
если TestMain просто вернётся, обёртка сделает это сама. И упомянуть t.Setenv / t.TempDir, которые
сами регистрируют cleanup, причём t.Setenv ещё и запрещает
t.Parallel в этом тесте: переменные окружения глобальны для процесса.
t.Run(tc.name, ...);
негативные кейсы проверяются не «есть ошибка», а errors.Is/errors.As на
конкретную сентинель или тип.func TestValidateUser(t *testing.T) {
valid := User{Login: "gopher", Email: "g@example.com", Age: 30}
tests := []struct {
name string
mutate func(*User) // точечная правка валидного объекта
wantErr error // nil = ожидаем успех
wantField string
}{
{name: "валидный пользователь", mutate: func(*User) {}},
{name: "пустой логин", mutate: func(u *User) { u.Login = "" },
wantErr: ErrRequired, wantField: "login"},
{name: "логин 1 символ", mutate: func(u *User) { u.Login = "a" },
wantErr: ErrTooShort, wantField: "login"},
{name: "логин на границе 2", mutate: func(u *User) { u.Login = "ab" }},
{name: "email без собаки", mutate: func(u *User) { u.Email = "gopher.example.com" },
wantErr: ErrFormat, wantField: "email"},
{name: "отрицательный возраст", mutate: func(u *User) { u.Age = -1 },
wantErr: ErrRange, wantField: "age"},
{name: "юникод в логине", mutate: func(u *User) { u.Login = "гофер" }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
u := valid // своя копия на каждый кейс, общего состояния нет
tc.mutate(&u)
err := ValidateUser(u)
if tc.wantErr == nil {
if err != nil {
t.Fatalf("ожидали успех, получили %v", err)
}
return
}
if !errors.Is(err, tc.wantErr) { // не reflect.DeepEqual и не сравнение строк
t.Fatalf("ожидали %v, получили %v", tc.wantErr, err)
}
var ve *ValidationError
if errors.As(err, &ve) && ve.Field != tc.wantField {
t.Errorf("поле: got %q, want %q", ve.Field, tc.wantField)
}
})
}
}
Как организуют негативные кейсы
- Мутация валидного объекта, а не двадцать литералов целиком. Тогда в кейсе видно ровно то, что его отличает, и новое обязательное поле не ломает всю таблицу.
- Ожидаем сентинель или тип, а не строку. Сравнение
err.Error() == "..."привязывает тест к формулировке сообщения; поменяли текст - упало двадцать тестов.errors.Isпереживает оборачивание через%w. - Границы отдельными кейсами: длина 1 / 2 / 3 при минимуме 2, возраст 0 / -1, пустая строка / пробелы / юникод / очень длинная строка.
- Валидатору, собирающему все ошибки сразу, нужна отдельная группа кейсов: проверяем, что при
трёх плохих полях вернулось три ошибки, и что
errors.Isнаходит каждую (в Go 1.20+ этоerrors.Join). - Когда кейсов на один вход набираются сотни, пора переходить к фаззингу, а не растить таблицу на 400 строк.
- Общий изменяемый объект между кейсами (мапа, слайс, указатель в структуре кейса)
особенно опасен с
t.Parallel: получишь гонку или зависимость от порядка. - Логика внутри цикла: раз понадобился
if tc.name == "особый" { ... }, кейс не влезает в таблицу, выноси его в отдельныйfunc Test.... - Безымянные кейсы: без поля
nameотчёт покажет#03, и по логу упавшего CI невозможно понять, что сломалось. - Таблица без ассерта на конкретное значение: «ошибка не nil» пропускает подмену
ErrTooShortнаErrFormat.
t.Parallel() приостанавливает тест и возвращает
управление родителю; все «припаркованные» подтесты стартуют разом после того, как родительская
функция дошла до конца, с ограничением -parallel (по умолчанию GOMAXPROCS).Что происходит по шагам
- Тест зовёт
t.Parallel(), метод сигналит родителю и блокируется на канале. Функция теста останавливается ровно на этой строке. - Родитель (или
tRunnerверхнего уровня) продолжает: запускает следующийt.Run, тот тоже паркуется, и так далее. - Когда тело родителя закончилось,
testingразблокирует всех припаркованных детей сразу, но одновременно бегут не больше-parallel, остальные ждут в очереди. - Родитель не завершается, пока не отработают все параллельные дети; его
t.Cleanupвыполнится после них, аdeferуже выполнился до их старта.
Отсюда два следствия, которые и спрашивают. Первое: вызов t.Parallel() должен идти
первой строкой подтеста - код до него исполняется последовательно и в другой момент
времени. Второе: параллелятся тесты внутри одного пакета; разные пакеты и так идут
параллельно (флаг -p, по умолчанию GOMAXPROCS), это независимые процессы.
Ловушка с переменной цикла
// До Go 1.22 классический баг: все подтесты видят последний tc
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // тест паркуется; цикл успевает докрутиться до конца
check(tc.input) // tc одна на весь цикл, к моменту старта в ней последний кейс
})
}
// До 1.22 лечилось так:
for _, tc := range tests {
tc := tc // копия на итерацию (или параметр функции)
t.Run(tc.name, func(t *testing.T) { t.Parallel(); check(tc.input) })
}
В Go 1.22 семантику изменили: при go-директиве в go.mod
не ниже 1.22 переменные for-цикла создаются заново на каждой
итерации, и строка tc := tc больше не нужна (линтер copyloopvar
и модернизаторы gopls подсвечивают её как лишнюю). Решает при этом версия в go.mod,
а не версия компилятора: старый модуль с go 1.19, собранный новым тулчейном,
работает по-старому. И баг проявлялся именно из-за парковки: без
t.Parallel замыкание выполнялось сразу и видело правильное значение, поэтому
«непараллельная» версия того же теста была зелёной.
Когда параллелить не стоит
- Глобальное состояние: переменные окружения (
t.Setenvпрямо паникует в параллельном тесте), рабочая директория, глобальные синглтоны,http.DefaultClient, подменаtime.Nowчерез пакетную переменную. - Общая БД без изоляции: тесты видят чужие строки. Либо схема/транзакция на тест, либо последовательно.
- Тесты, чувствительные к времени и ресурсам: бенчмарки, замеры таймаутов, тесты
с ограничением по памяти. Под нагрузкой соседей они флакают. С Go 1.25 один такой
случай стал явной ошибкой:
testing.AllocsPerRunпаникует (AllocsPerRun called during parallel test), если в момент замера бежит параллельный тест - раньше это молча давало плавающее число аллокаций. - Быстрые тесты чистых функций: накладные расходы на планировщик больше выигрыша, а риск словить скрытую связанность вполне реален. Параллелить осмысленно там, где тест ждёт: сеть, диск, контейнеры.
-parallel N ограничивает параллельные тесты внутри одного бинаря, а -p N
задаёт число одновременно тестируемых пакетов. Итоговая конкурентность доходит до их произведения.
По умолчанию оба равны GOMAXPROCS, так что на 2 ядрах это до 4 тестов разом, но стоит
поднять -parallel ради скорости - и за одну базу дерутся уже десятки. А -race вместе
с t.Parallel лучше всего находит реальные гонки: тесты начинают дёргать общий
код одновременно.
Как считается
При сборке с -cover инструмент cover переписывает исходник ещё до компилятора: режет
каждую функцию на блоки и вставляет в начало блока инкремент счётчика. Режимы: set (был/не был, по умолчанию),
count (сколько раз), atomic (атомарный инкремент; с -race он обязателен и ставится
сам, иначе сам профилировщик стал бы источником гонки).
-coverpkg=./... считает покрытие чужих пакетов, и тогда интеграционный тест
из tests/ засчитывается в internal/. С Go 1.20 покрытие снимается и
с обычного бинаря: go build -cover, затем GOCOVERDIR=... ./app и
go tool covdata. До этого e2e-покрытие снимали обходным путём: тестовым бинарём
с -coverpkg, в котором тест просто зовёт main().
Почему 100 % ничего не гарантируют
- Комбинации условий не видны.
if a && bзанимает одну строку: два теста закрывают её на 100 %, а из четырёх комбинаций проверены две. - Границы не видны.
i <= nпротивi < n- та же строка, тот же процент, другой результат. - Отсутствующий код покрыть нельзя. Забытая проверка
nilили необработанная веткаerrне имеет строки, которая могла бы стать красной. - Качество ассертов не учитывается. Тест, который вызвал функцию и ничего не проверил, даёт ровно те же проценты. Такие тесты и появляются, когда в KPI записан порог покрытия.
- Конкурентность. Гонка исполняет те же строки; покрытие про порядок ничего не знает.
Что отвечать про «хороший процент»
Что число зависит от слоя: доменная логика, парсеры, расчёты требуют 90 %+, там это дёшево
и полезно; хендлерам и репозиториям хватает 60–80 %, выше начинается тестирование фреймворка;
сгенерированный код, моки, DTO, main.go и обвязку миграций исключают из подсчёта. Полезнее абсолютного
числа два правила: покрытие diff'а в PR (новый код покрыт) и покрытие не падает.
А качество самих тестов проверяет мутационное тестирование
(go-mutesting, gremlins): инструмент меняет > на
>=, удаляет вызовы, и если тесты остались зелёными - покрытие было декоративным.
Вопрос-ловушка: «у нас 100 % покрытия - значит, багов нет?». Спорить про число не нужно,
лучше привести пример: функция на четыре ветки, два теста, 100 % строк и непроверенная комбинация.
Часто спрашивают и другое: «почему go test -cover ./... показывает 0 % для пакета,
который точно тестируется?». Потому что по умолчанию считается покрытие только тестируемого
пакета, а тесты лежат в другом; лечится -coverpkg.
assert зовёт t.Errorf, помечает тест
провалившимся и возвращает false - выполнение продолжается; require зовёт
t.FailNow() (то есть runtime.Goexit) и обрывает тест на этой строке.Правило выбора
require нужен для предусловий: если они провалились, остаток теста либо упадёт
паникой, либо напечатает бессмысленный мусор. Типичные примеры: require.NoError(t, err),
require.NotNil, require.Len перед индексацией слайса.
На assert пишут независимые утверждения о результате, чтобы увидеть все
расхождения за один прогон и не чинить их по одному. Тест целиком на require отдаёт
по одной ошибке за запуск; тест целиком на assert падает nil-паникой на первой же
неудаче.
u, err := svc.Get(ctx, 42)
require.NoError(t, err) // без этого проверки ниже упадут на nil pointer dereference
require.NotNil(t, u)
assert.Equal(t, "gopher", u.Login) // независимые проверки: хотим весь список расхождений
assert.Equal(t, 42, u.ID)
assert.True(t, u.Active)
Три ловушки, на которых ловят
requireиз побочной горутины.FailNowсводится кGoexit: он убьёт горутину, провал запишется, а тест пойдёт дальше и зависнет наWaitGroup, еслиDoneвызывается не черезdefer. В горутинах пиши толькоassert.assert.Equalстрог к типам:assert.Equal(t, 1, int64(1))падает. ЕстьEqualValues, но надёжнее привести типы явно.assert.Nil≠assert.NoError:Nilработает через рефлексию и пройдёт для типизированного nil-указателя в интерфейсе, хотя в боюerr != nilвернётtrue. Для ошибок толькоNoError/ErrorIs/ErrorAs.
Сказать, что у assert есть форма assert.Eventually(t, cond, wait, tick) -
нормальная замена time.Sleep для «дождаться асинхронного эффекта», и
require.EventuallyWithT для ассертов внутри условия. И упомянуть
противоположный лагерь: команда Go и стандартная библиотека обходятся без testify, пишут
if got != want { t.Errorf }, для структур берут cmp.Diff (в самой
стандартной библиотеке - reflect.DeepEqual: сторонних зависимостей там нет) и ценят
стектрейсы без магии и отсутствие путаницы в порядке (want, got). Единообразие
в репозитории важнее выбора лагеря.
Clock, случайность - *rand.Rand с фиксированным seed
или io.Reader, конкурентность - честной синхронизацией вместо time.Sleep
плюс -race.Время
Прямой вызов time.Now() внутри доменной логики делает её непроверяемой: тест
«токен истёк через час» пришлось бы ждать час. Лечится это узким интерфейсом на стороне потребителя:
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
type fakeClock struct {
mu sync.Mutex
now time.Time
waiters []chan time.Time // «спящих» будим вручную
}
func (c *fakeClock) Advance(d time.Duration) { /* двигаем now и будим тех, чей дедлайн прошёл */ }
Тогда тест «через 61 минуту токен протух» выполняется за микросекунды:
clk.Advance(61 * time.Minute). Готовые реализации есть в k8s.io/utils/clock/testing
и github.com/jonboulle/clockwork. В Go 1.23 переписали таймеры:
Timer/Ticker, на которые не осталось ссылок, собираются GC даже без
Stop, а канал стал
небуферизованным, и часть старых трюков с time.After в тестах поменяла
поведение. И ещё: time.Time содержит монотонную часть, поэтому
reflect.DeepEqual на двух «одинаковых» временах может дать false.
Сравнивают через .Equal() или cmpopts.EquateApproxTime.
Случайность
- Инжектировать
*rand.Rand, в тесте подставитьrand.New(rand.NewSource(42)): последовательность детерминирована. - С Go 1.20 глобальный
math/randсам засеивается случайно,rand.Seedустарел (а с Go 1.24 ничего не делает); вmath/rand/v2(Go 1.22) функцииSeedнет вовсе - «зафиксировать глобальный seed» больше не выйдет. crypto/randподменяется параметромio.Reader: в тест передаютbytes.NewReaderс фиксированными байтами. Но в стандартных crypto-пакетах с Go 1.26 часть функций (генерация ключей RSA, ECDSA и ECDH, подпись ECDSA и ещё несколько) этот параметр игнорирует, для них естьtesting/cryptotest.SetGlobalRandom.- Если случайность нужна именно как источник входов, это уже property-based подход: фаззинг
или
testing/quick.
Конкурентность
- Никакого
time.Sleepдля «подождать горутину»: на нагруженном раннере это флак, а в зелёном прогоне - потерянные секунды. Синхронизируют каналом,sync.WaitGroupилиerrgroup. Go 1.25 добавилwg.Go(func(){...}), который сам делаетAdd(1)/Done. - Любое ожидание делают с таймаутом:
select { case <-done: case <-time.After(2*time.Second): t.Fatal("таймаут") }. Иначе повисший тест держит весь бинарь до-timeoutи печатает дамп всех горутин. -raceв CI обязателен. Детектор находит только те гонки, которые реально произошли в этом прогоне, зато практически без ложных срабатываний. Отсюда практикаgo test -race -count=5 ./...ночью и-cpu=1,4для разных GOMAXPROCS.- Ассерты делают только из горутины теста; из побочных
t.Fatal/requireзапрещены (этоGoexit). Результаты собирают через канал. - Утечки горутин ловит
go.uber.org/goleak, подключённый одной строкой вTestMain.
«Вопрос про мок time.Now() на самом деле про архитектуру. Если время, случайность
и I/O лежат за узкими интерфейсами, которые я объявляю у потребителя, то тест становится
чистой функцией от входа, и ни sleep'ов, ни флаков в нём не появляется в принципе.»
testdata/, с которым
сравнивается результат, плюс флаг -update для перегенерации; фикстуры - входные данные,
и их лучше собирать билдерами в коде, чем таскать общий глобальный набор.Golden files
Применяют, когда ожидаемый результат слишком велик для литерала в коде: отрендеренный HTML,
сгенерированный SQL, JSON-ответ API, форматированный отчёт, вывод CLI. Схема стандартная: с флагом
-update файл перезаписывается, без него идёт сравнение через
cmp.Diff, чтобы в отчёте был читаемый дифф, а не два блоба по 40 КБ.
После -update обязательно просмотреть git diff testdata/ глазами, иначе
golden-тест превращается в «сломал логику, перегенерировал, зелёно».
Почему именно testdata: эту директорию тулчейн Go игнорирует, не
обходит по ./... и не компилирует, туда можно класть хоть заведомо битый Go-код.
А поскольку тест запускается из директории пакета, testdata/x.json находится
без runtime.Caller. //go:embed для эталонов хуже: содержимое вшивается
на этапе сборки, и -update в том же прогоне уже ничего не изменит.
Как они гниют и что с этим делать
- Нестабильные поля:
time.Now(), UUID, порядок обхода мапы, порядок элементов из БД безORDER BY. Нормализуй перед сравнением: фиксированные часы, детерминированный генератор ID, явная сортировка. (json.Marshalсортирует ключи мапы, но не элементы слайса.) - Формат в одну строку: дифф превращается в «строка 1 отличается». Храни эталон отформатированным построчно.
- Golden на две строки: если ожидание помещается в литерал, файл только прячет смысл. Оправдано от десятков строк.
Фикстуры
Два рабочих подхода: файлы в testdata/ (JSON-пейлоады, SQL-дампы, примеры протокола)
и билдеры в коде - конструктор валидного объекта плюс точечные правки. Билдеры почти
всегда лучше: тест читается как «валидный заказ, но со скидкой 100 %», а не «двадцать полей,
из которых важно одно», и новое обязательное поле правится в одном месте. От общего глобального
набора фикстур на весь пакет лучше отказаться: он связывает тесты друг с другом, и через год никто
не знает, кто на какое поле опирается. Фикстуры-файлы уместны там, где данные пришли из внешнего
мира и важна их дословность: реальный ответ платёжного шлюза, конкретный дамп протокола.
FuzzXxx(f *testing.F)
мутирует входы, следит за счётчиками покрытия и оставляет в корпусе те, что открыли новый код;
найденный краш минимизируется и записывается в testdata/fuzz/ как обычный регрессионный кейс.Чем отличается от «случайных данных»
Обратной связью. Фаззер инструментирует код счётчиками покрытия, мутирует байты входа (битфлипы, вставки, обрезка, дублирование кусков) и, если мутация задела новый базовый блок, кладёт этот вход в корпус как «интересный». Дальше мутирует уже его. Поэтому за минуты он добирается до веток, куда чистый рандом не попал бы никогда: чтобы дойти до третьего уровня вложенности парсера, случайной строке надо угадать префикс, а фаззеру - нет.
Как устроено
f.Add(...)задаёт семенной корпус в коде, файлы вtestdata/fuzz/FuzzXxx/хранят его в репозитории. Оба прогоняются при обычномgo test, без-fuzz.f.Fuzz(func(t *testing.T, in string) {...})объявляет саму цель. Типы аргументов ограничены:[]byte,string,int*/uint*,float32/64,bool,rune,byte. Структуру фаззят через[]byteс разбором внутри цели.- Запуск:
go test -run '^$' -fuzz FuzzXxx -fuzztime 60s ./pkg- одна цель за прогон. Сгенерированный корпус лежит в$GOCACHE/fuzzи в git не попадает. - Падением считаются паника,
t.Error/t.Fatalвнутри цели, дедлок, срабатывание-race,os.Exit, таймаут воркера. Возвращённая функцией ошибка падением не считается: корректный отказ на мусоре нормален.
Что проверять внутри цели
Свойства, а не конкретные значения: «не паникует» (самая дешёвая и самая результативная
проверка), round-trip (Parse(v.String()) == v), differential (своя быстрая
реализация против эталонной или стандартной библиотеки), доменные инварианты (длина результата
не больше входа, сумма сохранилась).
Когда полезен
Там, где вход приходит извне и представим как байты: парсеры и декодеры, свои DSL,
разбор заголовков и URL, санитайзеры, форматы файлов, всё вокруг безопасности. Бизнес-логика
с десятком доменных структур фаззится плохо - кода на генерацию входа больше, чем самой проверки,
и почти всё отсекается валидацией на первой строке. В CI фаззинг ставят отдельным ночным
job'ом с -fuzztime: он занимает все ядра и по своей природе не завершается сам.
Цель обязана быть детерминированной и без побочных эффектов - воркеров несколько.
Упомянуть, что фаззинг в Go нашёл десятки багов в самой стандартной библиотеке
(encoding/*, net/http, image/*), и что найденный вход
после минимизации коммитится в testdata/fuzz/. Выходит, фаззер юнит-тесты
не заменяет, а производит.
Что стоит запомнить
- t.Run создаёт именованные subtest.
- t.Parallel откладывает выполнение до возврата родителя.
- Coverage отражает исполненные участки, не достаточность assertions.
Частые ошибки
- Проверь версию языка для поведения range variables.
- TestMain не должен скрывать зависимость теста от окружения.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери проверку
При go directive 1.22+, каждый parallel subtest должен обработать свой tc. Когда эти дочерние тесты начинают выполняться? Как меняется ответ для старого go directive?
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := normalize(tc.input)
if got != tc.want { t.Fatalf("got %q want %q", got, tc.want) }
})
}
Ответ и разбор
При go directive 1.22 или новее у каждой итерации своя tc. Вызов t.Parallel приостанавливает subtest до возврата родительской test function, затем parallel subtests запускаются. При go directive до 1.22 замкнутые функции используют одну переменную цикла, поэтому перед t.Run нужна копия tc := tc.
Код-ревью
2. Найди дефект и исправь
Parent создаёт каталог, ставит cleanup через defer и запускает parallel child, который пишет туда после возобновления. Найди lifetime bug и исправь его.
func TestParent(t *testing.T) {
dir, _ := os.MkdirTemp("", "test-")
defer os.RemoveAll(dir)
t.Run("parallel child", func(t *testing.T) {
t.Parallel()
_ = os.WriteFile(filepath.Join(dir, "value"), []byte("x"), 0o600)
})
}
Ответ и разбор
Parent возвращается и выполняет defer до возобновления parallel child, поэтому каталог уже удалён. Проверь ошибку создания и используй dir := t.TempDir(): testing очистит каталог после завершения дочерних тестов. Ошибку WriteFile тоже проверь.
Применение
3. Реши сценарий
Функция обработки заказа возвращает (Receipt, error). Предложи два table-driven случая: успешный заказ и недостаточный остаток. Какие поля нужно проверить?
Ответ и разбор
В успешном случае проверь отсутствие ошибки и значимые поля Receipt: номер заказа, итоговую сумму и строки заказа. Для недостаточного остатка проверь errors.Is(err, ErrInsufficientStock) или тип ошибки через errors.As; не сверяй только текст. Каждый case назови по сценарию, входы и want держи рядом.
Применение
4. Проверь альтернативу
Тестам пакета требуется один тестовый сервер, но каждый тест должен закрыть свой клиент/ресурс даже при Fatal. Где разместить setup и cleanup?
Ответ и разбор
Для разового общего окружения используй TestMain только если всем тестам действительно нужен этот ресурс; для локальных ресурсов строй helper, регистрирующий t.Cleanup сразу после успешного создания. Cleanup выполнится при завершении теста даже после Fatal. Параллельным тестам не давай менять общий mutable state.