Тестирование в Go
Тестирование в Go — часть языка, а не сторонняя библиотека. Пакет testing, команда go test, покрытие, бенчмарки, race-детектор и fuzzing идут из коробки. Это отражает философию Go: тесты — не роскошь, а рутина, которая должна быть тривиально доступной. В этой главе — от первого теста до интеграционных сценариев с настоящей БД и встраивания в CI.
Первый тест
Тесты живут рядом с кодом, в файлах *_test.go, в том же пакете. Функция теста начинается с Test, принимает *testing.T.
// math.go
package math
func Add(a, b int) int { return a + b }
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
go test ./... # прогнать все тесты
go test -v ./... # подробно, с именами тестов
go test -run TestAdd ./... # только тесты, чьё имя матчит regexp
Ключевое про API *testing.T: t.Errorf помечает тест провалившимся, но продолжает выполнение; t.Fatalf помечает и немедленно останавливает тест (когда дальше идти бессмысленно, например nil-указатель). В Go нет assert в ядре — вы пишете обычный if с понятным сообщением. Это осознанный минимализм: сообщение об ошибке должно объяснять что пошло не так, поэтому формат got X; want Y — стандарт.
Табличные тесты — идиома номер один
Вместо десяти похожих функций — одна таблица случаев. Это самый узнаваемый паттерн тестирования в Go, его используют в стандартной библиотеке повсеместно.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"положительные", 2, 3, 5},
{"с нулём", 0, 7, 7},
{"отрицательные", -2, -3, -5},
{"разные знаки", -5, 3, -2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // подтест — свой узел в дереве результатов
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
t.Run создаёт подтест — его видно отдельно в выводе, можно запустить точечно (go test -run TestAdd/с_нулём), и падение одного случая не мешает остальным. Добавить новый случай — одна строка. Это делает тесты дешёвыми в поддержке, а значит, их пишут.
Полезные помощники *testing.T:
t.Helper()— пометить функцию как вспомогательную, чтобы в отчёте показывалась строка вызова, а не внутренностей хелпера.t.Parallel()— пометить тест для параллельного запуска с другими параллельными.t.Cleanup(fn)— зарегистрировать очистку, выполнится после теста (аналогdefer, но работает и в подтестах).t.TempDir()— временный каталог, автоматически удаляемый после теста.t.Setenv(k, v)— переменная окружения на время теста.
testify — популярная библиотека ассертов
Стандартные if got != want многословны. Библиотека stretchr/testify — де-факто индустриальный стандарт для лаконичных проверок. Учтите: команда Go её не рекомендует официально, но в реальных проектах она встречается чаще всего.
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUser(t *testing.T) {
u, err := ParseUser(`{"id":1,"name":"Аня"}`)
require.NoError(t, err) // require: при провале СТОПИТ тест (как Fatal)
assert.Equal(t, int64(1), u.ID) // assert: при провале продолжает (как Error)
assert.Equal(t, "Аня", u.Name)
assert.NotEmpty(t, u.Name)
}
Разница require vs assert та же, что Fatal vs Error: require останавливает (используйте, когда дальше проверять нет смысла — например, err != nil), assert продолжает (когда хотите увидеть все несовпадения сразу). testify/suite даёт setup/teardown в ООП-стиле, но многие предпочитают обходиться t.Cleanup.
Моки и интерфейсы
Философия Go: тестируемость достигается через маленькие интерфейсы, которые объявляет потребитель. Чтобы протестировать сервис, не ходя в реальную БД, сервис зависит не от *sql.DB, а от узкого интерфейса.
// Сервис зависит от абстракции, а не от конкретной БД
type UserRepo interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
type UserService struct {
repo UserRepo
}
func (s *UserService) Greeting(ctx context.Context, id int64) (string, error) {
u, err := s.repo.GetUser(ctx, id)
if err != nil {
return "", fmt.Errorf("Greeting: %w", err)
}
return "Привет, " + u.Name, nil
}
Ручной мок (часто этого достаточно)
type fakeRepo struct {
user *User
err error
}
func (f *fakeRepo) GetUser(ctx context.Context, id int64) (*User, error) {
return f.user, f.err
}
func TestGreeting(t *testing.T) {
svc := &UserService{repo: &fakeRepo{user: &User{Name: "Аня"}}}
got, err := svc.Greeting(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, "Привет, Аня", got)
}
Для узкого интерфейса ручной фейк — чище и понятнее любой генерации. Не спешите тянуть библиотеку моков ради двух методов.
Генерация моков: mockery и gomock
Когда интерфейсы крупнее, моки генерируют. Два лидера:
- mockery — генерирует моки на базе testify. Настраивается через
.mockery.yaml, вызовmockery. Удобныеmock.On("GetUser", ...).Return(...). - go.uber.org/mock (форк google/gomock, официальный больше не поддерживается) — строгий контроль ожиданий и порядка вызовов через
gomock.Controller.
// mockery-стиль
m := mocks.NewUserRepo(t)
m.On("GetUser", mock.Anything, int64(1)).Return(&User{Name: "Аня"}, nil)
svc := &UserService{repo: m}
// ... m.AssertExpectations(t) — проверит, что все ожидания сработали
Генерацию подключают через директиву в коде и go generate:
//go:generate mockery --name=UserRepo
Прагматика: генерируйте моки только для интерфейсов с реальным поведением и несколькими методами. Для однометодного интерфейса ручной фейк выигрывает всегда.
Тест-пирамида в контексте Go
весь сервис через HTTP/gRPC"] INT["Интеграционные — БД, брокеры, внешние API
testcontainers"] UNIT["Юнит — много, быстро, дёшево
чистая логика, табличные тесты"] E2E --> INT --> UNIT style UNIT fill:#2d5 style INT fill:#dd5 style E2E fill:#d55
- Юнит-тесты — основание. Быстрые, изолированные, без сети и диска. Табличные тесты для чистой логики: валидация, парсинг, доменные правила. Их должно быть много.
- Интеграционные — проверяют стыки с внешним миром: реальная БД, очередь, HTTP-клиент к моку внешнего сервиса.
- E2E — весь сервис целиком, обычно через его публичный API. Мало, но они ловят то, что юниты не видят.
Интеграционные тесты с настоящей БД
Мокать БД для проверки SQL — самообман: моки не поймают ошибку в запросе. Правильный подход — поднять реальную БД в контейнере через testcontainers-go.
func TestUserRepo_Integration(t *testing.T) {
if testing.Short() {
t.Skip("пропускаем интеграционный тест в -short режиме")
}
ctx := context.Background()
// Поднимаем настоящий PostgreSQL в Docker на время теста
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Terminate(ctx) }) // контейнер убирается после теста
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
db, err := sql.Open("pgx", dsn)
require.NoError(t, err)
// ... прогнать миграции, дальше тестировать реальные запросы
}
Тег testing.Short() + флаг go test -short позволяет отделять быстрые юниты от медленных интеграционных. Часто интеграционные помечают build-тегами (//go:build integration) и запускают отдельной стадией CI.
Покрытие кода
go test -cover ./... # процент покрытия в консоль
go test -coverprofile=cover.out ./... # профиль в файл
go tool cover -html=cover.out # интерактивный HTML-отчёт
go tool cover -func=cover.out # покрытие по функциям
Здравая оговорка про покрытие: это индикатор, а не цель. 100% покрытия не значит отсутствия багов — можно исполнить строку, не проверив её результат. Гонитесь за покрытием важной логики и граничных случаев, а не за красивой цифрой. Требовать 80%+ на критичных пакетах разумно; требовать 100% на всём — вредный ритуал.
Бенчмарки
Пакет testing умеет и измерять производительность. Функция начинается с Benchmark, крутит цикл b.N раз (рантайм сам подбирает N):
func BenchmarkParseUser(b *testing.B) {
data := `{"id":1,"name":"Аня"}`
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseUser(data)
}
}
go test -bench=. -benchmem ./... # запустить бенчмарки с аллокациями
-benchmem показывает аллокации на операцию — часто важнее наносекунд, потому что аллокации грузят GC. Сравнивать результаты «до/после» удобно инструментом benchstat, который считает статистическую значимость разницы.
Fuzzing — тестирование случайными входами
С Go 1.18 fuzzing встроен. Движок сам генерирует входы, ища панику или нарушение инварианта. Незаменим для парсеров и обработки недоверенного ввода.
func FuzzParseUser(f *testing.F) {
f.Add(`{"id":1,"name":"x"}`) // сид-корпус
f.Fuzz(func(t *testing.T, data string) {
_, _ = ParseUser(data) // не должно паниковать ни на каком входе
})
}
go test -fuzz=FuzzParseUser # гонять фаззер (пока не остановите)
Найденные падающие входы автоматически сохраняются в testdata/fuzz и становятся регрессионными тестами.
Golden-файлы
Для проверки крупных выводов (сгенерированный JSON, HTML, отчёт) удобны golden-файлы: эталон лежит в testdata/, тест сверяет вывод с ним, а флаг -update перезаписывает эталон.
var update = flag.Bool("update", false, "обновить golden-файлы")
func TestRender(t *testing.T) {
got := Render(input)
golden := "testdata/render.golden"
if *update {
require.NoError(t, os.WriteFile(golden, got, 0o644))
}
want, _ := os.ReadFile(golden)
assert.Equal(t, string(want), string(got))
}
Встраивание в CI
Минимальный, но правильный тестовый шаг в CI (пример для GitHub Actions):
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- run: go vet ./...
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- uses: golangci/golangci-lint-action@v6
Обязательные правила для CI:
-raceвсегда на тестах — конкурентные баги ловятся только так.go vetиgolangci-lintв отдельных шагах — быстрая обратная связь.- Интеграционные тесты (testcontainers) требуют Docker в раннере — обычно отдельная стадия.
- Тесты должны быть детерминированными: никаких
time.Sleepдля синхронизации, никакой зависимости от порядка map, реального времени (используйте инъекциюclock).
Чек-лист качественного теста на Go
- Табличная структура,
t.Runдля подтестов. - Сообщение об ошибке в формате
got X; want Y. - Зависимости за интерфейсами, моки/фейки для внешнего мира.
- Интеграционные тесты на реальной БД через testcontainers, отделены
-short/build-тегами. -
t.Cleanup/t.TempDirвместо ручной уборки. -
go test -raceзелёный. - Тесты детерминированы и не зависят от времени/порядка.
Источники
- Go blog: Table-driven tests / Testing techniques и документация пакета
testing. - Go Fuzzing — официальный гайд.
- testcontainers-go, testify, uber-go/mock.
- Doc: Add a test в официальном туториале.
Что дальше
Код протестирован. Пора собрать из него надёжную систему: архитектура, слои, внедрение зависимостей, конфигурация и работа с БД в проде.