Go Тестирование в Go: табличные тесты, моки, интеграция и CI
0%

Тестирование в Go: табличные тесты, моки, интеграция и CI

Тестирование в 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-клиент к моку внешнего сервиса.
  • 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 зелёный.
  • Тесты детерминированы и не зависят от времени/порядка.

Источники

Что дальше

Код протестирован. Пора собрать из него надёжную систему: архитектура, слои, внедрение зависимостей, конфигурация и работа с БД в проде.

06. Архитектура и прод-код

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов