Перейти к содержимому

18. Testing — стандартный пакет `testing`

Полный разбор тестирования в Go: от t.Error до фаззинга и synctest. Зачем это знать: тесты — это контракт качества кода. Junior, который умеет писать табличные тесты с t.Parallel() и понимает t.Helper(), выглядит на собесе ощутимо сильнее.

  1. Базовое API
  2. Под капотом
  3. Gotchas
  4. Best practices
  5. Вопросы на собесе
  6. Practice
  7. Источники

Go идёт с встроенным тестовым фреймворком — пакет testing. Никаких сторонних библиотек для базы не нужно. Запуск — командой go test.

package math
func Add(a, b int) int {
return a + b
}
package math
import "testing"
func TestAdd(t *testing.T) {
if Add(2, 3) != 5 {
t.Errorf("Add(2, 3) = %d; want 5", Add(2, 3))
}
}
Окно терминала
go test ./...

Файлы тестов имеют суффикс _test.go. Они:

  • Включаются в сборку только при go test.
  • Не попадают в финальный бинарь.
  • Могут находиться в том же пакете (package math) или в package math_test (внешний тест).
math_internal_test.go
package math
// доступны приватные функции
math_external_test.go
package math_test
// доступен только публичный API

Внешние тесты заставляют тестировать как клиент — это лучше с точки зрения чистоты API. Часто их совмещают: интеграционные/black-box в _test подпакете.

func TestAdd(t *testing.T) { ... }
func TestEdgeCases(t *testing.T) { ... }

Правила:

  • Имя начинается с Test.
  • После Test — заглавная буква.
  • Один аргумент *testing.T.
  • Регистрируется автоматически.

Аналогично:

  • BenchmarkXxx(b *testing.B) — бенчмарки.
  • ExampleXxx() — примеры.
  • FuzzXxx(f *testing.F) — фаззинг.
  • TestMain(m *testing.M) — setup/teardown.
func TestThings(t *testing.T) {
if x != expected {
t.Errorf("got %v, want %v", x, expected) // продолжает тест
}
if y != expected {
t.Fatalf("got %v, want %v", y, expected) // останавливает функцию
}
fmt.Println("сюда дойдём, только если первый Error, но не Fatal")
}
МетодПомечает тест failedПродолжает выполнение
t.Log / t.Logfнетда
t.Error / t.Errorfдада
t.Fatal / t.Fatalfданет (runtime.Goexit)
t.Skip / t.Skipfпомечает skippedнет
t.SkipNowпомечает skippedнет
t.FailNowданет

Важно: t.Fatal останавливает функцию, в которой вызвана. Это означает:

  • В вспомогательной функции (helper) t.Fatal остановит helper, но не сам тест.
  • Поэтому нужен t.Helper(), чтобы правильно сообщать line numbers.

t.Fatal использует runtime.Goexit, который вызывает все defer правильно. Это не паника.

t.Logf("got x=%d", x) // печатается только при -v или fail
t.Skipf("skipping: %s reason", "no env var")

t.Log пишет в лог теста. По умолчанию не отображается, только при -v или если тест провалился.

t.Skip пометит тест как SKIP. Часто используется для conditional skip:

func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
// ...
}

Каноническая форма тестов в Go:

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"zero", 0, 0, 0},
{"negative", -1, -2, -3},
{"overflow", math.MaxInt, 1, math.MinInt},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}

Преимущества:

  • Легко добавлять кейсы.
  • t.Run создаёт subtest: видно, какой кейс упал.
  • Можно фильтровать: go test -run TestAdd/positive.
t.Run("subname", func(t *testing.T) {
// подтест
})

Subtest имеет свой *testing.T и счётчик. Можно вкладывать:

t.Run("group1", func(t *testing.T) {
t.Run("case1", func(t *testing.T) {})
t.Run("case2", func(t *testing.T) {})
})

Фильтр запуска через -run:

Окно терминала
go test -run TestAdd # все subtests TestAdd
go test -run TestAdd/positive # только positive
go test -run "TestAdd/.*ative" # regex
func TestSomething(t *testing.T) {
t.Parallel()
// ...
}

Помечает тест как параллельный. Все тесты с t.Parallel() сначала “паркуются”, потом запускаются параллельно с другими t.Parallel().

Параллелизм:

  • Ограничен GOMAXPROCS (можно -parallel N).
  • Между разными pkg всегда параллельно (можно -p N).
  • Внутри одного pkg — только если есть t.Parallel().
Окно терминала
go test -parallel 4 ./...

До Go 1.22:

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// BUG: tt захвачен по ссылке (одна переменная)!
// Все горутины увидят последнее значение.
_ = tt
})
}

Лекарство:

for _, tt := range tests {
tt := tt // shadowing — каждая итерация своя tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_ = tt
})
}

