В Java тесты немыслимы без JUnit, в Python без pytest, в JavaScript без jest. В Go тестирование вшито в инструментальную цепочку: пакет testing лежит в стандартной библиотеке, команда go test входит в поставку, и вместе они закрывают юнит-тесты, бенчмарки, фаззинг, примеры и подсчёт покрытия. Сторонние библиотеки в экосистеме есть, но большинство проектов годами живут на голом стандарте. Разберём, что умеет встроенный набор, чем он удобнее внешнего каркаса и в каких местах команды всё-таки тянут testify и мок-генераторы.
Как устроены тесты на пакете testing и команде go test
Правила именования жёсткие: функция TestXxx с единственным аргументом *testing.T, файл с суффиксом _test.go. Xxx обязано начинаться с заглавной буквы, иначе функция молча не считается тестом:
package abs
import "testing"
func TestAbs(t *testing.T) {
got := abs(-1)
if got != 1 {
t.Errorf("abs(-1) = %d; want 1", got)
}
}
Файл живёт в двух режимах. Тест в том же пакете видит неотэкспортированные внутренности, это белый ящик. Тест в пакете с суффиксом _test, например abs_test, импортирует abs и трогает только экспортированное, это чёрный ящик и заодно проверка публичного контракта.
Прогон запускается одной строкой, и go test делает больше, чем кажется: перед тестами он автоматически гоняет часть проверок go vet, включая printf и errorsas, и ошибка анализа валит пакет ещё до запуска функций.
Основные флаги командной строки:
1. -run задаёт регулярное выражение для отбора тестов и подтестов;
2. -v печатает имя каждого теста отдельной строкой и время его работы;
3. -count=1 отключает кэш и заставляет тесты выполниться заново;
4. -timeout ограничивает прогон, значение по умолчанию 10 минут.
Кэш - отдельная гордость Go: успешные результаты запоминаются, и повторный go test на неизменённом коде отрабатывает за доли секунды, печатая "(cached)". В CI это отключают флагом -count=1, чтобы странные зависимости от окружения всплывали стабильно.
Вывод прогона лаконичен до аскетизма:
go test ./...
ok example.com/app 0.012s
ok example.com/app/store (cached)
? example.com/app/cmd [no test files]
Пакеты без тестов честно признаются, упавший пакет печатает FAIL со списком функций. Полный список флагов живёт в go help testflag, и его полезно прочесть целиком хотя бы раз.
Порядок выполнения по умолчанию детерминирован, и это делает баги, зависящие от порядка, особенно коварными. Флаг -shuffle=on, появившийся в Go 1.17, перемешивает тесты случайным образом и сразу вскрывает скрытые зависимости от состояния соседей. Для CI-систем есть -json: каждое событие прогона печатается отдельной машиночитаемой строкой, и раннер собирает отчёт без сторонних плагинов.
Табличные тесты и подтесты через метод t.Run
Идиома table-driven tests экономит больше кода, чем любой внешний каркас. Набор случаев описывается срезом структур, проверка - одна:
func TestFormat(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", "00:00"},
{"simple", "9:05", "09:05"},
{"leading_zero", "07:30", "07:30"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Format(tc.in); got != tc.want {
t.Errorf("Format(%q) = %q; want %q", tc.in, got, tc.want)
}
})
}
}
Метод t.Run появился в Go 1.7 и порождает подтест. Каждый кейс виден отдельной строкой в отчёте, падает отдельно, запускается точечно:
go test -run 'TestFormat/simple' ./...
Параллельность задаётся вызовом t.Parallel() внутри подтеста: родитель приостановится и подождёт всех параллельных детей до завершения. Новые кейсы добавляются строкой в срез, тестовая функция не растёт.
Пара слов о сообщениях падений. t.Errorf фиксирует сбой и продолжает тест, t.Fatalf пишет лог и останавливает функцию: после фатальной ошибки состояние часто уже испорчено, и дальнейшие проверки плодят шум. Текст собирают по канону "получили X, ожидали Y", тогда строку падения читают без открытия редактора. Подтест в отчёте выводится полным путём вроде TestFormat/leading_zero, и в логе CI сразу видно, какой кейс таблицы сломался.
Бенчмарки от цикла b.N до метода b.Loop
Функции BenchmarkXxx(*testing.B) запускаются тем же go test, но только с флагом -bench. Классический цикл крутится b.N раз, где N подбирает сам прогон, пока не наберёт достаточно времени:
func BenchmarkRandInt(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = rand.Int()
}
}
В Go 1.24 появился метод b.Loop, который избавляет от ручного цикла и от типичных ошибок вроде забытого b.ResetTimer после дорогой подготовки:
func BenchmarkRandInt(b *testing.B) {
for b.Loop() {
_ = rand.Int()
}
}
Вывод выглядит так:
BenchmarkRandInt-8 68453040 17.8 ns/op
Суффикс -8 означает значение GOMAXPROCS на восьмиядерной машине. Флаг -benchmem добавляет колонки аллокаций, а вызов b.ReportAllocs() включает их для отдельного бенчмарка. Для статистики честности запускают несколько повторов и сравнивают через benchstat из пакета golang.org/x/perf: разница в одну цифру без дисперсии не значит ничего. Параллельные нагрузки измеряет b.RunParallel с флагом -cpu 1,2,4.
Управление замером живёт в методах B: b.SetBytes(n) включает в отчёт пропускную способность в мегабайтах в секунду, b.ReportAllocs() включает подсчёт аллокаций для отдельного бенчмарка. С -benchmem вывод обрастает колонками памяти:
BenchmarkRandInt-8 68453040 17.8 ns/op 0 B/op 0 allocs/op
Байты и счётчик аллокаций порой говорят больше наносекунд: код, аллоцирующий на горячем пути, тащит за собой сборщик мусора. Бенчмарки выполняются последовательно, шум соседних функций минимален, а честное сравнение собирают так:
go test -bench=. -count=10 ./... > new.txt
benchstat old.txt new.txt
benchstat покажет дельту с доверительным интервалом, и разница в пару процентов без него перестанет быть новостью.
Покрытие, детектор гонок и фаззинг без внешних утилит
Покрытие считается штатно:
go test -cover -coverprofile=cover.out ./...
go tool cover -html=cover.out
Первая команда печатает процент по каждому пакету, вторая открывает HTML-отчёт с подсветкой строк. Флаг -race включает детектор гонок данных: замедление и повышенный расход памяти - плата за поимку невыловимых руками ошибок, поэтому -race держат включённым в CI на всех тестах.
Типичный кандидат на поимку - два инкремента без синхронизации:
func TestCounter(t *testing.T) {
var n int
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++
}()
}
wg.Wait()
_ = n
}
go test -race печатает WARNING: DATA RACE со стеками обеих горутин. Руками такой сбой ловится раз в сотню запусков, детектор находит его на каждом. С Go 1.20 покрытие работает и для целых программ: бинарник, собранный флагом go build -cover, пишет профиль в каталог из переменной GOCOVERDIR, и интеграционные прогоны дают реальные цифры вместо синтетики юнит-тестов.
Фаззинг встроен с Go 1.18. Цель объявляется функцией FuzzXxx(*testing.F), стартовые значения добавляются через f.Add, движок крутит случайные входы до обнаружения паники или зависания:
func FuzzParseDate(f *testing.F) {
f.Add("2024-02-29")
f.Add("garbage")
f.Fuzz(func(t *testing.T, s string) {
d, err := ParseDate(s)
if err != nil {
return
}
if FormatDate(d) == "" {
t.Errorf("roundtrip broken for %q", s)
}
})
}
Запуск срабатывает по флагу -fuzz=FuzzParseDate и ограничивается по времени через -fuzztime=30s. Движок прожорлив до памяти и процессора, поэтому фаззинг запускают отдельной ночной задачей, а не в каждом пуш-прогоне. Найденные входы движок складывает в testdata/fuzz, откуда они автоматически становятся частью посевного корпуса: упавший после фаззинга тест остаётся упавшим навсегда, даже без флага.
Служебные методы T которые заменяют полфреймворка
Ассерты в Go пишутся руками, и это не боль, а три строки. Хелпер-функция с t.Helper() показывает в отчёте строку вызывающего, а не свою собственную:
func requireEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
Ресурсы освобождает t.Cleanup, временный каталог создаёт и удаляет t.TempDir, переменные окружения ставит и возвращает t.Setenv, а с Go 1.24 доступны t.Context с отменой после теста и t.Chdir с откатом рабочего каталога. Быстрые прогоны собираются парой testing.Short() в коде и флага -short в команде:
func TestHeavyImport(t *testing.T) {
if testing.Short() {
t.Skip("skipping heavy test in -short mode")
}
// долгая проверка
}
Десять методов на T закрывают то, что в других экосистемах размазано по каркасу: отчёты, изоляцию, ресурсы и фильтрацию. Живой пример сборки окружения теста:
func TestExport(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
ctx := t.Context()
if err := Export(ctx, filepath.Join(dir, "out.csv")); err != nil {
t.Fatal(err)
}
// проверки содержимого файла
}
Каталог удалится сам, переменная окружения вернётся, контекст отменится после теста. Логи собираются через t.Log и t.Logf: без -v они молчат, с -v печатаются, а при падении теста показываются всегда. printf в тестах - мусор, который теряется мимо отчёта.
Примеры ExampleXxx как исполняемая документация
Особая порода функций - ExampleXxx. Пример с комментарием // Output: выполняется наравне с тестами, и go test сверяет фактический вывод с ожидаемым:
func ExampleAbs() {
fmt.Println(Abs(-2))
// Output: 2
}
Документация перестаёт врать: пример либо выводит ровно обещанное, либо тест падает. Пример без блока Output только проверяется компилятором, что тоже полезно. Несколько примеров на одну функцию различаются суффиксами: ExampleAbs_negative. Схема имён едина для всех: ExampleF иллюстрирует функцию F, ExampleT_M - метод M типа T, суффикс после подчёркивания обязательно со строчной буквы. Документация с живыми примерами переживает рефакторинг: смена сигнатуры ломает пример на этапе компиляции, а не в бою.
Чего стандартная библиотека не даёт и где оправданы сторонние пакеты
Честности ради: у встроенного набора есть дыры. Первая - лаконичность ассертов. require.NoError(t, err) из testify короче развёрнутой проверки, а assert собирает группу проверок без остановки теста, поэтому большие команды тащат testify ради читаемости и единообразия. Внутри тест выглядит так:
func TestCalc(t *testing.T) {
got, err := Calc("2+2")
require.NoError(t, err)
assert.Equal(t, 4, got)
}
require валит тест сразу, assert собирает все сбои в один отчёт. Под капотом те же сравнения без скрытой механики, зато падение печатает аккуратный диф значений. Вторая - моки. Стандарт генераторы заглушек не содержит, идиома предлагает маленькие интерфейсы и рукописные фейки, но на толстых слоях RPC-клиентов вручную никто не пишет: mockery или uber-go/mock собирают заглушки из интерфейсов. Третья - организация: BDD-каркасы вроде ginkgo дают сценарный стиль, но в экосистеме они экзотика.
Рукописный фейк при этом остаётся первой линией обороны. Интерфейс из пары методов покрывается десятью строками без генераторов:
type storeFake struct{ users []User }
func (s *storeFake) List(ctx context.Context) ([]User, error) {
return s.users, nil
}
Фейк читается в тесте как обычный код, чинится без перегенерации и не тянет зависимостей. Заглушки через мок-фреймворк оправданы там, где интерфейсов десятки и они меняются часто.
Против внешних каркасов работает не отсутствие функций, а связка с компилятором. Тест - обычная функция в обычном пакете, без регистратора, аннотаций и ритуалов запуска. Анализатор tests, который go test гоняет вместе с vet начиная с Go 1.24, отлавливает неверные имена вроде Testlowercase и битые сигнатуры, из-за которых тесты молча не запускались бы. Один и тот же инструмент закрывает юниты, бенчмарки, фаззинг, примеры и покрытие, второй язык для тестов учить не нужно.
Встроенный набор закрывает решительно большую часть задач: структура теста очевидна с первого дня, счётчик покрытий честный, бенчмарки и фаззинг без внешних зависимостей, а примеры держат документацию в тонусе. testify и мок-генераторы - вопрос вкуса команды и объёма кода, а не дефицит стандартной библиотеки. Инженер, который выучил go test целиком, переносит навык в любой проект на Go без единой новой зависимости.