Тесты в Go: табличные тесты, моки и testcontainers
Три уровня тестирования в Go-проекте — от табличных юнит-тестов до поднятия PostgreSQL в контейнере прямо из кода. Разбираем рабочие приёмы, сравниваем подходы к мокам и показываем, где каждый из них ломается.

Зачем три уровня, а не один
Стандартная библиотека Go покрывает юнит-тесты из коробки, но реальный сервис редко состоит из чистых функций. Есть слой бизнес-логики, есть адаптеры к базе, есть внешние API. Один инструмент не закроет всё, а попытка протестировать запрос к PostgreSQL через моки приводит к тестам, которые проходят зелёными и ничего не гарантируют. Ниже — три подхода, у каждого своя цена и своя зона применимости.
Табличные тесты: основа, которую стоит делать правильно
Табличный тест — это срез структур с входными данными и ожидаемым результатом, по которому прогоняется одна и та же логика. В Go нет фреймворка для этого — хватает testing.T и цикла range. Подход работает идеально для чистых функций и методов без побочных эффектов.
Когда табличный тест оправдан
- Функция принимает данные и возвращает результат без обращения к внешним системам.
- Нужно покрыть граничные случаи: пустая строка, отрицательное число, Unicode, nil.
- Логика ветвится по нескольким параметрам — комбинации удобнее видеть в таблице, а не в десятке отдельных функций.
Рабочий пример
Допустим, есть функция расчёта скидки. Go 1.22+, стандартная библиотека.
package pricing
// Discount возвращает процент скидки по сумме заказа.
func Discount(totalCents int64) float64 {
switch {
case totalCents >= 100_000:
return 0.15
case totalCents >= 50_000:
return 0.10
case totalCents >= 10_000:
return 0.05
default:
return 0
}
}
package pricing
import "testing"
func TestDiscount(t *testing.T) {
cases := []struct {
name string
totalCents int64
want float64
}{
{"ниже порога", 9_999, 0},
{"ровно 10 000", 10_000, 0.05},
{"средний заказ", 50_000, 0.10},
{"крупный заказ", 100_000, 0.15},
{"отрицательная сумма", -1, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Discount(tc.totalCents)
if got != tc.want {
t.Errorf("Discount(%d) = %v, хотели %v", tc.totalCents, got, tc.want)
}
})
}
}
Каждый кейс запускается как подтест — в выводе go test -v видно имя упавшего случая, а не номер строки.
Типичные ошибки
- Забытый
t.Run— без него все кейсы считаются одним тестом; падение первого скрывает остальные. - Захват переменной цикла в горутине. Начиная с Go 1.22, переменная цикла
rangeполучает новую область видимости на каждой итерации, но если вы используете более ранние версии — нужна локальная копияtc := tc. - Сравнение float через
==в более сложных расчётах. Для денег лучше работать в целых (центах), как в примере выше.
Моки: интерфейсы вместо магии
В Go нет аннотаций @Mock и runtime-патчинга. Мок — это структура, реализующая интерфейс. Можно писать руками, можно генерировать.
Ручной мок через интерфейс
Рассмотрим сервис, который отправляет уведомление после оплаты. Зависимость — Notifier.
package order
// Notifier отправляет уведомление пользователю.
type Notifier interface {
Send(userID string, message string) error
}
type Service struct {
notifier Notifier
}
func NewService(n Notifier) *Service {
return &Service{notifier: n}
}
// Confirm подтверждает заказ и отправляет уведомление.
func (s *Service) Confirm(userID string, orderID string) error {
// ...бизнес-логика...
return s.notifier.Send(userID, "Заказ "+orderID+" подтверждён")
}
package order
import (
"errors"
"testing"
)
// mockNotifier — ручной мок для тестов.
type mockNotifier struct {
called bool
userID string
message string
err error // ошибка, которую вернёт Send
}
func (m *mockNotifier) Send(userID, message string) error {
m.called = true
m.userID = userID
m.message = message
return m.err
}
func TestConfirm_Success(t *testing.T) {
mock := &mockNotifier{}
svc := NewService(mock)
if err := svc.Confirm("u-1", "ord-42"); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if !mock.called {
t.Fatal("Send не был вызван")
}
if mock.userID != "u-1" {
t.Errorf("userID = %q, хотели %q", mock.userID, "u-1")
}
}
func TestConfirm_NotifierFails(t *testing.T) {
mock := &mockNotifier{err: errors.New("smtp timeout")}
svc := NewService(mock)
err := svc.Confirm("u-1", "ord-42")
if err == nil {
t.Fatal("ожидали ошибку, получили nil")
}
}
Генерация моков
Для интерфейсов с десятком методов писать мок руками утомительно. Популярные генераторы: mockgen (из проекта go.uber.org/mock, ранее golang/mock) и moq. Оба создают структуру по интерфейсу, но с разной философией.
| Критерий | Ручной мок | mockgen | moq |
|---|---|---|---|
| Порог входа | Нулевой — обычный Go | Нужно освоить API матчеров | Генерирует простую структуру с функциональными полями |
| Читаемость теста | Высокая | Средняя — цепочки EXPECT | Высокая |
| Проверка порядка вызовов | Вручную | Встроенная (InOrder) | Вручную |
| Зависимости | Нет | go.uber.org/mock | github.com/matryer/moq |
| Когда выбирать | 1–3 метода в интерфейсе | Сложные сценарии, порядок вызовов важен | Простота, минимум магии |
Мок проверяет, что ваш код правильно вызывает зависимость. Он не проверяет, что зависимость правильно работает. Если замоканный SQL-запрос возвращает то, что вы ожидаете, а реальная база — нет, тест бесполезен.
Где моки ломаются
- Замоканный репозиторий не ловит ошибки в SQL, миграциях, индексах. Тест зелёный, продакшн — нет.
- Слишком строгие моки (точное число вызовов, точный порядок) превращают рефакторинг в ад: меняешь внутренности — ломаешь десять тестов, хотя поведение не изменилось.
- Мок на мок — если для теста нужно создать пять фейковых структур, это сигнал пересмотреть архитектуру, а не добавить шестой мок.
testcontainers-go: настоящая база в тесте
Когда мок перестаёт давать уверенность, нужна настоящая инфраструктура. Библиотека testcontainers-go поднимает Docker-контейнер прямо из теста, даёт порт и строку подключения, а после — убивает контейнер.
Сноска: testcontainers — открытый проект, реализованный для нескольких языков. Go-версия живёт в github.com/testcontainers/testcontainers-go. Требует работающий Docker на машине, где запускаются тесты.
Пример: PostgreSQL в интеграционном тесте
testcontainers-go v0.31+, Go 1.22+.
package repo_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func TestUserRepo_Create(t *testing.T) {
if testing.Short() {
t.Skip("пропускаем интеграционный тест в коротком режиме")
}
ctx := context.Background()
// Поднимаем контейнер с PostgreSQL
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("не удалось запустить контейнер: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(ctx) })
// Получаем строку подключения
connStr, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("не удалось получить строку подключения: %v", err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Создаём таблицу — в реальном проекте здесь миграции
_, err = db.ExecContext(ctx, `CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE
)`)
if err != nil {
t.Fatalf("миграция: %v", err)
}
// Проверяем вставку
_, err = db.ExecContext(ctx, "INSERT INTO users (email) VALUES ($1)", "go@test.dev")
if err != nil {
t.Fatalf("INSERT: %v", err)
}
var count int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM users").Scan(&count); err != nil {
t.Fatalf("SELECT: %v", err)
}
if count != 1 {
t.Errorf("count = %d, хотели 1", count)
}
}
Проверка: go test -v -count=1 ./repo/.... Контейнер поднимается за 3–8 секунд в зависимости от машины. При go test -short тест пропускается.
Цена подхода
- Скорость. Каждый тест, поднимающий контейнер, — это секунды. На CI с сотней интеграционных тестов это превращается в минуты. Решение — переиспользовать контейнер между тестами одного пакета через
TestMain. - Docker на CI. GitHub Actions и GitLab CI поддерживают Docker, но не все раннеры. В корпоративных средах Docker внутри CI иногда запрещён политикой безопасности.
- Параллельность. Если тесты пишут в одну базу без изоляции по схеме или транзакции, они начнут мешать друг другу. Хороший приём — оборачивать каждый тест в транзакцию с откатом.
Когда что выбирать
| Что тестируете | Подход | Почему |
|---|---|---|
| Чистая функция, расчёт, парсинг | Табличный тест | Быстро, без зависимостей, десятки кейсов за миллисекунды |
| Сервисный слой и взаимодействие компонентов | Моки (ручные или сгенерированные) | Изолирует бизнес-логику от инфраструктуры |
| SQL-запросы, миграции, транзакции | testcontainers | Только настоящая база поймает ошибку в запросе |
| Внешний API с нестабильным ответом | httptest.Server + мок-ответ | Контролируете тайминги и формат ответа |
Критерий простой: если от мока вы ожидаете тот же ответ, что и от реальной системы — мок вас обманывает, берите контейнер. Если вам важно проверить, как ваш код реагирует на ошибку — мок идеален, потому что заставить настоящий PostgreSQL вернуть «disk full» в тесте нетривиально.
Если не получилось
Cannot connect to the Docker daemon— Docker не запущен или у текущего пользователя нет прав. Проверьтеdocker psв терминале.port already allocated— testcontainers выбирает случайный порт, но если вы хардкодите порт вручную, он может быть занят. Используйте метод контейнера для получения порта.- Тест зависает на
WithWaitStrategy— контейнер не успел стартовать. УвеличьтеWithStartupTimeoutили проверьте, что образ скачан:docker pull postgres:16-alpine. undefined: postgres.Run— вы используете старую версиюtestcontainers-go, где модуль postgres ещё не имел функцииRun. Обновите до v0.31+ или используйтеpostgres.RunContainer— актуальное имя уточняйте в документации вашей версии.
Что делать дальше
Три инструмента покрывают большую часть потребностей Go-проекта, но не все. Следующие шаги:
- Настройте
-shortфлаг в CI так, чтобы пулл-реквесты прогоняли только юнит-тесты, а мерж в main — полный набор с контейнерами. - Измерьте покрытие
go test -cover— не как KPI, а чтобы увидеть непокрытые ветки ошибок. - Для сервисов с несколькими внешними зависимостями (PostgreSQL + Redis + Kafka) посмотрите на
testcontainers.Compose— он поднимает связку контейнеров по docker-compose файлу. - Интеграционные тесты с контейнерами — тема, тесно связанная с миграциями базы. Автоматический прогон миграций перед тестом — отдельная задача, которую стоит решить до масштабирования тестовой базы.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: testcontainers-go — официальная документация, Go — пакет testing, стандартная библиотека
Частые вопросы
Можно ли запускать testcontainers без Docker Desktop на macOS?
Как запускать табличные тесты параллельно?
Что делать, если на CI нет Docker и контейнерные тесты невозможны?
Тест с testcontainers работал, но после обновления библиотеки сломался — что проверить?
Стоит ли использовать testify вместо стандартной библиотеки testing?
Как изолировать интеграционные тесты друг от друга, если они используют общий контейнер с базой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.