С Go 1.22: loop variables в for range имеют per-iteration scope. Баг не воспроизводится. Можно писать чисто:

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_ = tt // уже корректно
})
}

Но если поддерживаешь Go < 1.22 — продолжай делать tt := tt.

func assertEqual(t *testing.T, got, want int) {
t.Helper() // отметить как helper
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
func TestSomething(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // ← report укажет ЭТУ строку
}

Без t.Helper() сообщение об ошибке указало бы на строку внутри assertEqual, а не на ту, что в TestSomething. С t.Helper() фрейм helper’а исключается из traceback.

t.Helper() можно вызывать в любой функции, включая методы. Эффект — пока helper-функция активна.

Глобальная setup/teardown для всего тестового пакета:

func TestMain(m *testing.M) {
// SETUP
setupDB()
code := m.Run() // запустить все тесты
// TEARDOWN
teardownDB()
os.Exit(code)
}

Важно: os.Exit пропускает defer. Поэтому teardown — перед os.Exit.

func TestThing(t *testing.T) {
server := startServer()
t.Cleanup(func() {
server.Stop()
})
// ...
}

t.Cleanup регистрирует callback, вызываемый при завершении теста (даже при t.Fatal). Можно несколько раз — выполнятся в LIFO порядке.

Преимущества t.Cleanup над defer:

  • Работает в subtests: cleanup конкретного subtest, не всего теста.
  • Гарантированно запускается при t.FailNow/t.Fatal.
  • Работает поверх t.Parallel() корректно.
mypkg/
parser.go
parser_test.go
testdata/
input1.json
expected1.json

Папка testdata — специальная для Go: её содержимое игнорируется компилятором, но доступно тестам.

func TestParse(t *testing.T) {
data, err := os.ReadFile("testdata/input1.json")
if err != nil {
t.Fatal(err)
}
got := Parse(data)
want, _ := os.ReadFile("testdata/expected1.json")
if !bytes.Equal(got, want) {
t.Errorf("mismatch")
}
}

Шаблон “записать ожидаемый результат в файл, потом сравнивать”:

var update = flag.Bool("update", false, "update golden files")
func TestRender(t *testing.T) {
got := Render(input)
golden := filepath.Join("testdata", "render.golden")
if *update {
os.WriteFile(golden, got, 0644)
}
want, _ := os.ReadFile(golden)
if !bytes.Equal(got, want) {
t.Errorf("render mismatch")
}
}

Запуск:

Окно терминала
go test -update # обновить golden files
go test # проверка
Окно терминала
go test -cover ./...
# coverage: 73.2% of statements

Сохранить в файл:

Окно терминала
go test -coverprofile=coverage.out ./...

Посмотреть отчёт:

Окно терминала
go tool cover -func=coverage.out
go tool cover -html=coverage.out # открывает в браузере

Также есть режимы:

Окно терминала
go test -covermode=count # count = сколько раз каждая строка
go test -covermode=atomic # для параллельных тестов
go test -covermode=set # set = просто покрыта (default)

С Go 1.20+ покрытие можно собирать с бинарей, не только с тестов:

Окно терминала
go build -cover -o myapp
GOCOVERDIR=/tmp/cov ./myapp
go tool covdata textfmt -i=/tmp/cov -o=cov.txt
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Add(2, 3)
}
}

b.N — счётчик итераций. Go сам подбирает его, чтобы бенчмарк длился ~1 секунду.

Запуск:

Окно терминала
go test -bench=. # все бенчмарки
go test -bench=BenchmarkAdd # один
go test -bench=. -benchmem # с allocation stats
go test -bench=. -benchtime=10s # дольше
go test -bench=. -benchtime=100x # ровно 100 итераций
go test -bench=. -count=5 # повторить 5 раз

Пример вывода:

BenchmarkAdd-8 1000000000 0.4032 ns/op 0 B/op 0 allocs/op
  • 1000000000b.N (количество итераций).
  • 0.4032 ns/op — наносекунды на одну итерацию.
  • 0 B/op — байт аллоцировано на итерацию.
  • 0 allocs/op — количество аллокаций.

Когда есть тяжёлая инициализация:

func BenchmarkExpensive(b *testing.B) {
data := setupLargeData() // не должно входить в измерение
b.ResetTimer()
for i := 0; i < b.N; i++ {
Process(data)
}
}

b.StopTimer()/b.StartTimer() — для измерений с переменной нагрузкой:

for i := 0; i < b.N; i++ {
b.StopTimer()
data := generateNew()
b.StartTimer()
Process(data)
}
func BenchmarkFoo(b *testing.B) {
b.ReportAllocs() // эквивалент флагу -benchmem для этого бенча
for i := 0; i < b.N; i++ {
_ = make([]byte, 1024)
}
}
func BenchmarkSizes(b *testing.B) {
for _, n := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Process(n)
}
})
}
}

