В Go инфраструктура тестирования является частью стандартной библиотеки. Без внешних фреймворков, без сложной конфигурации. Но за простотой go test стоит набор паттернов, которые отличают production-качество тестов от хрупких и поддерживаемых с трудом. В этой статье разберём основные приёмы: табличные тесты, подтесты, моки через интерфейсы, тестирование HTTP и интеграционные сценарии.
Пакет testing: что есть в стандартной библиотеке
Тесты в Go живут в файлах с суффиксом _test.go. Компилятор исключает их из финальной сборки — в бинарнике их не будет. Каждая тестовая функция начинается с Test и принимает единственный параметр *testing.T:
package math
import "testing"
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
func TestAbs(t *testing.T) {
result := Abs(-5)
if result != 5 {
t.Errorf("Abs(-5) = %d, ожидали 5", result)
}
}
Запуск:
$ go test ./...
ok mypackage 0.003s
Флаг -v покажет имена тестов и их результаты:
$ go test -v ./...
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok mypackage 0.003s
Флаг -run фильтрует тесты по имени (поддерживает регулярные выражения):
$ go test -v -run TestAbs ./...
t.Error и t.Fatal
t.Error и t.Errorf отмечают тест как проваленный, но продолжают выполнение. t.Fatal и t.Fatalf останавливают тест немедленно.
func TestExample(t *testing.T) {
conn, err := connect()
if err != nil {
t.Fatalf("не удалось подключиться: %v", err) // Дальше проверять нечего
}
result := conn.Query("SELECT 1")
if result != 1 {
t.Errorf("Query() = %d, ожидали 1", result) // Можно продолжать
}
result2 := conn.Query("SELECT 2")
if result2 != 2 {
t.Errorf("Query() = %d, ожидали 2", result2) // Увидим оба провала
}
}
Правило простое: t.Fatal для setup-кода, без которого остальные проверки не имеют смысла. t.Error для самих проверок — так вы увидите все проблемы за один запуск, а не по одной.
Example-функции
Помимо Test*-функций, Go поддерживает Example*-функции. Это тесты, которые одновременно работают как документация: go doc и pkg.go.dev показывают их как примеры использования, а go test проверяет, что вывод совпадает с ожидаемым:
func ExampleAbs() {
fmt.Println(Abs(-5))
fmt.Println(Abs(0))
fmt.Println(Abs(3))
// Output:
// 5
// 0
// 3
}
Комментарий // Output: в конце — обязательная часть. Без него функция компилируется, но go test не будет проверять вывод. Если реальный вывод не совпадает с ожидаемым, тест падает:
$ go test -v -run ExampleAbs
=== RUN ExampleAbs
--- PASS: ExampleAbs (0.00s)
PASS
Для функций, у которых порядок строк в выводе недетерминирован, есть // Unordered output: — Go отсортирует строки перед сравнением.
Именование привязано к коду:
| Функция | Что документирует |
|---|---|
ExampleAbs | Функцию Abs |
ExampleUser_Name | Метод Name типа User |
ExampleNewUser | Функцию NewUser |
Example | Пакет целиком |
Главное применение — библиотечный код. Пользователь видит работающий пример прямо в документации, а пример не устаревает, потому что go test его проверяет при каждом прогоне.
Табличные тесты
Табличные тесты (table-driven tests) — основной паттерн тестирования в Go. Вместо того чтобы писать отдельную функцию на каждый кейс, вы описываете все случаи в слайсе структур и прогоняете их в цикле через t.Run:
package validation
import (
"regexp"
"testing"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
func ValidateEmail(email string) bool {
if len(email) > 254 {
return false
}
return emailRegex.MatchString(email)
}
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "валидный email", email: "user@example.com", want: true},
{name: "с точкой в имени", email: "user.name@example.com", want: true},
{name: "с плюсом", email: "user+tag@example.com", want: true},
{name: "без @", email: "userexample.com", want: false},
{name: "без домена", email: "user@", want: false},
{name: "пустая строка", email: "", want: false},
{name: "пробелы", email: "user @example.com", want: false},
{name: "слишком длинный", email: string(make([]byte, 255)) + "@example.com", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, ожидали %v", tt.email, got, tt.want)
}
})
}
}
Вывод с -v:
$ go test -v -run TestValidateEmail
=== RUN TestValidateEmail
=== RUN TestValidateEmail/валидный_email
=== RUN TestValidateEmail/с_точкой_в_имени
=== RUN TestValidateEmail/с_плюсом
=== RUN TestValidateEmail/без_@
=== RUN TestValidateEmail/без_домена
=== RUN TestValidateEmail/пустая_строка
=== RUN TestValidateEmail/пробелы
=== RUN TestValidateEmail/слишком_длинный
--- PASS: TestValidateEmail (0.00s)
--- PASS: TestValidateEmail/валидный_email (0.00s)
--- PASS: TestValidateEmail/с_точкой_в_имени (0.00s)
--- PASS: TestValidateEmail/с_плюсом (0.00s)
--- PASS: TestValidateEmail/без_@ (0.00s)
--- PASS: TestValidateEmail/без_домена (0.00s)
--- PASS: TestValidateEmail/пустая_строка (0.00s)
--- PASS: TestValidateEmail/пробелы (0.00s)
--- PASS: TestValidateEmail/слишком_длинный (0.00s)
PASS
Каждый кейс — отдельный подтест с именем. Можно запустить конкретный:
$ go test -v -run "TestValidateEmail/без_@"
Добавить новый кейс — одна строка в слайсе. Не нужно дублировать логику вызова и проверки.
Подтесты с t.Run()
t.Run используется не только в табличных тестах. Подтесты удобны для логической группировки, когда одна тестовая функция проверяет несколько аспектов:
func TestUserService(t *testing.T) {
svc := NewUserService(newMockRepo())
t.Run("создание", func(t *testing.T) {
user, err := svc.Create("alice", "alice@example.com")
if err != nil {
t.Fatalf("Create: %v", err)
}
if user.Name != "alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "alice")
}
})
t.Run("дубликат email", func(t *testing.T) {
_, err := svc.Create("bob", "alice@example.com")
if err == nil {
t.Error("ожидали ошибку при дублировании email")
}
})
t.Run("поиск по ID", func(t *testing.T) {
user, err := svc.FindByID(1)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if user.Email != "alice@example.com" {
t.Errorf("Email = %q, ожидали %q", user.Email, "alice@example.com")
}
})
}
Фильтрация через -run работает по иерархии: -run TestUserService/создание запустит только подтест «создание».
Параллельные тесты с t.Parallel()
Вызов t.Parallel() внутри подтеста сигнализирует Go, что этот подтест можно запускать параллельно с другими параллельными подтестами:
func TestValidateEmail_Parallel(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "валидный", email: "user@example.com", want: true},
{name: "без @", email: "invalid", want: false},
{name: "пустой", email: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, ожидали %v", tt.email, got, tt.want)
}
})
}
}
До Go 1.22 здесь была ловушка: переменная цикла tt захватывалась по ссылке, и к моменту выполнения горутины содержала значение последней итерации. Приходилось писать tt := tt внутри цикла. Начиная с Go 1.22, переменные цикла создаются заново на каждую итерацию, и эта проблема исчезла.
Флаг -race хорошо сочетается с t.Parallel() — параллельное выполнение повышает шансы обнаружить гонку данных:
$ go test -race -v ./...
Когда применять t.Parallel():
| Ситуация | Использовать? | Причина |
|---|---|---|
| Чистые функции без побочных эффектов | Да | Нет общего состояния |
| Тесты с общим состоянием (БД, файлы) | Нет | Гонки и непредсказуемые результаты |
| Медленные тесты с сетевыми вызовами | Да | Ускоряет прогон |
Тесты с t.Setenv() | Нет | Переменные окружения — глобальное состояние |
| Тесты, зависящие от порядка выполнения | Нет | Порядок не гарантирован |
Хелперы: t.Helper(), t.Cleanup() и t.TempDir()
t.Helper()
Если вы выносите проверки в отдельную функцию, при провале теста Go покажет строку внутри хелпера, а не строку вызова. t.Helper() исправляет это — ошибка будет указывать на вызывающий код:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("получили %d, ожидали %d", got, want)
}
}
func TestCalculation(t *testing.T) {
assertEqual(t, Abs(-5), 5) // При ошибке укажет на эту строку
assertEqual(t, Abs(3), 3)
}
t.Cleanup()
t.Cleanup() регистрирует функцию, которая будет вызвана после завершения теста (и всех его подтестов). В отличие от defer, cleanup-функции выполняются даже после t.Fatal:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("postgres", "postgres://localhost/testdb?sslmode=disable")
if err != nil {
t.Fatalf("не удалось подключиться к БД: %v", err)
}
// Будет вызвано после завершения теста
t.Cleanup(func() {
db.Exec("DELETE FROM users WHERE email LIKE '%@test.example.com'")
db.Close()
})
return db
}
func TestCreateUser(t *testing.T) {
db := setupTestDB(t)
// Работаем с БД, cleanup вызовется автоматически
_, err := db.Exec("INSERT INTO users (email) VALUES ($1)", "alice@test.example.com")
if err != nil {
t.Fatalf("INSERT: %v", err)
}
}
t.TempDir()
t.TempDir() создаёт временную директорию, которая автоматически удаляется после теста:
func TestWriteConfig(t *testing.T) {
dir := t.TempDir() // Удалится после теста
path := filepath.Join(dir, "config.json")
err := WriteConfig(path, Config{Port: 8080})
if err != nil {
t.Fatalf("WriteConfig: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "8080") {
t.Error("конфиг не содержит порт 8080")
}
}
Папка testdata
Go-тулчейн специально обрабатывает директорию testdata. go build игнорирует её при сборке, а go test делает доступной для чтения. Это стандартное место для тестовых данных: входных файлов, эталонных ответов (golden files), конфигов, SQL-дампов.
mypackage/
├── parser.go
├── parser_test.go
└── testdata/
├── valid_input.json
├── malformed.json
└── golden/
└── expected_output.json
Доступ к файлам возможен через относительный путь от пакета:
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile("testdata/valid_input.json")
if err != nil {
t.Fatalf("не удалось прочитать фикстуру: %v", err)
}
got, err := ParseConfig(data)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if got.Port != 8080 {
t.Errorf("Port = %d, ожидали 8080", got.Port)
}
}
Golden files
Существует паттерн Golden files, при котором эталонный результат хранится в файле. Тест сравнивает вывод функции с содержимым файла. Если формат вывода меняется, golden-файл обновляют через флаг:
var update = flag.Bool("update", false, "обновить golden files")
func TestRender(t *testing.T) {
got := Render(inputData)
goldenPath := "testdata/golden/expected_output.json"
if *update {
os.WriteFile(goldenPath, got, 0644)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("не удалось прочитать golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("результат не совпадает с golden file:\nполучили:\n%s\nожидали:\n%s", got, want)
}
}
Обновление эталонов:
$ go test -run TestRender -update
После обновления golden-файл коммитится в репозиторий. То есть эталонные файлы видны в git diff. На ревью проще понять, как изменился вывод для тех или иных данных (текстовых и бинарных).
Стоит выбирать testdata вместо t.TempDir(), когда тестовые данные фиксированы и переиспользуются между запусками (фикстуры, эталоны, примеры невалидных входов). Метод t.TempDir() нужен для файлов, создаваемых временно и не нужных после завершения.
Fuzz-тестирование
С Go 1.18 фаззинг встроен в стандартную библиотеку. Фаззер генерирует случайные входные данные и скармливает их вашей функции, пытаясь вызвать панику или нарушение инвариантов. Табличные тесты проверяют случаи, которые вы придумали. Фаззер ищет те, которые вы не придумали.
Fuzz-функция начинается с Fuzz и принимает *testing.F:
func FuzzValidateEmail(f *testing.F) {
// Seed-корпус: начальные примеры, от которых фаззер отталкивается
f.Add("user@example.com")
f.Add("")
f.Add("@")
f.Add("a@b.c")
f.Add(string(make([]byte, 300)))
f.Fuzz(func(t *testing.T, email string) {
// Функция не должна паниковать ни на каком входе
ValidateEmail(email)
})
}
Запуск:
$ go test -fuzz FuzzValidateEmail
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 352781 (117573/sec), new interesting: 12 (total: 17)
^C
Фаззер работает, пока не найдёт ошибку или пока вы не остановите его через Ctrl+C. Флаг -fuzztime ограничивает время:
$ go test -fuzz FuzzValidateEmail -fuzztime=30s
Если фаззер нашёл входные данные, вызывающие ошибку, он сохраняет их в testdata/fuzz/<FuzzTestName>/. При следующем запуске go test (уже без -fuzz) этот кейс прогонится как обычный регрессионный тест. Добавлять крашащие входы вручную не нужно.
Проверять можно не только отсутствие паник. Типичный приём — round-trip: сериализуем, десериализуем и сравниваем с оригиналом:
func FuzzJSONRoundTrip(f *testing.F) {
f.Add("Alice", "alice@example.com", 30)
f.Fuzz(func(t *testing.T, name, email string, age int) {
original := User{Name: name, Email: email, Age: age}
data, err := json.Marshal(original)
if err != nil {
t.Skip("невалидный вход для JSON")
}
var decoded User
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal не смог прочитать то, что Marshal записал: %v", err)
}
if original != decoded {
t.Errorf("round-trip изменил данные:\nдо: %+v\nпосле: %+v", original, decoded)
}
})
}
t.Skip внутри fuzz-функции пропускает конкретный сгенерированный вход, не останавливая фаззинг целиком. Используйте его для входов, которые заведомо невалидны для тестируемой функции.
Фаззинг полезнее всего там, где код принимает произвольный внешний ввод: парсеры, валидаторы, кодеки. Однажды фаззингом я нашёл баг в JSON-библиотеке sonic. Разбор здесь.
Тестирование таймеров и конкурентности: testing/synctest
Код с таймерами тестировать сложно. Ретрай с экспоненциальной задержкой ждёт между попытками 1, 2 и 4 секунды. Тест на него будет работать семь секунд. Внутренний кэш приложения с TTL в 5 минут юнит-тестами не покрыть вообще: слишком долго, особенно для запусков в CI. Обычный подход: передать интерфейс для работы со временем и подменять его в тесте. Работает, но продакшн-код всё больше будет обрастать абстракциями только ради тестов.
С Go 1.25 в стандартной библиотеке появился testing/synctest. Функция synctest.Test запускает тело теста в изолированном «пузыре» (bubble). Внутри пузыря пакет time работает на фейковых часах. Часы стоят на месте и сдвигаются только когда все горутины «пузыря» надёжно заблокированы. Точка отсчёта этого времени в этом «пузыре» полночь 1 января 2000 года по UTC.
Вот тест на ретрай с задержками:
func Retry(attempts int, base time.Duration, fn func() error) error {
var err error
for i := 0; i < attempts; i++ {
if err = fn(); err == nil {
return nil
}
if i < attempts-1 {
time.Sleep(base << i)
}
}
return errors.New("все попытки исчерпаны: " + err.Error())
}
func TestRetry_Backoff(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
calls := 0
err := Retry(4, time.Second, func() error {
calls++
return errors.New("сервис недоступен")
})
if err == nil {
t.Fatal("ожидали ошибку после четырёх попыток")
}
if calls != 4 {
t.Errorf("вызовов = %d, ожидали 4", calls)
}
// Паузы 1s + 2s + 4s между четырьмя попытками.
if got := time.Since(start); got != 7*time.Second {
t.Errorf("прошло %v, ожидали 7s", got)
}
})
}
$ go test -v -run TestRetry_Backoff ./...
=== RUN TestRetry_Backoff
--- PASS: TestRetry_Backoff (0.00s)
Семь секунд внутреннего времени прошли за доли миллисекунды. При этом тест проверяет реальные задержки: time.Since(start) вернёт ровно 7s, и сравнение идёт на точное равенство, а не примерное. В обычном тесте так писать нельзя, там всегда набегают лишние микросекунды.
Тот же приём снимает ограничение на длинные интервалы. Проверка TTL кэша на 5 минут:
func TestCache_Expiration(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewCache(5 * time.Minute)
c.Set("user:1", "Alice")
time.Sleep(5*time.Minute - time.Nanosecond)
if _, ok := c.Get("user:1"); !ok {
t.Fatal("значение пропало раньше времени")
}
time.Sleep(time.Nanosecond)
if _, ok := c.Get("user:1"); ok {
t.Fatal("значение осталось после истечения TTL")
}
})
}
Граница TTL проверяется с точностью до наносекунд. Реальным часам операционных систем такая проверка недоступна в принципе.
synctest.Wait: дождаться, пока всё успокоится
Фейковых часов бывает мало. Часто нужно убедиться, что фоновая горутина уже отработала. Функция synctest.Wait блокируется до момента, когда все остальные горутины пузыря надёжно заблокированы. Это замена захардкоженным конструкциям вида «поспим 50 миллисекунд», которые мы как разработчики были вынуждены писать раньше в надежде, что этого хватит:
func TestWorker_StopsOnCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
stopped := false
go func() {
<-ctx.Done()
stopped = true
}()
synctest.Wait()
if stopped {
t.Fatal("воркер остановился до отмены контекста")
}
cancel()
synctest.Wait()
if !stopped {
t.Fatal("воркер не остановился после отмены контекста")
}
})
}
Обратите внимание: переменная stopped пишется из одной горутины и читается из другой без мьютекса, и go test -race на это не ругается. synctest.Wait устанавливает отношение happens-before между завершением горутины и продолжением теста.
synctest.Sleep: детерминизм при равных задержках
В Go 1.27 добавили synctest.Sleep. Это в точности time.Sleep плюс synctest.Wait:
synctest.Sleep(d)
// эквивалентно
time.Sleep(d)
synctest.Wait()
Зачем отдельная функция. Если тест и тестируемый код спят одинаковое время, часы сдвинутся для обоих одновременно и порядок пробуждения не определён. Такой тест выглядит детерминированным, но результат меняется от запуска к запуску:
func TestSleepVsWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var got string
go func() {
time.Sleep(time.Second)
got = "готово"
}()
time.Sleep(time.Second)
t.Log("значение записано:", got == "готово")
})
}
Я прогнал этот тест 200 раз: 99 раз got осталась пустой, 101 раз заполнилась. Заодно go test -race периодически ловит здесь гонку, и это честно: синхронизации между горутинами в таком тесте нет. Замена последнего time.Sleep на synctest.Sleep даёт 300 одинаковых результатов из 300, в том числе под -race. Тест сначала досыпает до нужной отметки, а потом ждёт, пока система под тестом «устаканится».
Практический пример с тикером:
func TestHeartbeat(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
beats := 0
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
beats++
}
}
}()
synctest.Sleep(3 * time.Second)
if beats != 3 {
t.Errorf("ударов = %d, ожидали 3", beats)
}
})
}
Что считается блокировкой
Время двигается, только когда все горутины пузыря надёжно заблокированы (durably blocked). Надёжно — значит разбудить горутину может лишь событие внутри того же пузыря:
| Операция | Надёжно блокирует? |
|---|---|
| Отправка и приём на канале, созданном в пузыре | Да |
select, где все каналы созданы в пузыре | Да |
time.Sleep | Да |
sync.WaitGroup.Wait, если Add вызывался в пузыре | Да |
sync.Cond.Wait | Да |
Захват sync.Mutex и sync.RWMutex | Нет |
| Чтение из сокета и любой ввод-вывод | Нет |
| Системные вызовы | Нет |
Проверил на практике, чем это грозит. Горутина, вставшая на mu.Lock(), не считается надёжно заблокированной, поэтому synctest.Wait из такого теста не возвращается никогда. Тест не падает с понятной ошибкой, он просто висит до panic: test timed out after 30s.
Отсюда главное практическое ограничение: настоящая сеть ломает всю схему. Горутина, висящая на чтении из сокета, не даёт пузырю уснуть, и время перестаёт двигаться. Поэтому в тестах с synctest сеть подменяют. Для HTTP хорошо подходит net.Pipe в паре с http.Transport.DialContext, пример такого теста есть в документации пакета.
Ловушки
Пузырь строг к тому, что происходит внутри. Четыре вещи стоит знать заранее.
Утечка горутины роняет тест. Если после выхода из тела теста осталась заблокированная горутина, synctest.Test паникует:
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain
Это скорее подарок, чем проблема. Обычный тест утечку не заметит, а пузырь показывает её сразу. Отсюда правило: каждую фоновую горутину внутри пузыря нужно останавливать через контекст или канал.
Взаимная блокировка тоже даёт панику, а не таймаут через десять минут:
panic: deadlock: all goroutines in bubble are blocked
t.Parallel внутри пузыря запрещён. Вызов паникует с сообщением testing: t.Parallel called inside synctest bubble. Параллелить можно сами тесты снаружи, но не тело пузыря.
Каналы, таймеры и тикеры, созданные в пузыре, к нему привязаны. Операция над ними снаружи роняет процесс:
fatal error: send on synctest channel from outside bubble
Это именно fatal error, а не паника. Перехватить его через recover нельзя. То же с sync.WaitGroup: после первого Add или Go внутри пузыря вызов снаружи считается фатальной ошибкой. Отдельная тонкость: пакетная переменная вида var wg sync.WaitGroup с пузырём не связывается вообще, и операции над ней могут не быть надёжно блокирующими. Для var wg = new(sync.WaitGroup) ограничение не действует.
Когда применять
synctest покрывает конкретный класс задач: код, где логика завязана на время и на порядок работы горутин. Ретраи, бэкофф, таймауты, TTL, дебаунс, тикеры, graceful shutdown, лимитеры. Для обычных табличных тестов чистых функций он не нужен.
Проверка на моей машине: четыре теста из этого раздела с флагами -race -count=100 отрабатывают за 1,1 секунды. Один только тест на ретрай без пузыря занял бы 700 секунд.
Минимальная версия — Go 1.25 для synctest.Test и synctest.Wait, Go 1.27 для synctest.Sleep. В Go 1.24 пакет существовал под GOEXPERIMENT=synctest и с другим API (synctest.Run вместо synctest.Test), в продакшн-тестах на него закладываться не стоило.
Моки через интерфейсы
В Go моки строятся на интерфейсах. Принцип «accept interfaces, return structs» — принимайте интерфейс как зависимость, а реализацию подставляйте при тестировании.
Ручные моки
Самый простой подход — написать структуру, которая реализует нужный интерфейс:
// Интерфейс репозитория
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
// Сервис принимает интерфейс
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("поиск пользователя: %w", err)
}
return user, nil
}
Мок — обычная структура с заранее заданным поведением:
type mockUserRepo struct {
users map[int]*User
err error
}
func (m *mockUserRepo) FindByID(id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("пользователь %d не найден", id)
}
return user, nil
}
func (m *mockUserRepo) Save(user *User) error {
if m.err != nil {
return m.err
}
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
repo := &mockUserRepo{
users: map[int]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
svc := NewUserService(repo)
t.Run("существующий пользователь", func(t *testing.T) {
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "Alice")
}
})
t.Run("несуществующий пользователь", func(t *testing.T) {
_, err := svc.GetUser(999)
if err == nil {
t.Error("ожидали ошибку для несуществующего пользователя")
}
})
t.Run("ошибка репозитория", func(t *testing.T) {
repo := &mockUserRepo{err: fmt.Errorf("connection refused")}
svc := NewUserService(repo)
_, err := svc.GetUser(1)
if err == nil {
t.Error("ожидали ошибку при сбое репозитория")
}
})
}
Ручные моки подходят, когда интерфейс маленький (1-3 метода) и поведение простое.
testify/mock
Когда интерфейс разрастается или нужно проверять, какие методы вызывались и с какими аргументами, удобнее testify/mock:
import (
"testing"
"github.com/stretchr/testify/mock"
)
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) FindByID(id int) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepo) Save(user *User) error {
args := m.Called(user)
return args.Error(0)
}
func TestGetUser_Testify(t *testing.T) {
repo := new(MockUserRepo)
// Настраиваем ожидаемое поведение
repo.On("FindByID", 1).Return(&User{ID: 1, Name: "Alice"}, nil)
repo.On("FindByID", 999).Return(nil, fmt.Errorf("не найден"))
svc := NewUserService(repo)
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "Alice")
}
// Проверяем, что все ожидаемые вызовы произошли
repo.AssertExpectations(t)
}
Сравнение подходов к мокам
| Подход | Зависимости | Автогенерация | Проверка вызовов | Когда использовать |
|---|---|---|---|---|
| Ручные моки | Нет | Нет | Вручную | Маленькие интерфейсы, простое поведение |
testify/mock | testify | Нет | AssertExpectations | Средние интерфейсы, нужна проверка вызовов |
gomock | gomock + mockgen | Да (mockgen) | EXPECT() + верификация | Большие интерфейсы, строгий контроль порядка вызовов |
gomock с генератором mockgen удобен для больших интерфейсов, где писать моки руками утомительно. Реализация генерируется автоматически из определения интерфейса.
testify: assert и require
Пакет testify — самая популярная библиотека для удобных проверок в Go-тестах. Два основных пакета: assert и require.
assert при провале продолжает тест. require при провале останавливает тест (аналогично t.Fatal):
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderCreation(t *testing.T) {
svc := NewOrderService(newMockDB())
// require для setup — если заказ не создался, проверять нечего
order, err := svc.CreateOrder(CreateOrderRequest{
UserID: 1,
Items: []Item{{ProductID: 42, Quantity: 2}},
})
require.NoError(t, err)
require.NotNil(t, order)
// assert для проверок — увидим все ошибки за один прогон
assert.Equal(t, 1, order.UserID)
assert.Equal(t, StatusPending, order.Status)
assert.Len(t, order.Items, 1)
assert.False(t, order.CreatedAt.IsZero())
}
require для setup-кода (подключение, создание объектов). assert для самих проверок — при провале увидите все несовпадения, а не только первое.
Тестирование HTTP с httptest
Стандартный пакет net/http/httptest покрывает два сценария: тестирование ваших обработчиков и тестирование кода, который вызывает внешние HTTP API.
Тестирование обработчиков
httptest.NewRecorder создаёт фейковый http.ResponseWriter, который записывает ответ в память:
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("статус %d, ожидали %d", rec.Code, http.StatusOK)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("не удалось распарсить ответ: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %q, ожидали %q", body["status"], "ok")
}
}
Здесь нет сетевых вызовов — обработчик вызывается напрямую как обычная функция. Тест работает за микросекунды.
Тестирование HTTP-клиентов
httptest.NewServer поднимает реальный HTTP-сервер на случайном порте. Используется для тестирования кода, который обращается к внешним API:
func TestGitHubClient(t *testing.T) {
// Поднимаем тестовый сервер, который имитирует GitHub API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/octocat" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"login": "octocat",
"name": "The Octocat",
"public_repos": 8,
})
}))
defer server.Close()
// Подставляем URL тестового сервера вместо реального
client := NewGitHubClient(server.URL)
user, err := client.GetUser("octocat")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Login != "octocat" {
t.Errorf("Login = %q, ожидали %q", user.Login, "octocat")
}
if user.PublicRepos != 8 {
t.Errorf("PublicRepos = %d, ожидали %d", user.PublicRepos, 8)
}
}
Ключевой момент: ваш HTTP-клиент должен принимать базовый URL как параметр. В продакшене это https://api.github.com, в тестах — server.URL.
Этим базовый сценарий и исчерпывается. Дальше начинается то, что httptest.NewServer уже не покрывает: ошибки соединения, обрыв тела на середине, разница между client.Timeout и дедлайном контекста, проверка ретраев и backoff, TLS. Для первых двух нужен другой инструмент — подмена http.RoundTripper. Всё это разобрано отдельно в статье про юнит-тестирование HTTP-клиентов.
Покрытие тестами
Go имеет встроенную поддержку покрытия кода тестами:
$ go test -cover ./...
ok mypackage 0.003s coverage: 78.4% of statements
Для детального анализа — профиль покрытия и визуализация в браузере:
$ go test -coverprofile=coverage.out ./...
$ go tool cover -html=coverage.out
В открывшейся HTML-странице зелёным отмечены покрытые строки, красным — непокрытые.
Практические рекомендации по уровням покрытия:
| Покрытие | Оценка | Рекомендация |
|---|---|---|
| < 40% | Низкое | Начните с тестов для критичной бизнес-логики |
| 40–70% | Приемлемое | Достаточно для большинства проектов на старте |
| 70–85% | Хорошее | Оптимальный уровень для production-кода |
| > 85% | Высокое | Имеет смысл для библиотек и критичных модулей |
Не гонитесь за 100%. Стопроцентное покрытие формально достижимо, но требует тестирования тривиальных вещей (геттеры, форматирование ошибок) и приводит к хрупким тестам, которые ломаются при любом рефакторинге. Лучше 75% осмысленных тестов, чем 100% с моками на каждую строку.
Команда для CI:
$ go test -race -cover -count=1 ./...
Флаг -race включает детектор гонок данных. -count=1 отключает кэширование — тесты будут запускаться при каждом вызове.
Пропуск тестов: t.Skip() и testing.Short()
Не все тесты нужно запускать каждый раз. Тесты с сетевыми вызовами или контейнерами замедляют цикл разработки. t.Skip() пропускает тест по любому условию:
func TestWithExternalAPI(t *testing.T) {
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
t.Skip("API_KEY не задан, пропускаем")
}
// ... тест с реальным API
}
Для типичного сценария «быстрый прогон без медленных тестов» есть флаг -short и функция testing.Short():
func TestSlowIntegration(t *testing.T) {
if testing.Short() {
t.Skip("пропускаем в short-режиме")
}
// ... тест, который работает 10 секунд
}
# Быстрый прогон при разработке
$ go test -short ./...
# Полный прогон в CI
$ go test ./...
-short работает по соглашению: Go не пропускает тесты автоматически, это ваш код проверяет testing.Short() и решает, что делать. Тесты, которые не проверяют этот флаг, выполнятся в любом случае.
Когда -short недостаточно и нужно жёстко отделить интеграционные тесты от юнит-тестов, используют build tags.
Интеграционные тесты
Интеграционные тесты медленнее юнит-тестов и требуют внешних зависимостей (базу данных, очередь, сеть). В Go их принято отделять с помощью build tags:
//go:build integration
package storage
import (
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup: подключаемся к тестовой БД
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "postgres://localhost/testdb?sslmode=disable"
}
var err error
testDB, err = sql.Open("postgres", dsn)
if err != nil {
panic("не удалось подключиться к БД: " + err.Error())
}
// Запускаем тесты
code := m.Run()
// Teardown: очищаем и закрываем
testDB.Exec("DELETE FROM users WHERE email LIKE '%@test.example.com'")
testDB.Close()
os.Exit(code)
}
func TestUserRepository_Create(t *testing.T) {
repo := NewUserRepository(testDB)
user, err := repo.Create("test@test.example.com", "Test User")
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
testDB.Exec("DELETE FROM users WHERE id = $1", user.ID)
})
found, err := repo.FindByID(user.ID)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if found.Email != "test@test.example.com" {
t.Errorf("Email = %q, ожидали %q", found.Email, "test@test.example.com")
}
}
Директива //go:build integration в начале файла означает, что эти тесты не запустятся при обычном go test ./.... Для запуска нужен явный тег:
$ go test -tags=integration ./...
TestMain — точка входа для всего пакета. Вызывается один раз перед всеми тестами. Используется для инициализации ресурсов (подключение к БД, запуск контейнеров) и очистки после.
Для полноценных интеграционных тестов с контейнерами есть testcontainers-go — библиотека, которая поднимает Docker-контейнеры прямо из тестов. Это отдельная тема, которая заслуживает своей статьи.
Типичные ошибки
| Ошибка | Пример | Как исправить |
|---|---|---|
| Захват переменной цикла (до Go 1.22) | for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel(); ... }) } | Добавить tt := tt перед t.Run или обновиться до Go 1.22+ |
| Тестирование реализации вместо поведения | Проверка количества вызовов internal-метода | Тестируйте публичный API: вход → выход |
| Слишком много проверок в одном тесте | 20 assert-ов в одной функции | Разбить на подтесты с t.Run |
Отсутствие -race | go test ./... | Всегда go test -race ./... в CI |
time.Sleep вместо синхронизации | time.Sleep(time.Second) в тесте на горутину | Каналы, sync.WaitGroup, synctest.Wait из testing/synctest, require.Eventually из testify |
| Общее состояние между тестами | Глобальная переменная, изменяемая в разных тестах | Создавать состояние внутри каждого теста или в setup-функции |
Заключение
- Табличные тесты — основной паттерн. Слайс структур +
t.Runпокрывают большинство случаев. - Example-функции — документация и тест в одном месте. Полезны для библиотечного кода.
t.Helper(),t.Cleanup(),t.TempDir()— стандартная библиотека даёт всё для организации тестов.- Фаззинг — встроен с Go 1.18. Находит крайние случаи, которые вы не придумаете.
- Моки через интерфейсы — сначала пишите моки руками, переходите к
testify/mockилиgomock, когда интерфейсы разрастаются. httptestпокрывает оба направления: тестирование своих обработчиков и подмена внешних API.-raceв CI — обязательно. Гонки данных не воспроизводятся стабильно, но детектор их находит.- Покрытие 70–85% — разумный ориентир. Не гонитесь за цифрой, тестируйте критичный код.
testing/synctest— с Go 1.25 в стандартной библиотеке. Фейковые часы для ретраев, таймаутов и TTL: семь секунд ожидания превращаются в доли миллисекунды.-shortи build tags — два способа разделить быстрые и медленные тесты.-shortпроще, build tags надёжнее.
Теги: