Тестирование в Go: что на самом деле делают t.Run, t.Parallel и бенчмарк
Собеседование по тестам идёт лестницей: что такое табличный тест — как запустить один случай — зачем t.Parallel и что с ним ломается — почему повторный go test не запускает тесты — что не так с бенчмарком. Прогон отвечает на каждый шаг: defer родителя закрывает ресурс раньше, чем начнутся параллельные подтесты; в модуле с go 1.21 все подтесты видят последний случай таблицы; четыре спящих подтеста идут парами, потому что -parallel равен GOMAXPROCS; а бенчмарк, выбросивший результат чистой встроенной функции, замерил пустой цикл.
Полное техническое изложение
TL;DR
Тест в Go — функция TestXxx(t *testing.T), а табличный тест — это
данные плюс одна проверка. Случаи лежат в срезе структур, проверка написана
один раз, и каждый случай запускается подтестом t.Run под своим именем.
Поэтому один случай можно запустить отдельно: go test -run 'TestClamp/выше'.
t.Parallel() меняет порядок выполнения, и это главный источник ошибок.
Параллельный подтест ждёт, пока тело родителя закончится. defer родителя
срабатывает ровно тогда — до подтестов: в прогоне оба подтеста увидели
уже закрытый ресурс. t.Cleanup вызывается после всех подтестов, а
t.Run группы не возвращается, пока её параллельные подтесты не закончились.
И
переменная цикла в параллельных подтестах зависит от строки go в go.mod:
в модуле с go 1.21 все три подтеста увидели последний случай таблицы.
Дальше — то, что отличает понимание от пересказа. t.Parallel не значит
«все сразу»: одновременно идёт не больше, чем -parallel, а он по умолчанию
равен GOMAXPROCS. Четыре подтеста по 200 мс сна при GOMAXPROCS=2 заняли
404 мс, а не 200. Повторный go test без изменений не запускает тесты, а
печатает (cached) — даже если база, в которую ходит тест, с тех пор
изменилась. А бенчмарк, который выбрасывает результат чистой встроенной
функции, в прогоне замерил пустой цикл: 0,32 нс против 1,65 нс, когда
результат сохранён.
- как написать функцию и запустить
go test; - что такое горутина и гонка данных — это урок о гонках данных, и
go test -raceразобран там; - что такое замыкание и переменная цикла.
- табличные тесты, подтесты и запуск одного случая;
t.Parallel,t.Cleanup, переменная цикла и флаг-parallel;- кеш результатов,
t.Fatalв горутине,t.Setenvи ошибка в бенчмарке.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Что такое табличный тест?» — разминка; ответ «таблица случаев» верен, но интересно, что происходит с каждым случаем.
- «Как запустить один случай?» — проверяют, знает ли кандидат про подтесты.
- «Зачем
t.Parallelи что с ним может пойти не так?» — вопрос, на котором ответы расходятся сильнее всего. - «Почему второй
go testпрошёл мгновенно?» — про кеш. - «Что не так с этим бенчмарком?» — классическая задача: бенчмарк меряет не то.
- «Как тестировать код с внешними зависимостями?» — про интерфейсы и подмены.
Стержень у лестницы один: go test — не просто вызов функций по очереди.
У него свой порядок выполнения, свой кеш и свои отношения с компилятором, и
почти все ошибки — от того, что этот порядок считают очевидным.
База: табличный тест — это подтесты
Функция под тестом и её таблица:
func TestClamp(t *testing.T) {
tests := []struct {
name string
v, lo, hi int
want int
}{
{"внутри", 5, 0, 10, 5},
{"ниже", -3, 0, 10, 0},
{"выше", 42, 0, 10, 10},
{"на_границе", 10, 0, 10, 10},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Clamp(tc.v, tc.lo, tc.hi); got != tc.want {
t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tc.v, tc.lo, tc.hi, got, tc.want)
}
})
}
}t.Run превращает каждый случай в подтест с именем TestClamp/<имя>. У
подтеста свой результат, своя строка в отчёте — и его можно запустить один:
go test -v ./cases/table/
--- PASS: TestClamp
--- PASS: TestClamp/внутри
--- PASS: TestClamp/ниже
--- PASS: TestClamp/выше
--- PASS: TestClamp/на_границе
go test -v -run 'TestClamp/выше' ./cases/table/
--- PASS: TestClamp
--- PASS: TestClamp/выше
Ответ уровня Junior: табличный тест — срез случаев и одна проверка в цикле;
каждый случай запускают через t.Run, чтобы у него было имя, отдельный
результат и возможность запустить его одного через -run. t.Errorf
сообщает об ошибке и даёт тесту идти дальше, t.Fatalf — останавливает
текущий тест.
Механизм 1: параллельный подтест ждёт, пока родитель закончит
t.Parallel() говорит не «запусти меня сейчас в отдельной горутине», а
другое:
Parallel signals that this test is to be run in parallel with (and only with)
other parallel tests.
Parallel сообщает, что этот тест надо выполнять параллельно с другими параллельными тестами — и только с ними.
«Только с ними» — ключ. Подтест, вызвавший t.Parallel(), приостанавливается
и ждёт, пока тело родителя закончится: только тогда параллельные подтесты
идут вместе. А у тела родителя есть defer, и он срабатывает ровно в этот
момент. Тот же тест с ресурсом, который закрывают двумя способами:
2) ПАРАЛЛЕЛЬНЫЕ ПОДТЕСТЫ: defer ПРОТИВ t.Cleanup
────────────────────────────────────────────────
EVENT defer: ресурс открыт
EVENT defer: тело родителя закончилось
EVENT defer: ресурс закрыт
EVENT defer: подтест a работает, ресурс закрыт: true
EVENT defer: подтест b работает, ресурс закрыт: true
EVENT Cleanup: ресурс открыт
EVENT Cleanup: тело родителя закончилось
EVENT Cleanup: подтест a работает, ресурс закрыт: false
EVENT Cleanup: подтест b работает, ресурс закрыт: false
EVENT Cleanup: ресурс закрыт
С defer оба подтеста получили закрытый ресурс: соединение с базой,
временный каталог, тестовый сервер. Тест при этом может даже пройти —
например, если подтест не проверяет ошибку закрытого соединения.
t.Cleanup устроен под этот случай:
Cleanup registers a function to be called when the test (or subtest) and all
its subtests complete.
Cleanup регистрирует функцию, которая будет вызвана, когда тест (или подтест) и все его подтесты завершатся.
Отсюда правило: ресурс родителя, которым пользуются параллельные подтесты,
освобождают через t.Cleanup, а не через defer родителя. Встроенные
помощники вроде t.TempDir() устроены так же: каталог удаляется в Cleanup.
Речь именно о defer родителя. defer внутри самого подтеста срабатывает,
когда заканчивается тело подтеста, — после его работы, и для ресурса, который
подтест создал себе сам, он годится. А дождаться параллельных подтестов можно
и вторым способом — группой:
Run does not return until parallel subtests have completed, providing a way
to clean up after a group of parallel tests.
Run не возвращается, пока не завершились параллельные подтесты, — так можно прибрать за группой параллельных тестов.
t.Run("group", func(t *testing.T) {
for _, name := range []string{"a", "b"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
// работа с общим ресурсом
})
}
})
// сюда выполнение доходит, когда подтесты группы закончились1) ГРУППА t.Run И defer ВНУТРИ ПОДТЕСТА
───────────────────────────────────────
EVENT group: ресурс открыт
EVENT group: подтест a работает, ресурс закрыт: false
EVENT group: подтест b работает, ресурс закрыт: false
EVENT group: группа вернулась, ресурс закрыт
EVENT local: подтест a работает, свой ресурс закрыт: false
EVENT local: подтест a закрыл свой ресурс
EVENT local: подтест b работает, свой ресурс закрыт: false
EVENT local: подтест b закрыл свой ресурс
Оба подтеста группы видели ресурс открытым, и закрыл его обычный код после
t.Run("group", …). Локальный defer каждого подтеста сработал после работы
этого подтеста (прогон bench/gotest/boundaries.go, раздел 1).
Механизм 2: переменная цикла — дело строки go в go.mod
Классическая ошибка параллельных подтестов — замыкание на переменную цикла. С Go 1.22 спецификация говорит:
Each iteration has its own separate declared variable (or variables)
У каждой итерации своя отдельная объявленная переменная (или переменные).
Но какая семантика действует, решает не версия компилятора, а версия языка
модуля — строка go в go.mod. Один и тот же тест, один и тот же
компилятор go1.24.7, два модуля:
cases := []string{"a", "b", "c"}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
t.Parallel()
fmt.Printf("EVENT подтест %s видит tc = %s\n", t.Name(), tc)
})
}3) ПЕРЕМЕННАЯ ЦИКЛА В ПАРАЛЛЕЛЬНЫХ ПОДТЕСТАХ
────────────────────────────────────────────
модуль с go 1.21 в go.mod:
EVENT подтест TestCases/a видит tc = c
EVENT подтест TestCases/b видит tc = c
EVENT подтест TestCases/c видит tc = c
модуль с go 1.24 в go.mod — тот же код:
EVENT подтест TestCases/a видит tc = a
EVENT подтест TestCases/b видит tc = b
EVENT подтест TestCases/c видит tc = c
go vet в модуле с go 1.21:
loopvar/loopvar_test.go:16:73: loop variable tc captured by func literal
В старом модуле все три подтеста проверили последний случай таблицы — и
тест прошёл: ошибка не в результате, а в том, что две трети таблицы не
проверялись вовсе. Это механизм Механизма 1 в действии: подтесты ждут, пока
цикл родителя закончится, и к этому моменту единственная переменная tc
содержит последнее значение.
Практический вывод для собеседования: «в Go 1.22 это починили» — верно лишь
наполовину. Модуль, чей go.mod объявляет более старую версию, сохраняет
старую семантику даже на новом компиляторе. Строка tc := tc в старом коде —
не мусор, а защита; go vet в таком модуле её отсутствие ловит.
Механизм 3: сколько подтестов идёт одновременно
«t.Parallel — значит, все подтесты сразу» — естественное ожидание. Но число
одновременных тестов ограничено флагом:
By default, -parallel is set to the value of GOMAXPROCS.
По умолчанию -parallel равен значению GOMAXPROCS.
Четыре подтеста по 200 мс сна, все с t.Parallel():
4) СКОЛЬКО ПОДТЕСТОВ ИДЁТ ОДНОВРЕМЕННО
──────────────────────────────────────
четыре подтеста по 200 мс сна, все с t.Parallel()
-parallel по умолчанию (= GOMAXPROCS) 404 мс
-parallel=1 805 мс
-parallel=4 203 мс
Сон процессор не занимает, но флаг ограничивает число подтестов, а не
загрузку ядер: на машине с GOMAXPROCS=2 подтесты, которые ждут сеть или базу,
идут парами. Если такие тесты медленные, -parallel поднимают явно. И
обратное: на CI-машине с большим числом ядер параллельных тестов станет больше
— и те, что делят общий ресурс, начнут мешать друг другу.
Механизм 4: повторный go test тесты не запускает
5) КЕШ РЕЗУЛЬТАТОВ
──────────────────
go test ./cases/table/
ok deepengineering/bench/gotest/cases/table
go test ./cases/table/
ok deepengineering/bench/gotest/cases/table (cached)
go test -count=1 ./cases/table/
ok deepengineering/bench/gotest/cases/table
Второй запуск ничего не выполнял:
When this happens, go test prints '(cached)' in place of the elapsed time in the
summary line.
В этом случае go test печатает '(cached)' вместо затраченного времени в итоговой строке.
Кеш срабатывает, когда не изменились ни тестовый бинарник, ни флаги из
короткого «кешируемого» списка, ни файлы внутри модуля и переменные
окружения, которые тест читал. Больше go test ничего не видит: состояние
базы, внешнего API, файлов вне модуля в эту проверку не входит. Для теста,
который ходит в настоящую базу или сеть, это ловушка: «зелёный» результат
может быть снят час назад, до того, как база изменилась. Поэтому
интеграционные тесты запускают с -count=1 — это идиоматичный способ
отключить кеш.
Механизм 5: t.Fatal в горутине, t.Setenv и искусственное время
t.Fatal останавливает горутину, а не тест. t.Fatalf — это t.Errorf
плюс t.FailNow, а тот устроен так:
FailNow marks the function as having failed and stops its execution by calling
runtime.Goexit (which then runs all deferred calls in the current goroutine).
FailNow помечает функцию упавшей и останавливает её выполнение вызовом runtime.Goexit (который затем выполняет все отложенные вызовы текущей горутины).
runtime.Goexit завершает ту горутину, которая его вызвала. Если это
горутина, запущенная тестом, остановится она — а тест пойдёт дальше:
3) t.Fatal ИЗ ЧУЖОЙ ГОРУТИНЫ
────────────────────────────
fatalgo_test.go:16: ошибка в горутине
EVENT fatalgo: тест продолжает работу после t.Fatal
--- FAIL: TestFatalFromGoroutine (0.00s)
go vet:
cases/fatalgo/fatalgo_test.go:16:3: call to (*testing.T).Fatal from a non-test goroutine
Тест помечен упавшим, но код после ожидания горутины выполнился — то, что
t.Fatal должен был предотвратить, случилось. Документация требует вызывать
FailNow только из горутины самого теста, и go vet такой вызов ловит. Из
запущенных горутин ошибку передают каналом или сообщают через t.Errorf:
методы семейств Log и Error можно вызывать из нескольких горутин.
t.Setenv и t.Chdir несовместимы с t.Parallel. Переменные окружения и
текущий каталог — общие для всего процесса:
Because Setenv affects the whole process, it cannot be used in parallel tests
or tests with parallel ancestors.
Поскольку Setenv влияет на весь процесс, его нельзя использовать в параллельных тестах и в тестах с параллельными предками.
Это не совет, а проверка: пакет паникует.
2) t.Setenv В ПАРАЛЛЕЛЬНОМ ТЕСТЕ
────────────────────────────────
--- FAIL: TestSetenvParallel (0.00s)
panic: testing: test using t.Setenv or t.Chdir can not use t.Parallel [recovered]
Время в тестах подменяют — testing/synctest. Код внутри «пузыря»
synctest.Run живёт по искусственным часам: время идёт вперёд, когда все
горутины пузыря заблокированы. Сон на сутки:
4) testing/synctest (GOEXPERIMENT=synctest)
───────────────────────────────────────────
EVENT synctest: внутри пузыря прошло 24h0m0s
EVENT synctest: по настоящим часам прошло меньше секунды: true
Тесты таймаутов, повторов и тикеров перестают ждать по-настоящему и зависеть
от загрузки машины. На собеседовании честный ответ про synctest включает
версию: в go1.24.7 это эксперимент.
Глубже: бенчмарк, который меряет пустой цикл
Классическая задача с собеседования — «что не так с этим бенчмарком»:
func BenchmarkDiscard(b *testing.B) {
for i := 0; i < b.N; i++ {
work(i)
}
}work — чистая функция, несколько умножений и сдвигов. Результат не нужен
никому. Компилятор встраивает вызов и видит, что его результат не
используется, — и вычислять его незачем:
BenchmarkDiscard 0.32 нс/оп
BenchmarkSink 1.65 нс/оп
BenchmarkLoop 2.20 нс/оп
Sink / Discard: 5.2
Лучший из 5 прогонов. Что решил компилятор (-gcflags=-m):
bench_test.go:41:2: skip inlining within testing.B.loop for for loop
bench_test.go:27:7: inlining call to work
BenchmarkDiscard замерил пустой цикл. Это не общее правило «любой бенчмарк
с выброшенным результатом пуст»: вычисление выбросили потому, что work
чистая и встроилась, — после встраивания компилятор видит, что результат
никому не нужен. Что он решил в конкретном случае, показывает
-gcflags=-m, а не догадка. Два способа защитить замер:
b.Loop()(Go 1.24) — способ для нового кода: вызовы функций в теле циклаfor b.Loop() { ... }компилятор не выбрасывает.- Сохранить результат в переменную уровня пакета:
sink = work(i)— 1,65 нс. Это старый приём, из времён доb.Loop; в чужом коде его стоит узнавать.
The compiler never optimizes away calls to functions within the body of a "for
b.Loop() { ... }" loop.
Компилятор никогда не выбрасывает вызовы функций внутри тела цикла „for b.Loop() { ... }“.
Граница обещания — в той же документации: в функциях, которые вызывает цикл, оптимизации идут как обычно. А как компилятор выполняет обещание — уже устройство версии:
В выводе -gcflags=-m видно, как он этого добивается в go1.24.7: внутри
b.Loop вызов не встраивается (skip inlining within testing.B.loop).
Отсюда и 2,20 нс против 1,65: в b.Loop замерен ещё и сам вызов функции. Ни
один из двух вариантов не «правильный» вообще — они меряют немного разное, и
на собеседовании ценится как раз умение это сказать.
И последнее — про сами числа. «Лучший из N» годится для оценки порядка, но для вывода «стало быстрее» его мало: один удачный прогон ничего не говорит о разбросе. Десять прогонов тех же бенчмарков:
5) РАЗБРОС БЕНЧМАРКА: ДЕСЯТЬ ПРОГОНОВ
─────────────────────────────────────
мин медиана макс
BenchmarkDiscard 0.33 0.34 0.42 нс/оп, 10 прогонов
BenchmarkSink 1.61 1.76 1.95 нс/оп, 10 прогонов
«Лучший из N» отбрасывает разброс. Для выводов о скорости
сравнивают распределения серий — это делает benchstat.
Для сравнения «до и после» бенчмарк гоняют сериями (-count=10) и сравнивают
серии утилитой benchstat из golang.org/x/perf: она показывает центр серии,
разброс и то, значимо ли различие.
Как отвечать на собеседовании
Короткий ответ: тест — функция TestXxx(t *testing.T), табличный тест —
срез случаев и одна проверка в цикле, каждый случай — подтест t.Run, его
можно запустить отдельно через -run. t.Parallel пускает подтесты
параллельно, а освобождать общие ресурсы надо через t.Cleanup. Гонки ищут
go test -race, кеш обходят -count=1.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «что может пойти не так с t.Parallel» — порядок. «Параллельный
подтест ждёт, пока тело родителя закончится. defer родителя срабатывает
раньше подтестов — у меня оба подтеста получили закрытый ресурс. Поэтому
t.Cleanup, а не defer родителя, — или группа: t.Run не возвращается,
пока её параллельные подтесты не закончились».
На переменную цикла — go.mod. «С Go 1.22 у каждой итерации своя
переменная, но только в модулях, чей go.mod объявляет 1.22 и выше. В модуле
с go 1.21 на go1.24.7 все параллельные подтесты увидели последний случай».
На «почему тесты не ускорились» — -parallel. «Одновременно идёт не
больше, чем -parallel, а он по умолчанию равен GOMAXPROCS. Четыре спящих
подтеста при GOMAXPROCS=2 — 404 мс, а не 200».
На «что не так с бенчмарком» — выброшенный результат. «Результат чистой
встроенной функции никому не нужен, и компилятор выбросил вычисление: 0,32 нс
— это пустой цикл. Проверить — -gcflags=-m. В новом коде — for b.Loop();
старый приём — сохранить результат в переменную пакета. И сравнивать серии
через benchstat, а не лучший прогон».
На «второй прогон мгновенный» — кеш. «Это (cached): тесты не
запускались. Кеш не знает о состоянии базы и внешних сервисов, поэтому
интеграционные тесты — с -count=1».
Дальше спросят
Как тестировать код, который ходит в базу или во внешний сервис?
Через интерфейс, объявленный на стороне того, кто зависимостью пользуется: код принимает не конкретный клиент, а интерфейс из тех двух-трёх методов, которые ему нужны. В тесте передают свою реализацию — заглушку с заранее заданными ответами. Библиотека моков для этого не обязательна; встраивание интерфейса в структуру-заглушку позволяет объявить только нужные тесту методы (это разобрано в уроке о встраивании).
Для HTTP в стандартной библиотеке есть net/http/httptest: тестовый сервер
на loopback или ResponseRecorder для вызова обработчика без сети.
Чем t.Errorf отличается от t.Fatalf?
t.Errorf помечает тест упавшим и даёт ему идти дальше — удобно, когда
проверок несколько и хочется увидеть все расхождения сразу. t.Fatalf
помечает тест упавшим и останавливает его: дальше идти бессмысленно,
например, если не удалось открыть файл.
t.Fatalf — это t.Errorf плюс FailNow, а FailNow останавливает
текущую горутину через runtime.Goexit. Поэтому вызывать его надо из
горутины самого теста: из запущенной тестом горутины он остановит только её,
и тест пойдёт дальше (Механизм 5). t.Errorf из таких горутин звать можно.
Как найти гонку данных в тестах?
go test -race. Но детектор видит только то, что произошло в этом запуске:
тест, где всё делает одна горутина, гонку не породит, сколько его ни гоняй.
Полезные тесты для -race запускают несколько горутин на общем состоянии, и
их гоняют не один раз (-count). Граница детектора и его цена разобраны в
уроке о гонках данных.
И пакет testing сам помечает тест упавшим, если детектор нашёл гонку во
время его выполнения, — даже если все проверки теста прошли.
Какие функции, кроме TestXxx, запускает go test?
Ещё три вида. BenchmarkXxx(b *testing.B) — бенчмарки, идут только с флагом
-bench. FuzzXxx(f *testing.F) — фаззинг: цель получает случайно
сгенерированные входы; без флага -fuzz она прогоняется на начальном
корпусе (f.Add и testdata/fuzz) как обычный тест, а найденные падения
сохраняются туда же и становятся регрессионными тестами. ExampleXxx() —
пример: его вывод сравнивается с комментарием // Output:, и он же
показывается в документации пакета.
Зачем t.Helper()?
Чтобы ошибка указывала на место вызова помощника, а не на строку внутри него.
Помощник вроде assertEqual(t, got, want) вызывает t.Errorf у себя внутри;
без t.Helper() в отчёте будет строка помощника — одна и та же для всех
проверок. С t.Helper() — строка теста, где помощник вызван.
Частые заблуждения
t.Parallel запускает все подтесты одновременно
Одновременно идёт не больше, чем -parallel, а он по умолчанию равен GOMAXPROCS. Четыре подтеста по 200 мс сна при GOMAXPROCS=2 заняли 404 мс — двумя парами.
defer в тесте закроет ресурс после подтестов
Не после параллельных: они ждут, пока тело родителя закончится, а defer срабатывает именно тогда. В прогоне оба подтеста получили закрытый ресурс. После всех подтестов вызывается t.Cleanup.
С Go 1.22 переменная цикла в замыканиях больше не ловушка
Только в модулях, чей go.mod объявляет 1.22 и выше. В модуле с go 1.21 на компиляторе go1.24.7 все три параллельных подтеста увидели последний случай таблицы — и тест прошёл.
Если go test прошёл, код только что проверен
Не если в строке стоит (cached): тесты не запускались, показан сохранённый результат. Для тестов, зависящих от внешнего мира, это особенно опасно; обойти кеш — -count=1.
Бенчмарк функции меряет эту функцию
Не всегда: в прогоне компилятор встроил чистую функцию, увидел, что результат не нужен, и не стал её вычислять — 0,32 нс против 1,65 нс, когда результат сохранён. В новом коде пишут for b.Loop().
t.Fatal из любой горутины останавливает тест
Он останавливает горутину, которая его вызвала: FailNow вызывает runtime.Goexit. В прогоне тест после t.Fatal в своей горутине продолжил работу, а go vet такой вызов поймал.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
func TestDB(t *testing.T) {
defer fmt.Println("defer: closed")
t.Cleanup(func() { fmt.Println("Cleanup: closed") })
t.Run("a", func(t *testing.T) {
t.Parallel()
fmt.Println("subtest a")
})
fmt.Println("end of body")
}Практика · оцените
Проверка знаний
Тест открывает соединение с базой, закрывает его через defer и запускает три подтеста с t.Parallel(). Что увидят подтесты?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- **Тест в Go — функция
TestXxx(t *testing.T), а табличный тест — это данные плюс одна проверка.** Случаи лежат в срезе структур, проверка написана один раз, и каждый случай запускается подтестомt.Runпод своим именем. Поэтому один случай можно запустить отдельно:go test -run 'TestClamp/выше'. t.Parallel()меняет порядок выполнения, и это главный источник ошибок. Параллельный подтест ждёт, пока тело родителя закончится.deferродителя срабатывает ровно тогда — до подтестов: в прогоне оба подтеста увидели уже закрытый ресурс.t.Cleanupвызывается после всех подтестов, аt.Runгруппы не возвращается, пока её параллельные подтесты не закончились. И переменная цикла в параллельных подтестах зависит от строкиgoвgo.mod: в модуле сgo 1.21все три подтеста увидели последний случай таблицы.- Дальше — то, что отличает понимание от пересказа.
t.Parallelне значит «все сразу»: одновременно идёт не больше, чем-parallel, а он по умолчанию равен GOMAXPROCS. Четыре подтеста по 200 мс сна при GOMAXPROCS=2 заняли 404 мс, а не 200. Повторныйgo testбез изменений не запускает тесты, а печатает(cached)— даже если база, в которую ходит тест, с тех пор изменилась. А бенчмарк, который выбрасывает результат чистой встроенной функции, в прогоне замерил пустой цикл: 0,32 нс против 1,65 нс, когда результат сохранён.
На самом деле
- Одновременно идёт не больше, чем
-parallel, а он по умолчанию равен GOMAXPROCS. Четыре подтеста по 200 мс сна при GOMAXPROCS=2 заняли 404 мс — двумя парами. - Не после параллельных: они ждут, пока тело родителя закончится, а
deferсрабатывает именно тогда. В прогоне оба подтеста получили закрытый ресурс. После всех подтестов вызываетсяt.Cleanup. - Только в модулях, чей
go.modобъявляет 1.22 и выше. В модуле сgo 1.21на компиляторе go1.24.7 все три параллельных подтеста увидели последний случай таблицы — и тест прошёл. - Не если в строке стоит
(cached): тесты не запускались, показан сохранённый результат. Для тестов, зависящих от внешнего мира, это особенно опасно; обойти кеш —-count=1. - Не всегда: в прогоне компилятор встроил чистую функцию, увидел, что результат не нужен, и не стал её вычислять — 0,32 нс против 1,65 нс, когда результат сохранён. В новом коде пишут
for b.Loop(). - Он останавливает горутину, которая его вызвала:
FailNowвызываетruntime.Goexit. В прогоне тест послеt.Fatalв своей горутине продолжил работу, аgo vetтакой вызов поймал.
Что разобрано
- Что здесь на самом деле спрашивают
- База: табличный тест — это подтесты
- Механизм 1: параллельный подтест ждёт, пока родитель закончит
- Механизм 2: переменная цикла — дело строки `go` в `go.mod`
- Механизм 3: сколько подтестов идёт одновременно
- Механизм 4: повторный `go test` тесты не запускает
- Механизм 5: t.Fatal в горутине, t.Setenv и искусственное время
- Глубже: бенчмарк, который меряет пустой цикл
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Пакет testing — T.Parallel и CleanupОфициальная документация. Что обещает t.Parallel: «Parallel signals that this test is to be run in parallel with (and only with) other parallel tests» (Parallel сообщает, что этот тест надо выполнять параллельно с другими параллельными тестами — и только с ними). И когда вызывается Cleanup: «Cleanup registers a function to be called when the test (or subtest) and all its subtests complete» (Cleanup регистрирует функцию, которая будет вызвана, когда тест (или подтест) и все его подтесты завершатся).https://pkg.go.dev/testing
- Пакет testing — подтесты, FailNow и SetenvОфициальная документация. Второй способ дождаться параллельных подтестов: «Run does not return until parallel subtests have completed, providing a way to clean up after a group of parallel tests» (Run не возвращается, пока не завершились параллельные подтесты, — так можно прибрать за группой параллельных тестов). Что делает t.Fatal: «FailNow marks the function as having failed and stops its execution by calling runtime.Goexit» (FailNow помечает функцию упавшей и останавливает её выполнение вызовом runtime.Goexit). И граница t.Setenv: «Because Setenv affects the whole process, it cannot be used in parallel tests or tests with parallel ancestors» (Поскольку Setenv влияет на весь процесс, его нельзя использовать в параллельных тестах и в тестах с параллельными предками).https://pkg.go.dev/testing#hdr-Subtests_and_Sub_benchmarks
- Пакет testing — B.Loop, go1.24Официальная документация. Почему b.Loop защищает замер: «The compiler never optimizes away calls to functions within the body of a "for b.Loop() { ... }" loop» (Компилятор никогда не выбрасывает вызовы функций внутри тела цикла „for b.Loop() { ... }“). И граница этой защиты: «Optimizations are performed as usual in any functions called by the loop» (В функциях, которые вызывает цикл, оптимизации выполняются как обычно).https://pkg.go.dev/testing#B.Loop
- Команда go — флаги тестирования и кешОфициальная документация. Сколько параллельных тестов идёт одновременно: «By default, -parallel is set to the value of GOMAXPROCS» (По умолчанию -parallel равен значению GOMAXPROCS). Что такое «(cached)»: «When this happens, go test prints '(cached)' in place of the elapsed time in the summary line» (В этом случае go test печатает '(cached)' вместо затраченного времени в итоговой строке). И как кеш обойти: «The idiomatic way to disable test caching explicitly is to use -count=1» (Идиоматичный способ явно отключить кеш тестов — -count=1).https://pkg.go.dev/cmd/go#hdr-Testing_flags
- Спецификация Go — For statementsОфициальная документация. Новая семантика переменной цикла: «Each iteration has its own separate declared variable (or variables)» (У каждой итерации своя отдельная объявленная переменная (или переменные)) — с пометкой Go 1.22. Какая семантика действует, решает версия языка модуля, то есть строка go в go.mod; урок проверяет это прогоном.https://go.dev/ref/spec#For_clause
- Пакет testing/synctest, go1.24.7Официальная документация. Статус пакета в версии, на которой сделан прогон: «This package only exists when using Go compiled with GOEXPERIMENT=synctest. It is experimental, and not subject to the Go 1 compatibility promise» (Пакет существует, только если Go собран с GOEXPERIMENT=synctest. Он экспериментальный и не подпадает под обещание совместимости Go 1). Статус в более новых версиях — по их документации.https://pkg.go.dev/testing/synctest@go1.24.7