Установить:

Окно терминала
go install golang.org/x/perf/cmd/benchstat@latest
Окно терминала
go test -bench=. -count=10 > old.txt
# ... изменения ...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

Вывод покажет процентное изменение и статистическую значимость.

func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}

go test запустит ExampleAdd, захватит stdout и сравнит с // Output: блоком. Если не совпало — fail.

Преимущества:

  • Документация, которая не врёт (компилируется).
  • Появляется в godoc.

Типы:

  • ExampleAdd — функция Add.
  • ExampleAdd_simple — вариант для Add.
  • Example_global — пример пакета.
func ExampleMapKeys() {
m := map[string]int{"a": 1, "b": 2}
for k := range m {
fmt.Println(k)
}
// Unordered output:
// a
// b
}

Фаззинг — автоматическая генерация входных данных:

func FuzzReverse(f *testing.F) {
// Seed corpus — начальные данные
f.Add("hello")
f.Add("")
f.Add("¥¥¥")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
revRev := Reverse(rev)
if s != revRev {
t.Errorf("Reverse(Reverse(%q)) = %q; want %q", s, revRev, s)
}
if utf8.ValidString(s) && !utf8.ValidString(rev) {
t.Errorf("invalid UTF-8: %q -> %q", s, rev)
}
})
}

Запуск:

Окно терминала
go test -fuzz=FuzzReverse # фаззинг
go test -fuzz=FuzzReverse -fuzztime=30s
go test -fuzz=. -run=^$ # только фаззинг, без unit-тестов

Когда находит контр-пример, сохраняет в testdata/fuzz/FuzzReverse/<hash>. После этого go test (без -fuzz) автоматически прогонит этот файл как unit-тест.

string, []byte, целочисленные типы (int, int8.., uint8..), float32, float64, bool, rune. Структуры не поддерживаются.

import "net/http/httptest"
func TestClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != `{"ok":true}` {
t.Errorf("wrong body: %s", body)
}
}

httptest.NewServer слушает на случайном порту — на 127.0.0.1.

httptest.NewTLSServer — то же с самоподписанным TLS.

Для теста HTTP-хендлеров без запуска сервера:

func MyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "hello")
}
func TestMyHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
MyHandler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
t.Errorf("status = %d; want 200", resp.StatusCode)
}
if !strings.Contains(string(body), "hello") {
t.Errorf("body = %s", body)
}
}

Утилиты для тестирования I/O:

import "testing/iotest"
// Reader, который возвращает данные по одному байту
r := iotest.OneByteReader(strings.NewReader("hello"))
// Reader, который возвращает ошибку после N байт
r := iotest.TimeoutReader(strings.NewReader("hello"))
// Reader, который читает половину
r := iotest.HalfReader(strings.NewReader("hello"))
// Reader, в котором каждый Read возвращает err после данных
r := iotest.DataErrReader(strings.NewReader("hello"))

Хорошо для проверки, что код читает в цикле и обрабатывает короткие чтения.

В Go 1.24 появился экспериментальный пакет testing/synctest (требует GOEXPERIMENT=synctest). Решает проблему тестов с временем.

import "testing/synctest"
func TestTimeout(t *testing.T) {
synctest.Run(func() {
ch := make(chan int)
go func() {
time.Sleep(time.Hour)
ch <- 42
}()
// synctest.Wait() — ждём, пока все горутины запаркуются
synctest.Wait()
// время "прошло" мгновенно
})
}

Внутри synctest.Run время фейковое. Полезно для:

  • Таймеров без time.Sleep.
  • Детерминированных тестов с горутинами.
  • Тестов с дедлайнами.

Пока экспериментально — API может меняться.

Окно терминала
go test -race ./...

Включает race detector — динамически проверяет конкурентный доступ к памяти. Замедляет ~5-10x, поэтому обычно только в CI или отдельно.

Каждый race репортит:

  • Где первая горутина пишет.
  • Где вторая читает/пишет.
  • Stack trace создания горутин.

В standard library моков нет. Популярны:

Окно терминала
go install go.uber.org/mock/mockgen@latest
mockgen -source=user.go -destination=mock_user.go
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := NewMockRepo(ctrl)
mockRepo.EXPECT().Get(gomock.Any()).Return(&User{ID: 1}, nil)
import "github.com/stretchr/testify/mock"
type MockRepo struct {
mock.Mock
}
func (m *MockRepo) Get(id int) (*User, error) {
args := m.Called(id)
return args.Get(0).(*User), args.Error(1)
}
// в тесте
m := new(MockRepo)
m.On("Get", 1).Return(&User{ID: 1}, nil)

Junior’у достаточно знать, что такие либы существуют. Middle — уметь применять.

