Deep Engineering
Средний·Опубликовано·35 МИН

Тестирование в 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 и ошибка в бенчмарке.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Что такое табличный тест?» — разминка; ответ «таблица случаев» верен, но интересно, что происходит с каждым случаем.
  2. «Как запустить один случай?» — проверяют, знает ли кандидат про подтесты.
  3. «Зачем t.Parallel и что с ним может пойти не так?» — вопрос, на котором ответы расходятся сильнее всего.
  4. «Почему второй go test прошёл мгновенно?» — про кеш.
  5. «Что не так с этим бенчмарком?» — классическая задача: бенчмарк меряет не то.
  6. «Как тестировать код с внешними зависимостями?» — про интерфейсы и подмены.

Стержень у лестницы один: go test — не просто вызов функций по очереди. У него свой порядок выполнения, свой кеш и свои отношения с компилятором, и почти все ошибки — от того, что этот порядок считают очевидным.

База: табличный тест — это подтесты

Функция под тестом и её таблица:

GO
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, t.Parallel, t.Cleanup и флагов go test — документация пакета testing и команды go. Числа ниже — прогоны bench/gotest на одной машине, GOMAXPROCS=2.

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 сообщает, что этот тест надо выполнять параллельно с другими параллельными тестами — и только с ними.

— Пакет testing — T.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 регистрирует функцию, которая будет вызвана, когда тест (или подтест) и все его подтесты завершатся.

— Пакет testing — 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 не возвращается, пока не завершились параллельные подтесты, — так можно прибрать за группой параллельных тестов.

— Пакет testing — подтесты
GO
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 — For statements
контракт языкаСемантика переменной цикла — спецификация Go с пометкой Go 1.22 и версия языка модуля из go.mod, а не версия компилятора. Вывод ниже — прогон bench/gotest/internals.go, раздел 3, компилятор go1.24.7.

Но какая семантика действует, решает не версия компилятора, а версия языка модуля — строка go в go.mod. Один и тот же тест, один и тот же компилятор go1.24.7, два модуля:

GO
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.

— Команда go — флаги тестирования
наблюдение замераbench/gotest/internals.go, раздел 4, go1.24.7 linux/amd64, GOMAXPROCS=2; лучший из трёх запусков тестового бинарника, без времени сборки.

Четыре подтеста по 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 — кеш тестов

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

Механизм 5: t.Fatal в горутине, t.Setenv и искусственное время

наблюдение замераbench/gotest/boundaries.go, разделы 2–4, go1.24.7 linux/amd64, GOMAXPROCS=2.

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 (который затем выполняет все отложенные вызовы текущей горутины).

— Пакет testing — T.FailNow

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 влияет на весь процесс, его нельзя использовать в параллельных тестах и в тестах с параллельными предками.

— Пакет testing — T.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 живёт по искусственным часам: время идёт вперёд, когда все горутины пузыря заблокированы. Сон на сутки:

деталь реализации · Go 1.24В go1.24.7 пакет testing/synctest — эксперимент: он есть только при GOEXPERIMENT=synctest и не подпадает под обещание совместимости Go 1. Статус и API в более новых версиях — по их документации; урок их не проверял.
4) testing/synctest (GOEXPERIMENT=synctest)
───────────────────────────────────────────
  EVENT synctest: внутри пузыря прошло 24h0m0s
  EVENT synctest: по настоящим часам прошло меньше секунды: true

Тесты таймаутов, повторов и тикеров перестают ждать по-настоящему и зависеть от загрузки машины. На собеседовании честный ответ про synctest включает версию: в go1.24.7 это эксперимент.

Глубже: бенчмарк, который меряет пустой цикл

наблюдение замераbench/gotest/internals.go, раздел 6, go1.24.7 linux/amd64; лучший из пяти прогонов -bench, разброс десяти прогонов — bench/gotest/boundaries.go, раздел 5. Наносекунды не переносятся; переносится то, что выброшенный результат дал в разы меньшее время.

Классическая задача с собеседования — «что не так с этим бенчмарком»:

GO
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() { ... }“.

— Пакет testing — B.Loop

Граница обещания — в той же документации: в функциях, которые вызывает цикл, оптимизации идут как обычно. А как компилятор выполняет обещание — уже устройство версии:

деталь реализации · Go 1.24Обещание — документация B.Loop. Механизм — поведение компилятора go1.24.7: вызовы в теле цикла не встраиваются. В других версиях механизм может быть другим; урок проверял только go1.24.7.

В выводе -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 такой вызов поймал.

Практика

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

Практика · что напечатает

Тест закрывает ресурс через defer и через t.Cleanup, а внутри — один параллельный подтест. Тест запущен настоящим пакетом testing. Что напечатает этот код и в каком порядке?
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")
}

Практика · оцените

Шесть подтестов по 100 мс сна, все с t.Parallel(), машина с GOMAXPROCS=2. Во сколько раз прогон с флагами по умолчанию быстрее, чем с -parallel=1?
раза

Проверка знаний

Вопрос 1 из 5

Тест открывает соединение с базой, закрывает его через defer и запускает три подтеста с t.Parallel(). Что увидят подтесты?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Пакет 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
  2. Пакет 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
  3. Пакет 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
  4. Команда 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
  5. Спецификация 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
  6. Пакет 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