go test собирает пакет вместе с его файлами _test.go в тестовый бинарник и запускает в нём каждую функцию func TestXxx(t *testing.T); файлы и функции с другими именами игнорируются.
**./...: все пакеты ниже текущего каталога; без него тестируется только пакет, в котором вы находитесь.
-run 'regexp'**: только тесты, чьё имя подходит под выражение; -run 'TestParse/empty' выбирает один подтест, -run '^$' не запускает ни одного (удобно вместе с -bench).
**-v**: печатает имя каждого теста и его вывод t.Log, а не только падения.
**-count=1**: заставляет прогнать тесты по-настоящему. Если переданы пакеты, успешные результаты кэшируются, и неизменённый пакет печатает ok example.com/store (cached). Кэш видит только файлы внутри модуля и переменные окружения, которые тест прочитал, но не базу и не сеть.
**-short, -race, -timeout**: testing.Short() позволяет медленным тестам пропуститься; -race собирает с детектором гонок; -timeout (по умолчанию 10m) убивает зависший бинарник и печатает стеки всех горутин.
Перед тестами go test ещё запускает небольшой набор самых точных проверок go vet, поэтому сломанный формат Printf роняет прогон раньше любого теста.
GoMiddle
Тестирование и бенчмарки
Табличные тесты, httptest, бенчмарки, фаззинг, -race в CI
Закрыто вопросов
0 / 25
- 0Повторить
- 4Часто спрашивают
Вопросы
Табличный тест описывает кейсы срезом структур (имя, вход, ожидаемый результат) и прогоняет по каждому одну и ту же проверку; t.Run превращает каждый кейс в подтест со своим именем и своим *testing.T.
Имя кейса приклеивается к имени родителя через слеш (TestParseDuration/no_unit), и по падению сразу видно, какой кейс сломался.
go test -run 'TestParseDuration/no_unit' перезапустит ровно этот кейс; в плоском цикле пришлось бы править исходник.
t.Fatal внутри подтеста завершает только этот подтест, и за один прогон видны все сломанные кейсы. В плоском цикле он завершает всю функцию.
Новый кейс занимает одну строку данных, и на ревью входные данные читаются как таблица, а не как скопированный код.
t.Error помечает тест упавшим и даёт ему продолжить работу; t.Fatal помечает его упавшим и сразу останавливает: после этой строки ничего не выполняется. t.Error сводится к t.Log + t.Fail, а t.Fatal к t.Log + t.FailNow.
t.Fatal нужен, когда продолжать бессмысленно или приведёт к панике: Open вернул ошибку, значит, дескриптор файла равен nil.
t.Error подходит для независимых проверок: три неверных поля видны за один прогон, а не за три.
t.FailNow останавливает тест через runtime.Goexit, поэтому отложенные вызовы и функции t.Cleanup всё равно выполнятся.
Вызывайте t.Fatal, t.FailNow и t.Skip только из горутины, в которой идёт тест: в запущенной вами горутине они завершат эту горутину, а не тест.
t.Helper() заставляет падение внутри хелпера указывать на строку, где хелпер вызвали, а не на его собственный t.Fatalf; интерфейс testing.TB реализуют *testing.T, *testing.B и *testing.F, поэтому один хелпер работает и в тестах, и в бенчмарках, и в фазз-тестах.
Без него каждое падение выглядит как helpers_test.go:14, и приходится гадать, какой из двадцати вызовов сломался.
Вызывайте его первым оператором хелпера, до того как что-либо может упасть.
Замыкание внутри хелпера считается отдельной функцией: ему нужен свой вызов t.Helper(), иначе строка падения снова укажет внутрь хелпера.
package foo_test объявляет внешний тестовый пакет: он лежит в том же каталоге и попадает в тот же тестовый бинарник, но компилируется отдельно, поэтому видит только экспортированный API foo и может импортировать пакеты, которые сами импортируют foo.
Тест как настоящий потребитель: если тесту нужно неэкспортированное поле, скорее всего, в публичном API чего-то не хватает.
Нет циклов импорта: пакет хелперов foo/footest, который импортирует foo, можно использовать из foo_test, но не из тестов в package foo.
Цена: нет доступа к неэкспортированным идентификаторам. Обойти это можно через export_test.go: файл в package foo, который переэкспортирует нужное (var ParseKey = parseKey). Это файл _test.go, поэтому в прод он не попадает.
Смешивать оба варианта в одном каталоге нормально: внутренние тесты для алгоритма, а внешние для поведения.
Функция ExampleXxx() в файле _test.go служит исполняемой документацией: go test запускает её, перехватывает стандартный вывод и сравнивает с комментарием // Output:, а pkg.go.dev показывает её рядом с Xxx.
Имя привязывает пример к идентификатору: к пакету (Example), к функции (ExampleFormat), к методу (ExampleWallet_Balance), а суффикс со строчной буквы, например ExampleFormat_negative, позволяет завести несколько примеров.
Пробелы в начале и в конце не учитываются. // Unordered output: сравнивает строки как множество (для обхода map).
Без комментария с выводом пример компилируется, но не запускается.
У него нет *testing.T, и он проверяет только то, что печатает: хорош, чтобы показать API, но не для граничных случаев.
t.Cleanup(f) привязывает уборку к тесту, а не к текущей функции, поэтому хелпер сам освобождает то, что открыл, вместо того чтобы отдавать вызывающему func(), о котором надо помнить.
defer в хелпере срабатывает при выходе из хелпера, а это слишком рано для базы, которую он только что открыл. t.Cleanup выполняется после завершения теста и всех его подтестов, в обратном порядке регистрации.
Параллельные подтесты стартуют только после возврата из функции родителя, поэтому defer в родителе уже закрыл то, что им нужно; t.Cleanup их дожидается.
В Go 1.24 появился t.Context(): контекст, который отменяется прямо перед запуском функций очистки; это простой способ остановить воркеры, запущенные тестом.
defer по-прежнему лучше для всего, что живёт в рамках текущей функции: файл, открытый в одной итерации цикла, мьютекс, resp.Body.
func TestMain(m *testing.M) заменяет сгенерированный main тестового бинарника пакета: он выполняется один раз на пакет, делает подготовку, вызывает m.Run(), чтобы прогнать все тесты, и затем убирает за собой.
Он нужен для дорогого или глобального состояния на весь процесс: один контейнер Postgres на пакет, goleak.VerifyTestMain(m), логгер пакета. Фикстурам отдельных тестов место в хелперах с t.Cleanup.
Когда TestMain стартует, флаги ещё не разобраны: вызовите flag.Parse() сами, если подготовка читает testing.Short() или свой флаг.
Начиная с Go 1.15 достаточно просто вернуться: обёртка сама передаст результат m.Run() в os.Exit. Если вызвать os.Exit(m.Run()) самому, отложенная уборка не выполнится.
Он выполняется в каждом процессе пакета, поэтому go test ./... по 30 пакетам поднимет 30 контейнеров, причём параллельно. Общий экземпляр или флаг -p держат это под контролем.
t.Fatalf останавливает только ту горутину, которая его вызвала (здесь воркер, а не тест), потому что выходит через runtime.Goexit. wg.Done() после него не выполняется, и wg.Wait() блокируется, пока -timeout не убьёт бинарник.
Документация testing разрешает FailNow, Fatal* и Skip* только в горутине, где идёт тест. Error* и Log* безопасны из любой горутины.
defer wg.Done() вылечил бы зависание, но вызов всё равно нарушал бы этот контракт.
Если воркер переживёт свой тест, поздний t.Errorf упадёт с паникой Log in goroutine after TestFetch has completed, и она может уронить весь пакет.
go vet это ловит: call to (*testing.T).Fatalf from a non-test goroutine.
Решение: воркеры возвращают ошибки (через канал или errgroup), а проверки делает горутина теста.
До Go 1.22 у цикла range была одна переменная tc, которой присваивалось новое значение на каждой итерации. t.Parallel() ставит каждый подтест на паузу до возврата из функции родителя, а к этому моменту цикл уже закончился, и все замыкания читают последний кейс.
В Go 1.22 на каждой итерации создаётся новая переменная, и каждое замыкание захватывает свой tc.
Правило определяется строкой go в go.mod, а не установленным тулчейном: если обновить Go и не поменять эту строку, останется старое поведение.
Раньше лечили строкой tc := tc в начале тела цикла; теперь она лишняя, и линтеры вроде copyloopvar её подсвечивают.
Из-за той же паузы всё, что родитель откладывает через defer, выполняется раньше его параллельных подтестов, поэтому для общих ресурсов используйте t.Cleanup.
Подтест выходит при любой ошибке, не глядя на tc.wantErr, поэтому неожиданная ошибка делает валидный кейс зелёным. Если ParsePort начнёт отвергать все порты, все три кейса останутся зелёными.
Почему: return задумывался для кейсов с ошибкой, но ничто его с ними не связывает: колонка wantErr нигде не читается. Тест проверяет только успешные значения, а функция, которая падает на всём, их просто не выдаёт.
Исправление: сравнивать ошибку с кейсом: (err != nil) != tc.wantErr падает и на неожиданной ошибке, и на отсутствующей. t.Fatalf останавливает подтест, потому что после неожиданной ошибки got ничего не значит. Для конкретной ошибки положите в таблицу sentinel-значение и проверяйте его через errors.Is.
Покрытие не страхует: оно считает выполненные строки, а не проверенные. Тело теста выполняется целиком и остаётся зелёным; падение покрытия ParsePort оказывается лишь побочным эффектом именно этой поломки, а ошибка, возвращающая неверную ошибку на валидном вводе, оставила бы покрытие прежним.
Как поймать: увидеть, как каждый новый тест хотя бы раз падает: сломайте код намеренно или запустите инструмент мутационного тестирования вроде gremlins и убедитесь, что какой-то кейс краснеет. Колонка таблицы, которую тело теста не читает, служит тревожным сигналом на ревью.
Рекордер тестирует хендлер, а тестовый сервер клиента: httptest.NewRecorder создаёт фейковый http.ResponseWriter, который передаётся прямо в ServeHTTP, а httptest.NewServer поднимает настоящий HTTP-сервер на loopback-порту.
Сторона хендлера: h.ServeHTTP(rec, httptest.NewRequest("POST", "/orders", body)), затем проверки по rec.Result(). Ни сокета, ни клиента, быстро.
Сторона клиента: ts := httptest.NewServer(h), и тестируемый код ходит на ts.URL, поэтому базовый URL должен передаваться извне. Хендлер может вернуть 503, зависнуть или прислать битый JSON, и это самый дешёвый способ проверить ретраи и таймауты. ts.Client() уже доверяет сертификату NewTLSServer.
Что рекордер пропускает: сеть: нет автоматического Content-Length, нет chunked-передачи, HTTP/2 и таймаутов, и он не реализует http.Hijacker, поэтому апгрейд до WebSocket требует настоящего сервера.
Сервис должен зависеть от небольших интерфейсов, объявленных там, где их используют, а в тесте ему передаётся двойник: чаще всего фейк, написанный руками, а сгенерированный мок (gomock, mockery) нужен, только когда сами вызовы и есть проверяемое поведение.
Stub возвращает заготовленные ответы. Fake представляет собой рабочую облегчённую реализацию, например хранилище на map в памяти. Spy записывает вызовы, чтобы проверить их потом. Mock заранее программируется ожиданиями и роняет тест, если вызовы не совпали.
В Go фейк занимает несколько строк: структура с map или структура с полями-функциями, чтобы каждый тест переопределял один метод.
Сгенерированные моки (go.uber.org/mock, поддерживаемый форк заархивированного golang/mock, или mockery с testify/mock) проверяют порядок и аргументы вызовов. Это жёстко привязывает тесты к реализации: рефакторинг, сохраняющий поведение, их ломает.
Фейки лишь повторяют ваши предположения о другой стороне. Проверяйте их отдельно: httptest.NewServer для HTTP API, настоящая база в интеграционном прогоне для SQL.
testify даёт в основном ассерты: require останавливает тест, как t.Fatal, assert продолжает, как t.Error.
Внедрите часы: код получает «сейчас» из зависимости (поля func() time.Time или интерфейса с одним методом), прод передаёт time.Now, а тест подставляет фейк, который сам двигает вперёд. Никаких слипов, никакой нестабильности.
time.Sleep в тесте делает ставку на то, насколько загружена CI-машина: под нагрузкой двух секунд не хватит, а десять замедлят каждый прогон.
Минимальным швом служит поле now func() time.Time, которое при nil по умолчанию становится time.Now.
Та же идея работает для другой недетерминированности: источника случайных чисел, генератора ID, http.RoundTripper.
Когда тайминг спрятан в горутинах и таймерах, куда часы не внедришь (time.AfterFunc, Ticker), testing/synctest из Go 1.25 подменяет время без изменений в коде.
Храните ожидаемый вывод в testdata/ и перезаписывайте его только под своим флагом -update: обычный прогон сравнивает с закоммиченным файлом, а перегенерация видна на код-ревью как дифф.
Объявите var update = flag.Bool("update", false, "rewrite golden files"). Под флагом нужно записать файл и выйти, иначе прочитать и сравнить. Никогда и то и другое в одном прогоне.
Ловушка кроется в перезаписи при несовпадении: тест, который сам себя чинит, не может упасть. Отсутствующий golden-файл тоже считается падением, а не поводом его создать.
Перед сравнением нормализуйте метки времени, абсолютные пути и вывод, зависящий от порядка обхода map, иначе вместо этого получится нестабильный тест.
Каталог testdata инструмент go не считает пакетом, поэтому он служит привычным местом для фикстур.
Логику тестируйте юнит-тестами с фейками; интеграционные тесты пишите на то, что код лишь предполагает о другой стороне: SQL, миграции, транзакции, формат данных клиента. Закройте их тегом //go:build integration, чтобы go test ./... их не компилировал, а CI запускал с -tags=integration.
Интеграционный тест окупается там, где фейк лишь повторил бы ваши предположения: запрос, который неверен только в настоящем SQL, уникальное ограничение, уровень изоляции.
Директива должна стоять выше строки package, перед ней допустимы только пустые строки и комментарии //; после неё оставьте пустую строку, чтобы она не стала документацией пакета. Ниже package или с пробелом (// go:build) она молча игнорируется, и файл собирается везде.
Вариант полегче: if testing.Short() { t.Skip("needs postgres") } и go test -short; один набор файлов, но тяжёлые зависимости всё равно компилируются.
Саму базу testcontainers-go поднимает из теста как одноразовый Postgres; подойдёт и сервис-контейнер в CI. Дайте каждому тесту свою схему или транзакцию, которая откатывается в t.Cleanup, чтобы тесты не зависели друг от друга.
Покрытие показывает, какие операторы тесты выполнили, а не то, проверили ли что-нибудь. Оно может доказать, что код не протестирован, но никогда не докажет, что выполнившийся код верен.
Тест без единого ассерта покрывает ровно те же строки, что и хороший: достаточно вызвать функцию.
Операторы и входные данные не одно и то же: однострочная функция с тремя граничными условиями покрыта на 100% одним кейсом.
При жёстком пороге последние проценты приходятся на ветки ошибок, до которых надо добираться через имитацию отказа диска, и люди пишут тесты без проверок или удаляют обработку ошибок. Многие команды берут целью около 70–80% плюс ревью непокрытого.
Как смотреть: go test -coverprofile=cover.out ./..., затем go tool cover -html=cover.out покажет непокрытые строки; -coverpkg=./... учитывает код, вызванный из других пакетов. С Go 1.20 go build -cover измеряет покрытие целого бинарника, который гоняют интеграционные тесты.
Бенчмарк представляет собой функцию func BenchmarkXxx(b *testing.B) в файле _test.go, запускается через go test -bench=Xxx; фреймворк повторяет тело, пока прогон не займёт -benchtime (по умолчанию 1s), и выдаёт время на одну операцию.
**b.N**: классический цикл for i := 0; i < b.N; i++. Функцию вызывают несколько раз с растущим b.N (1, затем всё большие оценки, максимум 1e9), пока она не заполнит -benchtime. Подготовку перед циклом нужно исключить через b.ResetTimer().
**b.Loop()** (Go 1.24): for b.Loop() { ... }. Функция выполняется один раз на -count, замеряется только цикл, поэтому подготовке и очистке не нужен ResetTimer, а компилятор не даёт вызовам в теле цикла исчезнуть при оптимизации.
Память: b.ReportAllocs() или -benchmem добавляют B/op и allocs/op, часто более стабильный сигнал.
Устройство: -run '^$' пропускает юнит-тесты; b.Run даёт под-бенчмарки на каждый размер входа; -cpuprofile показывает, куда уходит время.
Фазз-тест формулирует свойство, а не пример: f.Fuzz запускает функцию на мутациях исходных входов, направляемых покрытием кода, и добирается до данных, которые никто бы не догадался записать.
Форма: func FuzzParseHeader(f *testing.F), сиды через f.Add(...), затем ровно один f.Fuzz(func(t *testing.T, s string) { ... }). Аргументы могут быть только []byte, string, bool, rune, byte и числовыми типами.
Без -fuzz он работает как обычный тест по сидам, за миллисекунды. go test -fuzz=FuzzParseHeader -fuzztime=60s занимается мутациями и записывает падающий вход в testdata/fuzz/; закоммитьте этот файл, и обычный прогон будет воспроизводить баг всегда.
Он окупается для кода, который принимает недоверенный вход (парсеров, декодеров, обработчиков протоколов), и когда можно проверить что-то верное для любого входа: Parse(Format(x)) == x, совпадение с эталонной реализацией или просто «никогда не паникует».
Проверьте, какие горутины ещё живы в конце теста: go.uber.org/goleak делает это через defer goleak.VerifyNone(t) в отдельном тесте или goleak.VerifyTestMain(m) для всего пакета и роняет тест со стеками оставшихся горутин.
Обычные ассерты утечку не видят: функция возвращает верное значение, а её воркер навсегда заблокирован на канале. В сервере это одна горутина и её память на каждый запрос.
goleak какое-то время повторяет проверку, прежде чем упасть, поэтому горутины, которые уже завершаются, не дают ложных падений.
Известные долгоживущие горутины, например фоновый флашер библиотеки, исключаются через goleak.IgnoreTopFunction(...) или goleak.IgnoreCurrent().
VerifyNone не работает с t.Parallel(): горутины других тестов выглядят как утечка. В таком случае используйте VerifyTestMain.
Без зависимостей можно сравнить runtime.NumGoroutine() до и после с циклом повторов, но это грубее и без стеков.
Пустой цикл. Компилятор встроил scale, увидел, что результат не используется, и выбросил тело; 0.25 ns/op означает примерно один такт процессора, стоимость инкремента i.
Признаки: время около одного такта, b.N, упёршийся в потолок 1e9 для реальной работы, или число, которое не меняется при удвоении входа. go build -gcflags=-m покажет can inline scale.
Исправление до Go 1.24: сделать результат наблюдаемым: накапливать его внутри цикла и после цикла записать сумму в экспортируемую переменную пакета var Sink. Сохранять только последний результат недостаточно, потому что остальные итерации всё ещё можно выбросить.
Константные входы: scale(3, 3) может свернуться в константу, даже если результат сохраняется. Берите входные данные из среза или переменной.
Go 1.24+: for b.Loop() { scale(x, 3) }: тулчейн сохраняет аргументы и результаты вызовов в теле цикла живыми (сейчас за счёт того, что не встраивает эти вызовы). Используйте b.Loop или b.N, но не оба в одном бенчмарке.
slices.Sort сортирует data на месте, поэтому перемешанный вход видит только первая итерация; все следующие сортируют уже отсортированный срез, и pdqsort справляется с ним почти за линейное время. 0,1 мс соответствуют лучшему случаю, примерно в 100 раз быстрее настоящей сортировки.
Почему: b.Loop вызывает функцию бенчмарка один раз и повторяет только тело цикла, поэтому подготовка над циклом выполняется однажды. У классического цикла по b.N тот же изъян внутри каждого вызова. Всё, что меняет свой вход (сортировка, дедупликация на месте, reader, вычитывающий буфер), меняет вход следующей итерации.
Исправление: держать нетронутый data и в начале каждой итерации copy его в заранее выделенный buf. Копирование 100 000 int стоит около 30 мкс против примерно 10 мс сортировки, то есть меньше 1%; если нужно, измерьте копирование отдельным бенчмарком и вычтите.
**Не b.StopTimer вокруг копирования**: b.StartTimer при каждом вызове читает статистику памяти рантайма, и на коротком теле эти накладные расходы искажают результат сильнее, чем само копирование.
Как поймать: результат не меняется, когда фикстура из случайной становится отсортированной, или сильно расходится с одним замером вручную. Проверьте после цикла, что фикстура не тронута: например, slices.IsSorted(data) всё ещё false.
Один прогон на сторону даёт одну выборку. Прогоните каждую сторону раз десять в файл, и пусть benchstat скажет, больше ли 9%, чем собственный шум машины.
Шум: изменение частоты процессора, троттлинг, другие процессы и даже раскладка кода сдвигают результат на несколько процентов между двумя прогонами одного и того же бинарника.
Порядок действий: go test -run '^$' -bench=Encode -benchmem -count=10 > old.txt, вносите изменение, та же команда в new.txt, затем benchstat old.txt new.txt. Знак ~ означает, что разница незначима при p<0.05, и это ответ, а не сломанный инструмент.
Аллокации: allocs/op намного стабильнее, чем ns/op. Четыре аллокации, превратившиеся в одну, служат реальным доказательством, даже если в колонке времени стоит ~.
Окружение: тихая машина на питании от розетки, одна и та же версия Go с обеих сторон и прогоны близко по времени; на общих CI-раннерах что-то значат только большие разницы.
synctest.Test (Go 1.25) запускает функцию теста в «пузыре» с фейковыми часами: время внутри идёт только тогда, когда все горутины пузыря заблокированы, поэтому минутный таймаут проходит мгновенно и детерминированно, а synctest.Wait() ждёт, пока фоновые горутины успокоятся.
Как устроен пузырь
Горутины, запущенные внутри, принадлежат ему. time.Now, time.Sleep, таймеры, тикеры и context.WithTimeout используют часы пузыря, которые стартуют с полуночи UTC 2000-01-01.
Часы перескакивают к следующему таймеру, только когда все горутины устойчиво заблокированы (durably blocked): на каналах, созданных в пузыре, в time.Sleep, select, sync.Cond.Wait, sync.WaitGroup.Wait (если Add вызывали в пузыре). Мьютекс, системный вызов или настоящий сетевой ввод-вывод не считаются; пузырь ждёт их, как реальную работу.
synctest.Wait() возвращается, когда все остальные горутины пузыря устойчиво заблокированы: детерминированная замена «поспать немного, чтобы воркер догнал».
Ограничения и бонусы
Код, который ждёт настоящий сокет, никогда не даст часам сдвинуться; используйте соединение в памяти, например net.Pipe.
Когда функция возвращается, Test ждёт завершения всех горутин пузыря и паникует при дедлоке, а это бесплатная проверка на утечки.
В Go 1.24 он вышел как эксперимент (GOEXPERIMENT=synctest, synctest.Run); в Go 1.25 synctest.Test стал стабильным.
Для простого кода, который лишь читает время, внедрённые часы по-прежнему проще.
Ретраи прячут баг; вместо этого сделайте набор тестов враждебным. -race, -count=N и -shuffle=on превращают в воспроизводимые падения три обычные причины: гонку данных (data race), общее состояние и зависимость от порядка.
**-race** сообщает о двух горутинах, которые обращаются к одной памяти без синхронизации, и печатает оба стека. Он видит только реально выполненный код, поэтому сочетайте его с -count и настоящим параллелизмом.
**-shuffle=on** перемешивает порядок тестов и печатает сид; -shuffle=<seed> воспроизводит его. Тест, который проходит только после другого, зависит от глобального состояния.
**-count=50 -run TestX** на одном подозреваемом, лучше под нагрузкой на CPU (-cpu=1,4 или параллельные прогоны в духе stress), превращает падение в 5% случаев в почти гарантированное.
Типичные виновники: синглтон на уровне пакета, файл в testdata, который переписывает один из тестов, os.Setenv вместо t.Setenv, захардкоженный порт вместо порта 0, общий путь в /tmp вместо t.TempDir().
**time.Sleep как синхронизация**: крупнейший источник нестабильных тестов. Ждите канал с дедлайном или используйте testing/synctest.
Известный нестабильный тест лучше поместить в карантин через t.Skip с тикетом, а не через ретрай: так сигнал остаётся на виду.