import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStuff(t *testing.T) {
assert.Equal(t, 5, Add(2, 3)) // tests continues
require.NoError(t, err) // tests stops on fail
assert.Contains(t, "hello world", "world")
}
  • assert.* — как t.Error (продолжает).
  • require.* — как t.Fatal (останавливает).

В РФ часто используется testify. Но в стандарте Go его нет, и некоторые команды (особенно из core Go-сообщества) предпочитают чистый testing + helper’ы.

Окно терминала
go test -v # verbose, видеть имена тестов и Log
go test -run TestFoo # фильтр по имени
go test -count=10 # запустить N раз
go test -timeout=30s # таймаут на тест
go test -short # пометить как short mode (testing.Short())
go test -race # race detector
go test -cover # покрытие
go test -bench=. # бенчмарки
go test -fuzz=. # фаззинг
go test -failfast # остановиться при первом fail
go test -parallel=4 # ограничение параллелизма
go test ./... # все пакеты рекурсивно

  1. go test парсит все *_test.go в пакете.
  2. Генерирует временный файл _testmain.go с func main().
  3. Этот main() вызывает testing.Main со списком найденных TestXxx/BenchmarkXxx/ExampleXxx/FuzzXxx.
  4. Собирается бинарь <pkg>.test.
  5. Запускается с переданными аргументами.

Можно увидеть бинарь:

Окно терминала
go test -c -o mytest
./mytest -test.v -test.run=TestFoo

Флаги для бинаря имеют префикс -test. (например, -test.run), а go test принимает их без префикса.

type T struct {
common
isParallel bool
isEnvSet bool
context *testContext
}
type common struct {
mu sync.RWMutex
output []byte
w io.Writer
ran bool
failed bool
skipped bool
done bool
helperPCs map[uintptr]struct{}
helperNames map[string]struct{}
cleanups []func()
// ...
}

Внутри:

  • mu — мьютекс для thread-safe вызовов из горутин (например, в t.Parallel()).
  • helperPCs — список program counters, помеченных t.Helper().
  • cleanups — стек callback’ов от t.Cleanup().
  • failed — флаг “тест провалился”.

При вызове t.Parallel():

  1. Тест помечается как параллельный.
  2. Текущий goroutine блокируется на сигнал.
  3. После того как все sequential тесты в пакете завершатся, parallel-тесты разблокируются.
  4. Они выполняются параллельно с лимитом GOMAXPROCS (или -parallel).

Псевдокод:

func (t *T) Parallel() {
t.signal <- struct{}{} // сигнал родителю "можно запускать следующий"
<-t.context.startParallel // ждать сигнала на старт
}

Поэтому в subtests с t.Parallel() важно: outer test не закончится, пока все parallel children не завершатся.

Iteration 1: b.N = 1
→ измерили T1
Iteration 2: b.N = max(2 * predicted, 1)
→ predicted = b.N * (benchtime / T1)
Iteration 3: ...
Stop when accumulated time >= benchtime

В реальности — итеративно растёт (1 → 100 → 10000 → …), пока общее время не превысит -benchtime (по умолчанию 1s).

При -cover:

  1. Go-компилятор инструментирует бинарь — в каждом basic block добавляется счётчик.
  2. Тесты запускают код, счётчики увеличиваются.
  3. По завершению — счётчики сохраняются и анализируются.

Modes:

  • set — bool: “был выполнен или нет”.
  • count — int: “сколько раз”.
  • atomicint64 с atomic-ops, для race-safe.

С Go 1.20+ — отдельный механизм для бинарей (не только тестов):

Окно терминала
go build -cover -o myapp
GOCOVERDIR=/tmp/cov ./myapp

t.Helper() запоминает PC (program counter) текущей функции в helperPCs. Когда тест печатает ошибку, он идёт по stack frames вниз и пропускает все, чьи PC помечены как helper. Первый не-helper фрейм — это и есть “место вызова”.

func (c *common) Helper() {
pc, _, _, _ := runtime.Caller(1)
c.helperPCs[pc] = struct{}{}
}

t.Cleanup функции:

  • Запускаются в LIFO порядке.
  • Запускаются даже после t.FailNow/t.Fatal (defer тоже, но runtime.Goexit).
  • Запускаются после завершения subtest, не всего теста.
func TestParent(t *testing.T) {
t.Cleanup(func() { fmt.Println("parent cleanup") })
t.Run("child", func(t *testing.T) {
t.Cleanup(func() { fmt.Println("child cleanup") })
})
// ChildCleanup напечатается ДО ParentCleanup (LIFO + scoped to subtest)
}

Это просто bool флаг, выставляемый через -short:

if testing.Short() {
t.Skip("skipping integration tests in short mode")
}

Часто make test-unit использует -short, а make test-integration — без.

Race detector — это ThreadSanitizer (TSan) от Google, портированный для Go. Включается флагом -race:

