В 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 без единой новой зависимости.