Окно терминала
go test -race ./...
go build -race -o myapp
go run -race main.go

Накладные расходы:

  • Память: 5-10x.
  • CPU: 2-20x (зависит от паттерна).

Не подходит для prod, только для тестирования.

go test -fuzz запускает coverage-guided фаззинг. Worker’ы:

  • Берут seed corpus.
  • Применяют мутации (битовые флипы, добавление/удаление байт, и т.д.).
  • Сравнивают coverage с предыдущими прогонами.
  • Сохраняют “интересные” входы (увеличившие coverage).
  • При краше сохраняют в testdata/fuzz/FuzzXxx/<hash>.
func (t *T) Run(name string, f func(t *T)) bool {
t.hasSub.Store(true)
sub := &T{
common: common{
name: t.name + "/" + rewrite(name),
parent: &t.common,
level: t.level + 1,
},
context: t.context,
}
// ... запускает f в собственной горутине
}

Subtest — это новый *testing.T, новая goroutine, отдельный счётчик failures. Имя формируется из родителя + / + имя.


for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_ = tt // BUG до Go 1.22: общая переменная
})
}

С Go 1.22 баг исправлен (per-iteration scope). Если поддерживаешь старые версии — tt := tt.

var counter int
func TestA(t *testing.T) {
t.Parallel()
counter++ // race с TestB
}
func TestB(t *testing.T) {
t.Parallel()
counter++
}

-race найдёт это сразу. Не использовать shared mutable state в parallel-тестах.

Окно терминала
go test ./pkg # тесты pkg/
go test ./... # все пакеты рекурсивно

./... обходит подпапки. Часто можно случайно зацепить третейские модули, если не настроен go.work.

func TestMain(m *testing.M) {
defer cleanup() // ✗ не вызовется
os.Exit(m.Run())
}

Правильно:

func TestMain(m *testing.M) {
code := m.Run()
cleanup() // явно
os.Exit(code)
}
func TestThing(t *testing.T) {
go func() {
t.Fatal("oops") // ⚠️ panic: Fatal called from non-test goroutine
}()
// ...
}

t.Fatal/t.FailNow нельзя вызывать из другой горутины. Только из той, где запущен тест. Используй каналы для передачи ошибок:

errCh := make(chan error, 1)
go func() {
if x != y {
errCh <- fmt.Errorf("bad")
return
}
errCh <- nil
}()
if err := <-errCh; err != nil {
t.Fatal(err)
}
func Divide(a, b int) int {
return a / b
}
func TestDivide(t *testing.T) {
_ = Divide(10, 2) // 100% coverage, но ничего не проверяет
}

Coverage показывает, какие строки выполнены, но не утверждает корректность. Полагайся на assertions + property-based + edge cases.

go test -cover считает coverage по всему коду, включая сгенерированный (например, easyjson, mockgen). Это занижает реальный coverage написанного человеком кода.

Решения:

  • -coverpkg — указать пакеты для подсчёта.
  • Исключать сгенерированный код через build tags (//go:build !generated).
func ExampleAdd() {
fmt.Println(Add(2, 3))
// забыли // Output:
}

Этот пример скомпилируется (т.е. ошибки компиляции поймаются), но не запустится. Чтобы Go реально выполнил его и проверил, нужен комментарий // Output: 5.

Окно терминала
go test ./...
# во второй раз:
go test ./...
ok github.com/example/foo (cached)

Go кэширует результаты тестов: если ни код, ни тесты не менялись, кэш возвращается. Сбросить:

Окно терминала
go clean -testcache
# или
go test -count=1 ./...

httptest.NewServer слушает на 127.0.0.1:. URL получается через server.URL. Не пытайся хардкодить порт — он рандомный.

Нет — t.Cleanup вызывается на t.FailNow (через runtime.Goexit). Но panic тест убьёт без cleanup, если паника не восстановлена. Поэтому в TestMain или в helper’ах иногда используют recover.

t.Run("a", func(t *testing.T) {
t.Parallel()
// ...
})

t.Parallel() внутри subtest означает: этот subtest параллелен другим subtest’ам того же родителя. Хороший паттерн для параллельных табличных тестов.

В современном Go это невозможно — b.N >= 1 гарантировано. Но если у тебя бенч с if b.N == 0 { ... } — это мёртвый код.

3.14. Бенчмарк, который компилятор оптимизирует в ничто

Заголовок раздела «3.14. Бенчмарк, который компилятор оптимизирует в ничто»
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3) // результат отбрасывается → компилятор может удалить
}
}

Защита:

var sink int
func BenchmarkAdd(b *testing.B) {
var x int
for i := 0; i < b.N; i++ {
x = Add(2, 3)
}
sink = x // глобальная переменная — компилятор не оптимизирует
}

Или использовать runtime.KeepAlive.

mypkg/testdata/...

Эта папка:

  • Не индексируется компилятором (как _ или .).
  • Не считается пакетом.
  • Не попадает в go list ./....

Не путать с обычной папкой — там Go-файлы будут собраны.

-race требует cgo и работает только на ограниченных платформах (amd64, arm64, mips64, ppc64le, s390x на Linux/Mac/Windows). На Android/iOS — нет.

Окно терминала
go test -run Add

Запустит все тесты, чьё имя содержит Add: TestAdd, TestAddNegative, TestSubtractAdd. Используй regex для точности:

Окно терминала
go test -run '^TestAdd$'
t.Run("a/b", func(t *testing.T) { ... }) // в выводе будет "a/b" с escaped /

Имена subtest’ов с / стрипаются — / имеет специальное значение для -run. Лучше избегать.

func BenchmarkParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// работа
}
})
}

Запускает бенч в GOMAXPROCS горутин. Полезно для тестирования конкурентного кода. Не использовать с обычным for i := 0; i < b.N; i++ — это разные API.

Файлы в testdata/fuzz/FuzzXxx/ загружаются автоматически как seed. При go test (без -fuzz) они запускаются как unit-тесты, чтобы регрессии не вернулись.


Тесты должны быть изолированы:

  • Не лазить в сеть.
  • Не зависеть от файловой системы (кроме testdata).
  • Не использовать глобальное состояние.
  • Не зависеть от порядка выполнения.

Тесты, которые сегодня прошли — должны проходить через год.

go test ./... должен быть быстрым — секунды, не минуты. Иначе разработчики перестанут запускать локально.

  • Mock внешние сервисы.
  • Используй in-memory implementations (например, sqlite :memory:).
  • Параллель там, где можно.

Долгие тесты — за флагом -short:

if testing.Short() {
t.Skip("skipping integration in -short")
}
func TestA(t *testing.T) {
t.Parallel()
// ...
}

Если нет shared state — добавляй t.Parallel(). Это найдёт race-condition’ы и ускорит запуск.

// плохо
var globalDB *DB
func TestA(t *testing.T) {
globalDB.Query(...)
}

Каждый тест — свой setup. Используй t.Cleanup или TestMain.

Если есть >2 случая — пиши таблицу:

tests := []struct {
name string
input ...
want ...
}{
// ...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ...
})
}

Любая функция, которая принимает *testing.T и зовёт t.Error/Fatal — должна начинать с t.Helper().

// плохо
func TestAdd(t *testing.T) {}
// лучше
func TestAdd_PositiveNumbers(t *testing.T) {}
func TestAdd_OverflowReturnsMinInt(t *testing.T) {}

Или с table-driven:

{name: "overflow returns min int", a: MaxInt, b: 1, want: MinInt},

4.8. Не комментируй сложные кейсы — пиши // edge case: ...

Заголовок раздела «4.8. Не комментируй сложные кейсы — пиши // edge case: ...»
{
name: "empty string",
// edge case: должно вернуть пустую строку, а не nil
input: "",
want: "",
},
func setupTest(t *testing.T) (*Server, *Client) {
t.Helper()
s := startServer()
t.Cleanup(s.Stop)
c := newClient(s.URL)
return s, c
}
func TestA(t *testing.T) {
s, c := setupTest(t)
// ...
}

Всегда параметризуй бенчи:

func BenchmarkSort(b *testing.B) {
for _, n := range []int{10, 100, 1000, 10000} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
data := make([]int, n)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sort(data)
}
})
}
}

Минимум один CI job должен запускать тесты с -race. Race condition’ы дёшево ловятся в тестах, дорого — в проде.

Кэш go test помогает в локальной разработке, но в CI лучше -count=1, чтобы убедиться, что всё реально выполнилось.

Не смешивай unit и integration в одном пакете. Делай:

  • pkg/parser/parser.go + parser_test.go — unit.
  • pkg/parser/integration_test.go с build tag — integration.
//go:build integration
package parser
Окно терминала
go test -tags=integration ./...

Когда setup провалился — тест дальше не имеет смысла:

db, err := openDB(t)
require.NoError(t, err) // не продолжать без БД
user, err := db.GetUser(1)
assert.NoError(t, err) // продолжать, чтобы видеть остальные асерты
assert.Equal(t, "alice", user.Name)

Не пиши тесты на json.Marshal или strings.Contains. Тестируй свой код.

Когда fuzz нашёл краш — файл сохраняется в testdata/fuzz/FuzzXxx/. Это автоматически становится seed corpus. Коммить эти файлы — это регрессионные тесты.

// плохо
if got != want {
t.Error("fail")
}
// хорошо
if got != want {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, got, want)
}

Используй -timeout:

Окно терминала
go test -timeout=30s

И контексты с дедлайнами:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

t.Error помечает тест как failed, но продолжает выполнение функции. t.Fatal помечает + останавливает функцию через runtime.Goexit (т.е. defer вызовутся).

Структура тестов: slice структур с входными данными и ожидаемыми результатами, цикл с t.Run. Делает тесты компактными, легко расширяемыми, видны причины фейлов.

Чтобы сообщения об ошибках указывали на строку вызова helper-функции, а не на строку внутри неё. Помечает фрейм как пропускаемый при формировании traceback.

Помечает тест как параллельный. Все параллельные тесты в пакете сначала “паркуются”, потом запускаются параллельно (с лимитом -parallel).

До Go 1.22: переменная цикла была общая для всех итераций. В parallel-subtests это приводило к тому, что все горутины видели последнее значение. Лекарство: tt := tt внутри цикла. С Go 1.22 переменная — per-iteration scope, проблемы нет.

TestMain(m *testing.M) — глобальный setup/teardown для всего пакета. t.Cleanup — callback при завершении конкретного теста (или subtest). Cleanup работает даже после t.Fatal.

Специальная папка, содержимое которой Go-компилятор игнорирует. Туда складывают fixtures, golden files, fuzz corpus. Доступна тестам как обычный путь.

go test -cover инструментирует код — добавляет счётчики в каждый basic block. После выполнения тестов посчитанные строки делятся на общее количество. Режимы: set, count, atomic.

  • set — bool (выполнено/нет), не thread-safe, но дешёвый.
  • atomicint64 с atomic ops, безопасен для параллельных тестов.
  • count — int, но не thread-safe.

Go итеративно подбирает b.N, пока общее время прогона не превысит -benchtime (по умолчанию 1s). Обычно растёт по схеме 1 → 100 → 10000 → … Цель — статистически значимое измерение.

Чтобы исключить из измерения тяжёлый setup, выполненный до основного цикла. Также есть b.StopTimer()/b.StartTimer() для отрезков внутри цикла.

Функции вида func ExampleXxx() { ... // Output: ... }. Go компилирует, запускает и сравнивает stdout с блоком // Output:. Документация, которая не врёт.

Автоматическая генерация входных данных (мутации seed corpus, coverage-guided). Помогает находить edge cases. С Go 1.18 встроен: func FuzzXxx(f *testing.F), go test -fuzz=.

5.14. Чем httptest.NewServer отличается от httptest.NewRecorder?

Заголовок раздела «5.14. Чем httptest.NewServer отличается от httptest.NewRecorder?»
  • NewServer — поднимает реальный HTTP-сервер на 127.0.0.1:. Для интеграционных тестов.
  • NewRecorderhttp.ResponseWriter, который записывает в память. Для unit-тестов хендлеров без сети.

-race флаг включает ThreadSanitizer — динамическую проверку конкурентного доступа к памяти. Не находит все race condition (только в выполненном коде), но эффективно выявляет реальные баги. Замедляет 5-10x.

5.16. Можно ли вызывать t.Fatal из другой горутины?

Заголовок раздела «5.16. Можно ли вызывать t.Fatal из другой горутины?»

Нет. t.Fatal/t.FailNow нужно вызывать только из той горутины, в которой запущен тест. Из других — panic. Используй каналы или sync для передачи ошибок.

  • assert.* — fail продолжает тест (как t.Error).
  • require.* — fail останавливает (как t.Fatal).

Полезно: require для setup’а, assert для основных проверок.

5.18. Когда использовать package foo_test вместо package foo в тестах?

Заголовок раздела «5.18. Когда использовать package foo_test вместо package foo в тестах?»

package foo_test — внешний тест, доступен только публичный API. Хорошо для black-box тестирования, защищает от использования internal-функций. package foo — для white-box, тестирования приватных функций.

5.19. Что делает t.Cleanup() и чем отличается от defer?

Заголовок раздела «5.19. Что делает t.Cleanup() и чем отличается от defer?»

t.Cleanup(fn) — регистрирует функцию, вызываемую при завершении теста или subtest. Преимущества:

  • Работает в subtests (vs defer в parent, который выполнится после всех subtests).
  • Выполнится при t.Fatal (как defer, через runtime.Goexit).
  • Можно вызывать из helper’ов.

Файлы с ожидаемым результатом, обычно в testdata/. Тесты сравнивают свой output с golden. Преимущество — легко обновлять (флаг -update), легко проверять глазами при изменениях.

5.21. Как протестировать функцию с временем/таймером?

Заголовок раздела «5.21. Как протестировать функцию с временем/таймером?»

Варианты:

  • Внедрить time.Now через интерфейс/функцию: clock Clock.
  • Использовать testing/synctest (Go 1.24+, experimental) — фейковое время.
  • Использовать time.Tick с короткими интервалами — не идеально.

Возвращает true, если запущено go test -short. Позволяет пропустить долгие тесты:

if testing.Short() {
t.Skip("...")
}
Окно терминала
go install golang.org/x/perf/cmd/benchstat@latest
go test -bench=. -count=10 > old.txt
# changes...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

benchstat показывает статистически значимые изменения, не “0.05ns тут лучше”.

5.24. Что произойдёт, если в Example есть // Output:, но реального вывода нет?

Заголовок раздела «5.24. Что произойдёт, если в Example есть // Output:, но реального вывода нет?»

go test сравнит ожидаемый output с фактическим (пустым) и упадёт с сообщением о несовпадении. Пример обязательно должен производить ожидаемый stdout.

Go кэширует результаты go test по хэшу: исходники + флаги + env. Если ничего не изменилось — возвращается (cached). Сбросить: go clean -testcache или go test -count=1.


Создай math/math.go:

package math
func Max(a, b int) int {
if a > b {
return a
}
return b
}

Напиши math_test.go с table-driven тестом, включающим:

  • positive, negative, equal, zero
  • int.Max/int.Min

Запусти go test -v ./math.

Возьми предыдущий тест, добавь t.Parallel() в каждый subtest. Запусти с -race:

Окно терминала
go test -race -v ./math

Напиши:

func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}

Используй в тестах. Замени t.Helper() на ничего и сравни сообщения.

Напиши пакет, который использует БД (sqlite :memory:):

var db *sql.DB
func TestMain(m *testing.M) {
var err error
db, err = sql.Open("sqlite3", ":memory:")
if err != nil {
panic(err)
}
// создать схему
db.Exec("CREATE TABLE users (id INTEGER, name TEXT)")
code := m.Run()
db.Close()
os.Exit(code)
}

Напиши тест с временным файлом:

func TestFile(t *testing.T) {
f, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)
t.Cleanup(func() {
os.Remove(f.Name())
})
// ... используй f
}

Проверь, что файл удаляется даже при t.Fatal.

Запусти:

Окно терминала
go test -coverprofile=cov.out ./...
go tool cover -html=cov.out

Изучи HTML-отчёт. Найди непокрытые ветки, допиши тесты.

func BenchmarkStrConcat(b *testing.B) {
parts := []string{"a", "b", "c", "d", "e"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
}

Запусти go test -bench=. -benchmem. Сравни.

func ExampleMax() {
fmt.Println(Max(2, 5))
// Output: 5
}

Запусти go test -v -run Example. Поменяй 5 на 4 — увидь fail.

func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
revRev := Reverse(rev)
if s != revRev {
t.Errorf("%q != %q", s, revRev)
}
})
}

Запусти:

Окно терминала
go test -fuzz=FuzzReverse -fuzztime=30s

Если использовать []byte вместо []rune — найдёт случай с UTF-8 (например, ).

func HelloHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
fmt.Fprintf(w, "Hello, %s!", name)
}
func TestHelloHandler(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{"default", "/", "Hello, world!"},
{"named", "/?name=Alice", "Hello, Alice!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil)
w := httptest.NewRecorder()
HelloHandler(w, req)
got := w.Body.String()
if got != tt.want {
t.Errorf("got %q; want %q", got, tt.want)
}
})
}
}
var update = flag.Bool("update", false, "update golden files")
func TestRender(t *testing.T) {
got := Render(input)
golden := filepath.Join("testdata", "render.golden")
if *update {
if err := os.WriteFile(golden, got, 0644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch:\n--- got\n%s\n--- want\n%s", got, want)
}
}

Намеренно сломанный код:

type Counter struct {
n int
}
func (c *Counter) Inc() {
c.n++
}
func TestCounter(t *testing.T) {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
}
Окно терминала
go test -race

Должен поймать DATA RACE. Исправь через sync/atomic или mutex.


  1. Официальная документация testinghttps://pkg.go.dev/testing Полный API reference. Самый авторитетный источник.

  2. The Go Blog: Using Subtests and Sub-benchmarkshttps://go.dev/blog/subtests От Go team, объясняет t.Run и парадигму subtests.

  3. The Go Blog: Fuzzing is Beta Ready (и follow-up) — https://go.dev/blog/fuzz-beta Введение в фаззинг. Обновляйся до новых статей по synctest/fuzz.

  4. Go Wiki: Table Driven Testshttps://go.dev/wiki/TableDrivenTests Каноническая форма Go-тестов.

  5. Russ Cox: Versions in Go + Дейв Чейни: testing tipshttps://dave.cheney.net/category/testing Серия статей с лучшими практиками.

  6. Effective Go: Testinghttps://go.dev/doc/effective_go#testing Идиоматичные паттерны от Go team.

  7. Дейв Чейни: Prefer table driven testshttps://dave.cheney.net/2019/05/07/prefer-table-driven-tests Глубокое обоснование табличного стиля.