5.2Моки и интеграционные тесты
Выберем подходящий test double для границы и отделим проверку поведения от проверки взаимодействия.
Подделки зависимостей: стаб, мок, спай, фейк
Тестируемый код лезет в базу, в платёжный шлюз и в Kafka. Что подставить вместо них, чтобы тест был быстрым и повторяемым? Подставляют «подделку», а подделки бывают пяти сортов. В повседневной речи их все зовут «моками», но на собесе разницу спрашивают почти всегда, потому что за ней стоит выбор: подставить данные или проверить факт вызова.
1. Test double - общее слово для любой подделки
Test double значит «тестовый дублёр», по аналогии с каскадёром, который заменяет актёра в опасной сцене. Термин зонтичный: так зовут любой объект, который подставили вместо настоящей зависимости на время теста. Четыре слова ниже называют его разновидности; классификацию придумал Джерард Месарош, и спрашивают обычно именно её.
2. Стаб - отвечает заранее заготовленным, ничего не проверяет
Стаб (stub - «заглушка», «корешок») нужен, чтобы поставить систему в нужное
состояние. Ты заранее говоришь: «на Get(42) верни вот этот заказ», или «верни
ошибку 503». Стаб просто отдаёт это, и всё. Провалить тест он не может,
решение принимает проверка в конце теста. Самый частый и самый безобидный дублёр.
3. Мок - заранее объявленные ожидания, проваливает тест сам
Мок (mock, «имитация», «пародия») добавляет к стабу ожидания,
записанные до вызова: «метод Charge должен быть вызван ровно один раз, с суммой 500».
Если вызвали не так, не столько раз или не в том порядке, мок роняет тест сам, без
единого ассерта в коде теста. Отсюда и его польза, и его беда: тест начинает знать, как
код устроен внутри, и ломается на любом рефакторинге, даже если поведение не изменилось.
4. Спай - стаб, который ещё и ведёт журнал вызовов
Спай (spy, «шпион») стоит посередине между стабом и моком. Он отвечает как стаб, но
попутно записывает в слайс, кто его звал и с какими аргументами. Проверяют это потом, обычной
строчкой в теле теста: if len(s.calls) != 1 { ... }. От мока спай отличается тем,
где живёт проверка: у спая - в тесте и после факта, у мока - внутри библиотеки и заранее.
5. Фейк - упрощённая, но по-настоящему работающая реализация
Фейк (fake - «подделка», в смысле «копия, которая работает») стоит особняком:
это не заглушка, а настоящая реализация интерфейса, только простая. Репозиторий на
map[int]Order с мьютексом вместо PostgreSQL, очередь в памяти вместо Kafka,
каталог на диске вместо S3. У фейка есть своё состояние: положил - прочитал, как в бою.
Поэтому тест на фейке проверяет результат, а не последовательность вызовов, и рефакторинг его
не ломает.
Стаб подставляет данные. Мок проверяет взаимодействие. Спай проверяет то же, что мок, но руками и постфактум. Фейк работает по-настоящему, просто упрощённо. Пятый дублёр Месароша, самый скучный, называется dummy: пустышка, которую передают только ради компиляции и никогда не вызывают.
Точка подмены: интерфейс объявляется у потребителя
В Java или C# нормально написать UserRepository и рядом UserRepositoryImpl:
интерфейс живёт вместе с реализацией. В Go так делать не принято, и дело не во вкусе, а в
неявной реализации интерфейсов. Типу не надо писать implements: он подходит под
интерфейс, если у него есть нужные методы. Значит, интерфейс можно объявить там, где он нужен,
и никого не трогать.
Формулировки две: «The bigger the interface, the weaker the abstraction» из Go Proverbs Роба Пайка и «Accept interfaces, return structs» - правило, которое популяризовало сообщество. Отсюда правило:
Интерфейс объявляет потребитель, а не поставщик. Пакет order сам определяет,
что ему нужен Repo с двумя методами. Пакет postgres ничего не знает про
order и просто возвращает свою структуру. Тогда: (1) интерфейс маленький,
в нём ровно то, чем пользуются, а не 30 методов «на всякий случай»; (2) стрелка зависимости
смотрит внутрь, к домену, что и называют dependency inversion; (3) моки генерируются тривиально,
потому что мокать надо два метода, а не тридцать; (4) если добавить метод в postgres.OrderRepo,
ни один мок не сломается.
NewService с другими аргументами.Полный пример: сервис + репозиторий + клиент + тест
Через всю главу пройдёт один пример, сервис оформления заказа. Он читает заказ из БД, дёргает
внешний платёжный API и сохраняет результат. Обе зависимости спрятаны за интерфейсами, объявленными
в пакете order.
// ---------- internal/order/service.go ----------
package order
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("order: не найден")
type Order struct {
ID int
UserID int
Amount int64
Status string
}
// Интерфейсы объявлены здесь, у потребителя, и в них ровно то, чем пользуется сервис.
type Repo interface {
Get(ctx context.Context, id int) (Order, error)
UpdateStatus(ctx context.Context, id int, status string) error
}
type Payments interface {
Charge(ctx context.Context, userID int, amount int64) (txID string, err error)
}
type Service struct {
repo Repo
pay Payments
}
// Конструктор принимает интерфейсы и возвращает структуру: accept interfaces, return structs.
func NewService(r Repo, p Payments) *Service {
return &Service{repo: r, pay: p}
}
func (s *Service) Pay(ctx context.Context, id int) (string, error) {
o, err := s.repo.Get(ctx, id)
if err != nil {
return "", fmt.Errorf("получить заказ %d: %w", id, err)
}
if o.Status == "paid" {
return "", errors.New("order: заказ уже оплачен") // идемпотентность
}
if o.Amount <= 0 {
return "", errors.New("order: некорректная сумма")
}
txID, err := s.pay.Charge(ctx, o.UserID, o.Amount)
if err != nil {
return "", fmt.Errorf("списание по заказу %d: %w", id, err)
}
if err := s.repo.UpdateStatus(ctx, id, "paid"); err != nil {
// Деньги списаны, а статус не обновлён: ради этого кейса тест и пишут.
return txID, fmt.Errorf("обновить статус %d (списание %s прошло): %w", id, txID, err)
}
return txID, nil
}
// ---------- internal/postgres/order_repo.go ----------
package postgres
// Никакого import "internal/order" ради интерфейса: пакет про него не знает.
// Он просто возвращает структуру, которая случайно подходит под order.Repo.
// Querier - общее у *sql.DB и *sql.Tx: в интеграционном тесте сюда придёт транзакция.
type Querier interface {
QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)
}
type OrderRepo struct {
db Querier
}
func NewOrderRepo(db Querier) *OrderRepo { return &OrderRepo{db: db} }
func (r *OrderRepo) Get(ctx context.Context, id int) (order.Order, error) { /* SQL */ }
func (r *OrderRepo) UpdateStatus(ctx context.Context, id int, status string) error { /* SQL */ }
Если postgres.OrderRepo возвращает order.Order, импорт домена всё-таки
появляется - но только ради типа данных, а не ради интерфейса. Это нормально и стрелку
зависимости не переворачивает. Плохо, когда домен импортирует postgres. Проверить
легко: go list -deps ./internal/order не должен содержать ни драйвера БД, ни
HTTP-клиента.
Теперь тест. Он написан на «ручных» дублёрах, без единой библиотеки: так видно, что моки собираются из обычных структур с полями-функциями и никакой магии в них нет.
// ---------- internal/order/service_test.go ----------
package order_test
// stubRepo: подставляет данные, поведение задаётся полями-функциями.
type stubRepo struct {
getFn func(ctx context.Context, id int) (order.Order, error)
updateFn func(ctx context.Context, id int, status string) error
updateCalls []string // а это уже spy: записываем, что с нами делали
}
func (s *stubRepo) Get(ctx context.Context, id int) (order.Order, error) {
return s.getFn(ctx, id)
}
func (s *stubRepo) UpdateStatus(ctx context.Context, id int, status string) error {
s.updateCalls = append(s.updateCalls, status)
if s.updateFn == nil {
return nil
}
return s.updateFn(ctx, id, status)
}
type stubPayments struct {
txID string
err error
gotAmount int64
}
func (s *stubPayments) Charge(ctx context.Context, userID int, amount int64) (string, error) {
s.gotAmount = amount
return s.txID, s.err
}
func TestService_Pay(t *testing.T) {
t.Parallel()
okOrder := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}
t.Run("успешная оплата", func(t *testing.T) {
t.Parallel()
repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) { return okOrder, nil }}
pay := &stubPayments{txID: "tx-42"}
got, err := order.NewService(repo, pay).Pay(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, "tx-42", got)
require.Equal(t, int64(500), pay.gotAmount) // передали правильную сумму
require.Equal(t, []string{"paid"}, repo.updateCalls) // статус обновили ровно один раз
})
t.Run("платёж упал - статус не трогаем", func(t *testing.T) {
t.Parallel()
repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) { return okOrder, nil }}
pay := &stubPayments{err: errors.New("gateway 503")}
_, err := order.NewService(repo, pay).Pay(context.Background(), 1)
require.Error(t, err)
require.Empty(t, repo.updateCalls, "нельзя помечать оплаченным, если списание не прошло")
})
t.Run("деньги списаны, БД упала - ошибка несёт txID", func(t *testing.T) {
t.Parallel()
repo := &stubRepo{
getFn: func(context.Context, int) (order.Order, error) { return okOrder, nil },
updateFn: func(context.Context, int, string) error { return errors.New("connection reset") },
}
pay := &stubPayments{txID: "tx-99"}
txID, err := order.NewService(repo, pay).Pay(context.Background(), 1)
require.Error(t, err)
require.Equal(t, "tx-99", txID, "txID обязан вернуться наружу, иначе деньги потеряются")
require.Contains(t, err.Error(), "tx-99")
})
t.Run("заказа нет", func(t *testing.T) {
t.Parallel()
repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) {
return order.Order{}, order.ErrNotFound
}}
_, err := order.NewService(repo, &stubPayments{}).Pay(context.Background(), 1)
require.ErrorIs(t, err, order.ErrNotFound) // обёртка через %w сохранила цепочку
})
}
Проверяется не «сервис позвал репозиторий», а решения: при неудачном списании статус не
меняется, при упавшей БД txID обязан вернуться наружу (иначе деньги ушли, а система
о них не знает), ошибка не потеряла цепочку и ловится errors.Is. Интеграционным
тестом такие кейсы воспроизводить дорого, а юнит-тест укладывает каждый в три строки. Ради этого
дублёры и нужны, а не ради «покрытия сервисного слоя».
Терминология: dummy, stub, spy, mock, fake
Те же пять дублёров, но теперь на одной шкале и с ответом на вопрос «чем платишь за каждый». Шкала выстроена не по «мощности», а по связанности теста с внутренним устройством кода: чем правее, тем больше тест знает о том, как именно код делает свою работу, - и тем чаще он ломается от рефакторинга, который ничего не сломал.
| Дублёр | Что делает | Что проверяет тест | Ломается при рефакторинге |
|---|---|---|---|
| dummy | Просто существует, чтобы скомпилировалось | ничего | нет |
| stub | Возвращает заготовленные значения | результат вызова | редко |
| spy | Стаб + журнал вызовов | результат + факт вызова (постфактум) | иногда |
| mock | Заранее объявленные ожидания, сам верифицирует | протокол взаимодействия | часто |
| fake | Упрощённая, но работающая реализация | состояние после операций | почти нет |
Стаб подставляет данные, мок проверяет взаимодействие. Стаб не может провалить тест: он просто отвечает, а решение о провале принимает ассерт в конце. Мок провалит тест сам, если метод вызвали не с теми аргументами, не столько раз или не в том порядке. В этом его и польза, и опасность. Отсюда практическое правило: стаб отвечает на вопрос «что вернулось», мок - на вопрос «был ли сделан вызов, которого снаружи не видно». Второй вопрос осмыслен ровно тогда, когда вызов и есть наблюдаемый результат: отправили письмо, списали деньги, опубликовали событие в Kafka, инвалидировали кеш.
gomock: go.uber.org/mock
Оригинальный github.com/golang/mock заархивирован в 2023 году; поддерживается теперь
форк go.uber.org/mock, туда и переехали генератор и рантайм. Поэтому
ответ «пользуюсь golang/mock» звучит как «я давно не смотрел на экосистему».
$ go install go.uber.org/mock/mockgen@latest
$ mockgen -source=internal/order/service.go -destination=internal/order/mocks/mock_deps.go -package=mocks
У генератора четыре режима, в ходу два. Source mode (-source) читает конкретный файл и делает
моки на все интерфейсы в нём - просто и предсказуемо. Package mode, в старых версиях
reflect mode (mockgen database/sql/driver Conn,Driver), грузит пакет и достаёт
интерфейс; он нужен для чужих пакетов. Раньше он делал это через рефлексию, в
go.uber.org/mock с v0.5.0 - разбором исходников. Третий, archive mode, появился
в v0.6.0: он берёт интерфейсы из собранного архива пакета и нужен в основном под Bazel. Четвёртый, gob mode (-model_gob, с v0.5.0), читает готовое
описание пакета из файла и нужен тем, кто загружает пакеты своими средствами.
Директиву обычно кладут прямо рядом с интерфейсом:
//go:generate mockgen -source=service.go -destination=mocks/mock_deps.go -package=mocks
func TestService_Pay_gomock(t *testing.T) {
ctrl := gomock.NewController(t) // ещё с golang/mock 1.5 Finish сам регистрируется в Cleanup
repo := mocks.NewMockRepo(ctrl)
pay := mocks.NewMockPayments(ctrl)
ctx := context.Background()
o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}
// Матчеры: gomock.Any() (любой), gomock.Eq(x) (ровно x), gomock.Cond(func) (предикат).
repo.EXPECT().
Get(gomock.Any(), 1).
Return(o, nil).
Times(1)
pay.EXPECT().
Charge(gomock.Any(), 7, int64(500)).
Return("tx-42", nil) // без Times(...) ждём ровно один вызов
repo.EXPECT().
UpdateStatus(gomock.Any(), 1, "paid").
Return(nil)
got, err := order.NewService(repo, pay).Pay(ctx, 1)
require.NoError(t, err)
require.Equal(t, "tx-42", got)
// Ожидания проверятся сами в t.Cleanup: любой невыполненный EXPECT
// валит тест с внятным сообщением «missing call».
}
| Конструкция | Смысл |
|---|---|
.Times(n), .MinTimes(n), .MaxTimes(n) | Сколько раз ждём вызов. По умолчанию - ровно один |
.AnyTimes() | Ноль и более. Для «шумных» зависимостей вроде логгера или метрик |
gomock.Any() | Любое значение аргумента. Стандартный приём для ctx |
gomock.Eq/Nil/Not/Len/InAnyOrder | Матчеры на аргумент |
gomock.Cond(func(x any) bool) | Произвольный предикат - когда важна часть структуры |
gomock.InOrder(a, b, c) | Строгий порядок вызовов |
b.After(a) | Частичный порядок: b только после a |
.Do(f) / .DoAndReturn(f) | Побочный эффект или динамический ответ в зависимости от аргументов |
ctrl.Finish() | Верификация. С Go 1.14+ и golang/mock 1.5 (в форке Uber - во всех версиях) вызывается автоматически через t.Cleanup |
- По умолчанию мок строгий. Незаявленный вызов сразу валит тест с
Unexpected call. Это фича: забыли ожидание - узнали сразу. Но логгер, метрики и трейсер приходится закрывать.AnyTimes(), иначе тест разваливается на каждой новой строке логирования. - Порядок по умолчанию не проверяется. Три
EXPECT()подряд задают множество, а не последовательность. Порядок надо просить явно (InOrder/After), и просить его стоит, только когда порядок входит в контракт (сначала списать, потом пометить оплаченным), а не «так получилось». Times(1)на «любой аргумент» почти ничего не проверяет.repo.EXPECT().UpdateStatus(gomock.Any(), gomock.Any(), gomock.Any())проходит и при статусе"paid", и при"cancelled".- Мок из побочной горутины.
ctrlпотокобезопасен, но провал ожидания вызываетt.Fatalf, а он из чужой горутины работает какGoexit. Асинхронные вызовы синхронизируй каналом внутри.Do(...). - Мок на структуру нельзя.
mockgenработает только по интерфейсам - ещё один аргумент за то, чтобы зависимости были интерфейсами у потребителя.
testify/mock
Альтернатива без кодогенерации: мок пишется руками поверх встраиваемой структуры
mock.Mock. Так гибче в мелочах, но больше ручной работы, и мок легко расходится
с интерфейсом: имена методов в ожиданиях - просто строки, и опечатку в них компилятор не поймает.
Сигнатуры самого мока сверит строчка var _ order.Repo = (*MockRepo)(nil): ошибка
всплывёт рядом с моком, а не там, где его передают в конструктор.
type MockRepo struct {
mock.Mock
}
var _ order.Repo = (*MockRepo)(nil) // страховка: расхождение с интерфейсом всплывёт прямо здесь
func (m *MockRepo) Get(ctx context.Context, id int) (order.Order, error) {
args := m.Called(ctx, id) // регистрируем вызов и достаём заготовленный ответ
return args.Get(0).(order.Order), args.Error(1)
}
func (m *MockRepo) UpdateStatus(ctx context.Context, id int, status string) error {
return m.Called(ctx, id, status).Error(0)
}
func TestService_Pay_testify(t *testing.T) {
repo := new(MockRepo)
pay := new(MockPayments)
o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}
repo.On("Get", mock.Anything, 1).Return(o, nil).Once()
pay.On("Charge", mock.Anything, 7, int64(500)).Return("tx-42", nil).Once()
repo.On("UpdateStatus", mock.Anything, 1, "paid").Return(nil).Once()
// Динамический ответ и произвольный матчер. Функцию из Return testify сам не вызывает:
// её распознаёт метод мока (так сделаны моки mockery), а ручной args.String(0) на ней упадёт.
// pay.On("Charge", mock.Anything, mock.MatchedBy(func(u int) bool { return u > 0 }), mock.Anything).
// Return(func(ctx context.Context, u int, a int64) string { return fmt.Sprintf("tx-%d", u) }, nil)
_, err := order.NewService(repo, pay).Pay(context.Background(), 1)
require.NoError(t, err)
repo.AssertExpectations(t) // без этой строки ожидания вообще не проверяются
pay.AssertExpectations(t)
repo.AssertNumberOfCalls(t, "UpdateStatus", 1)
repo.AssertNotCalled(t, "UpdateStatus", mock.Anything, 1, "cancelled")
}
testify по умолчанию не верифицирует ничего. Забыл AssertExpectations(t) - тест
зелёный, даже если ни один On(...) не сработал. gomock проверяет ожидания сам,
через t.Cleanup. Второе отличие: незаявленный вызов в testify роняет тест паникой
с текстом «mock: I don't know what to return», если на метод нет ни одного ожидания, или
«mock: Unexpected Method Call», если не совпали аргументы. Паника обрывает прогон всего пакета,
а не один тест, и в чужом коде её часто принимают за баг сервиса. И третье: .Once()/.Times(n) в testify
надо писать явно, без них ожидание отработает любое число раз.
Когда моки вредят и почему фейк на map часто лучше
Мок фиксирует протокол: кто кого позвал, с чем и сколько раз. Значит, тест начинает знать
внутреннее устройство метода. Допустим, ты переписал Pay, и вместо двух вызовов
Get + UpdateStatus теперь делается один
UpdateStatusIfNew. Поведение снаружи не изменилось ни на байт, а двадцать
тестов красные. Тест, который падает при рефакторинге без изменения поведения, ничего не ловит,
он просто берёт налог с каждой правки.
Мок: тест описывает внутренности
repo.EXPECT().Get(gomock.Any(), 1).
Return(o, nil).Times(1)
repo.EXPECT().UpdateStatus(gomock.Any(), 1, "paid").
Return(nil).Times(1)
_, err := svc.Pay(ctx, 1)
require.NoError(t, err)
// Проверили: "сервис сделал ровно эти два вызова" (порядок - только с InOrder или After).
// Не проверили: а заказ-то стал оплаченным?
// Поменяли реализацию на один запрос, и тест покраснел.
Фейк: тест описывает результат
repo := newFakeRepo(o) // map внутри
_, err := svc.Pay(ctx, 1)
require.NoError(t, err)
got, _ := repo.Get(ctx, 1)
require.Equal(t, "paid", got.Status)
// Проверили наблюдаемый эффект: заказ оплачен.
// Как именно сервис этого добился, уже его дело.
// Рефакторинг тест не ломает.
// Весь фейк-репозиторий: три десятка строк, пишется один раз на пакет.
type fakeRepo struct {
mu sync.Mutex
orders map[int]order.Order
failOn map[int]error // чтобы смоделировать сбой конкретной строки
}
func newFakeRepo(seed ...order.Order) *fakeRepo {
r := &fakeRepo{orders: map[int]order.Order{}, failOn: map[int]error{}}
for _, o := range seed {
r.orders[o.ID] = o
}
return r
}
func (r *fakeRepo) Get(_ context.Context, id int) (order.Order, error) {
r.mu.Lock(); defer r.mu.Unlock()
if err := r.failOn[id]; err != nil {
return order.Order{}, err
}
o, ok := r.orders[id]
if !ok {
return order.Order{}, order.ErrNotFound // ведёт себя как настоящий: тот же сентинель
}
return o, nil
}
func (r *fakeRepo) UpdateStatus(_ context.Context, id int, status string) error {
r.mu.Lock(); defer r.mu.Unlock()
o, ok := r.orders[id]
if !ok {
return order.ErrNotFound
}
o.Status = status
r.orders[id] = o
return nil
}
- Фейк бери для всего, у чего есть состояние: репозитории, кеши, хранилища файлов, очереди. Тест проверяет состояние после операции и переживает рефакторинг.
- Стаб подходит для чтения без состояния: «конфиг вернул вот это», «курс валюты равен 90», «фича-флаг включён». Ещё им удобно моделировать ошибки: таймаут, 503, битый JSON.
- Мок - только когда сам факт вызова и есть требование: отправили письмо, списали деньги, опубликовали событие в брокер, инвалидировали кеш, записали аудит. Такой вызов снаружи не наблюдается никак иначе, и проверять его законно.
- Пустой реализации хватит для логгеров, метрик и трейсеров: они не влияют на решения. Строго по Месарошу это уже стаб, а не dummy: его вызывают, просто он ничего не делает.
- Мок на собственный код внутри одного пакета почти всегда ошибка. Если тебе понадобился мок, чтобы протестировать соседнюю функцию, скорее всего, у них слишком тесная связь.
Фейки обычно упрекают в том, что они врут: реальный PostgreSQL вернёт unique violation, а твой
map просто перезапишет. Лечится это общим набором тестов на интерфейс: пишется функция
func testRepoContract(t *testing.T, newRepo func(t *testing.T) order.Repo), в неё
собраны все требования к репозиторию (не найден → ErrNotFound, повторная вставка →
ErrDuplicate, обновление несуществующего → ошибка). Дальше её запускают дважды:
на фейке в обычном прогоне и на настоящем PostgreSQL под тегом integration. Если
фейк соврал, интеграционный прогон это поймает. В стандартной библиотеке так проверяют
fs.FS через testing/fstest.TestFS.
httptest: обе стороны HTTP
Пакет net/http/httptest закрывает два разных сценария, и их регулярно путают.
ResponseRecorder нужен, когда тестируем свой хендлер: сети нет вообще, хендлер
вызывается как обычная функция. httptest.NewServer берут, когда тестируем свой клиент:
поднимается настоящий сервер на 127.0.0.1 со свободным портом.
Сторона сервера: ResponseRecorder
func TestPayHandler(t *testing.T) {
tests := []struct {
name string
body string
svcTxID string
svcErr error
wantCode int
wantBody string
}{
{name: "успех", body: `{"order_id":1}`, svcTxID: "tx-42",
wantCode: http.StatusOK, wantBody: `{"tx_id":"tx-42"}`},
{name: "битый json", body: `{`,
wantCode: http.StatusBadRequest},
{name: "нет заказа", body: `{"order_id":9}`, svcErr: order.ErrNotFound,
wantCode: http.StatusNotFound},
{name: "шлюз лёг", body: `{"order_id":1}`, svcErr: paygate.ErrUnavailable,
wantCode: http.StatusBadGateway},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := NewPayHandler(stubPayer{txID: tc.svcTxID, err: tc.svcErr})
req := httptest.NewRequest(http.MethodPost, "/orders/pay", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() // реализует http.ResponseWriter, копит ответ в памяти
h.ServeHTTP(rec, req) // прямой вызов: ни сокета, ни порта, ни таймаутов
res := rec.Result()
defer res.Body.Close()
require.Equal(t, tc.wantCode, res.StatusCode)
if tc.wantBody != "" {
body, _ := io.ReadAll(res.Body)
require.JSONEq(t, tc.wantBody, string(body))
}
})
}
}
httptest.NewRequestуже проставляетRemoteAddr,HostиRequestURI, как у запроса, пришедшего на сервер.http.NewRequestтак не делает: он собирает клиентский запрос, и на сервере тот может вести себя иначе.- Роутер тестируется вместе с хендлером, если проверяются path-параметры: с Go 1.22
http.ServeMuxумеет"POST /orders/{id}/pay"иr.PathValue("id"), и без прогона через мультиплексор значение будет пустым. rec.Result()возвращает полноценный*http.Response; поляrec.Codeиrec.Bodyтоже доступны напрямую. Телоrec.Result().Bodyлежит в памяти, утечки не будет, и закрывают его разве что по привычке: линтерbodycloseна рекордер не ругается с октября 2024 года (в golangci-lint - с 1.63).- Мидлварь тестируется тем же способом: оборачиваем заглушечный хендлер и смотрим, что мидлварь сделала с запросом и ответом.
Сторона клиента: httptest.NewServer
func TestPaygateClient_Charge(t *testing.T) {
var gotBody []byte
var gotAuth string
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
gotAuth = r.Header.Get("Authorization")
gotBody, _ = io.ReadAll(r.Body)
assert.Equal(t, "/v1/charges", r.URL.Path) // горутина сервера: только assert, не require
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"tx-42","status":"succeeded"}`))
}))
defer srv.Close() // Close ждёт завершения всех активных запросов
// Клиенту подсовываем базовый URL тестового сервера, ради этого он и параметризован.
c := paygate.New(srv.URL, "secret-token", srv.Client())
tx, err := c.Charge(context.Background(), 7, 500)
require.NoError(t, err)
require.Equal(t, "tx-42", tx)
require.Equal(t, "Bearer secret-token", gotAuth)
require.JSONEq(t, `{"user_id":7,"amount":500}`, string(gotBody))
require.EqualValues(t, 1, atomic.LoadInt32(&calls))
}
Хендлер выполняется в горутине сервера, а не в горутине теста. require.X там
вызывает t.FailNow() → runtime.Goexit(): горутина умрёт посреди обработки,
клиент получит оборванное соединение, и под настоящей причиной в логе окажется ещё и клиентское
«EOF», которое сбивает с толку. Безопаснее
складывать наблюдения в переменные и проверять их в теле теста после вызова (как выше)
либо использовать только assert/t.Errorf. Формально t.Errorf
из другой горутины тоже требует, чтобы тест ещё не завершился, поэтому все проверки лучше
делать после того, как клиент вернул управление.
Ещё три приёма, которые стоит назвать:
httptest.NewTLSServerподнимает сервер по HTTPS с самоподписанным сертификатом. Ходить в него надо клиентомsrv.Client(): у него уже подложен нужный корневой сертификат. Отключать проверку черезInsecureSkipVerifyв тестах не нужно.- Подмена транспорта выручает, если клиент не даёт передать базовый URL: можно подсунуть свой
http.RoundTripper. Это точка расширения ниже уровня URL, через неё удобно моделировать таймауты, обрывы и ретраи без реального сервера. - Моделирование сбоев тестовому серверу даётся легко: он отдаёт 500, 429 с
Retry-After, обрезанное тело, зависание дольше клиентского таймаута (time.Sleepв хендлере - редкое место, где sleep оправдан: ты моделируешь медленную сеть, а не ждёшь горутину).
// Подмена транспорта: клиент даже не подозревает, что сети нет.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestClient_RetriesOn503(t *testing.T) {
var n int
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
n++
if n < 3 {
return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader(""))}, nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"id":"tx-1"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})
c := paygate.New("https://api.example.com", "tok", &http.Client{Transport: rt})
tx, err := c.Charge(context.Background(), 7, 500)
require.NoError(t, err)
require.Equal(t, "tx-1", tx)
require.Equal(t, 3, n, "ожидали две неудачные попытки и одну успешную")
}
Пирамида тестирования и антипаттерн «рожок мороженого»
Модель Майка Кона: чем ниже уровень, тем тестов больше, тем они быстрее, дешевле и точнее локализуют поломку. Чем выше - тем меньше, медленнее и дороже, зато они проверяют то, чего не видит ни один юнит-тест: что все части собраны вместе и реально работают.
| Уровень | Что проверяет | Что подменяем | Скорость | Что там тестировать НЕ надо |
|---|---|---|---|---|
| Unit | Логика одной единицы: расчёты, валидация, ветвления, обработка ошибок | всё внешнее (фейки, стабы) | мкс–мс | SQL, сериализацию по сети, конфиг |
| Integration | Границы: SQL и маппинг, миграции, транзакции, HTTP-контракт, сериализация в брокер | только сторонние сервисы (или testcontainers на них) | сотни мс – секунды | перебор всех веток бизнес-логики |
| Contract | Что провайдер и потребитель понимают формат одинаково (Pact, схемы protobuf/Avro) | вторую сторону - записанным контрактом | секунды | бизнес-правила |
| E2E | Несколько сервисов вместе: критичные пользовательские сценарии, smoke после деплоя | ничего или только платные внешние API | минуты | всё, что можно проверить ниже |
Назвать критерий разделения, а не проценты. Мой рабочий критерий: юнит-тест не выходит за границы процесса - ни сети, ни диска, ни времени; тест, который выходит, уже интеграционный. Дальше добавить, что для сервиса с тонким доменом и толстым слоем работы с БД буквальная пирамида вредна: если 80 % ценности кода приходится на SQL и транзакции, то честные 40 % интеграционных тестов полезнее, чем 80 % юнит-тестов на маппинг структур. Эту форму называют «тестовым трофеем». И упомянуть цену флака: тест, который падает раз в двадцать прогонов, хуже отсутствующего, потому что учит команду перезапускать пайплайн не глядя.
Интеграционные тесты: testcontainers-go
Идея простая: раз тесту нужен настоящий PostgreSQL, пусть тест сам его и поднимет. Библиотека
github.com/testcontainers/testcontainers-go запускает контейнер через Docker API,
дожидается готовности и отдаёт хост со случайным свободным портом. Это лучше
«поднятой заранее базы на 5432»: тесты не дерутся за порт, прогон воспроизводится на ноутбуке и
в CI одинаково, а версия PostgreSQL зафиксирована тегом образа.
//go:build integration
package postgres_test
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"example.com/shop/internal/order"
"example.com/shop/internal/postgres" // пакет под тестом: имя совпадает с модулем, отсюда tcpostgres
)
var testDB *sql.DB
// TestMain: контейнер поднимается один раз на весь пакет.
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := tcpostgres.Run(ctx, "postgres:16-alpine",
tcpostgres.WithDatabase("app_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
// Ждём готовности базы, а не порта: entrypoint образа сначала поднимает
// PostgreSQL только на unix-сокете для инициализации, останавливает
// и запускает снова, отсюда Occurrence(2).
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
if err != nil {
fmt.Fprintf(os.Stderr, "поднять postgres: %v\n", err)
os.Exit(1)
}
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
fmt.Fprintf(os.Stderr, "dsn: %v\n", err)
os.Exit(1)
}
testDB, err = sql.Open("pgx", dsn)
if err != nil {
fmt.Fprintf(os.Stderr, "подключиться: %v\n", err)
os.Exit(1)
}
if err := applyMigrations(testDB); err != nil { // миграции один раз на контейнер
fmt.Fprintf(os.Stderr, "миграции: %v\n", err)
os.Exit(1)
}
code := m.Run()
// os.Exit не выполняет defer: убираем руками и до выхода.
_ = testDB.Close()
_ = testcontainers.TerminateContainer(pg)
os.Exit(code)
}
// Изоляция: каждый тест работает в своей транзакции, которая всегда откатывается.
func withTx(t *testing.T) *sql.Tx {
t.Helper()
tx, err := testDB.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
t.Cleanup(func() { _ = tx.Rollback() }) // именно Cleanup, а не defer в хелпере
return tx
}
func TestOrderRepo_GetUpdate(t *testing.T) {
ctx := context.Background()
t.Run("несуществующий заказ", func(t *testing.T) {
repo := postgres.NewOrderRepo(withTx(t)) // репозиторий умеет работать с любым Querier
_, err := repo.Get(ctx, 999999)
require.ErrorIs(t, err, order.ErrNotFound) // проверяем именно маппинг sql.ErrNoRows
})
t.Run("уникальный индекс", func(t *testing.T) {
// Транзакция своя на каждый подтест: после ошибки 23505 PostgreSQL
// отвергает в ней любой запрос (25P02) до самого отката.
repo := postgres.NewOrderRepo(withTx(t))
require.NoError(t, repo.Create(ctx, order.Order{ID: 1, UserID: 7, Amount: 500}))
err := repo.Create(ctx, order.Order{ID: 1, UserID: 7, Amount: 500})
require.ErrorIs(t, err, order.ErrDuplicate) // 23505 превратился в доменную ошибку
})
t.Run("маппинг NULL и timestamptz", func(t *testing.T) {
// То, чего фейк на map не проверит никогда:
// NULL в nullable-поле, часовой пояс, точность numeric, длина varchar.
})
}
Только то, чего принципиально не видно на фейке: правильность самого SQL и маппинга
(включая NULL, часовые пояса, numeric, массивы, jsonb),
превращение кодов ошибок PostgreSQL в доменные (23505 → ErrDuplicate,
23503 → ErrForeignKey), поведение уникальных индексов и каскадов,
транзакции и уровни изоляции, работу миграций «вверх и вниз», блокировки и
SELECT ... FOR UPDATE. Если перебирать в интеграционном тесте все ветки бизнес-логики,
пирамида как раз и превращается в рожок.
Билд-теги: разделение прогонов
Строка //go:build integration исключает файл из обычной сборки. Пустую строку после
неё и до package gofmt ставит сам, а обязательной она была только у старого
синтаксиса «// +build». Дальше:
$ go test ./... # быстрые: секунды, докер не нужен
$ go test -tags=integration ./... # медленные: нужен docker
$ go test -tags=integration -run TestOrderRepo -count=1 ./internal/postgres
# .github/workflows/ci.yml: два независимых job'а
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with: { go-version: '1.27' }
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
integration:
runs-on: ubuntu-latest # docker доступен из коробки, testcontainers сам всё поднимет
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with: { go-version: '1.27' }
- run: go test -tags=integration -race -timeout=15m ./...
Вместо билд-тегов можно поставить переключатель в рантайме. Он проще (не надо помнить про тег и про то, что IDE по умолчанию файл не подсвечивает), но файл всё равно компилируется:
func requireDocker(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("пропуск интеграционного теста: -short") // go test -short ./...
}
// Проверка по переменным вроде DOCKER_HOST врёт: Docker Desktop её не ставит.
testcontainers.SkipIfProviderIsNotHealthy(t) // пропуск, если docker недоступен
}
Третий вариант, docker-compose, выручает, когда сервисов много и они должны видеть друг друга
(база + Kafka + Redis + сам сервис). В GitHub Actions есть ещё services: - контейнеры
поднимает сам раннер, а тест получает готовый адрес через переменные окружения.
# docker-compose.test.yml: вариант для локального прогона и «тяжёлого» CI
services:
postgres:
image: postgres:16-alpine
environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
healthcheck: # без healthcheck тесты стартуют раньше базы
# -h localhost: через unix-сокет проверка пройдёт ещё на время инициализации
test: ["CMD-SHELL", "pg_isready -h localhost -U postgres -d app_test"]
interval: 2s
timeout: 3s
retries: 15
tests:
build: { context: ., dockerfile: Dockerfile.test }
depends_on:
postgres: { condition: service_healthy }
environment:
DATABASE_DSN: postgres://postgres:test@postgres:5432/app_test?sslmode=disable
command: ["go", "test", "-tags=integration", "-race", "./..."]
| testcontainers-go | docker-compose / services в CI | |
|---|---|---|
| Кто управляет жизненным циклом | сам тест, из кода | внешний оркестратор |
| Порты | случайные свободные, конфликтов нет | фиксированные, параллельные прогоны дерутся |
| Готовность | wait-стратегии в коде | healthcheck + depends_on |
| Локальный запуск | go test -tags=integration и всё | надо помнить про docker compose up |
| Уборка при падении | контейнер Ryuk убивает всё сам | down -v в always()-шаге |
| Много связанных сервисов | сложнее (сети, зависимости руками) | естественно |
| Цена | +1–3 с на старт пакета | старт вынесен из измерения |
- Ожидание порта вместо ожидания сервиса. Порт на хосте Docker открывает сразу, а
PostgreSQL сначала поднимается для инициализации только на unix-сокете и потом
перезапускается. Поэтому ждут
wait.ForLog(...).WithOccurrence(2)или, надёжнее,wait.ForSQLс реальнымSELECT 1. os.ExitвTestMainне исполняетdefer. Убирать за собой надо явно перед выходом, иначе контейнеры останутся висеть (спасает только Ryuk).- Тесты, зависящие от порядка. Первый тест вставил строку, второй на неё рассчитывает -
и при
-runодного теста всё красное. Каждый тест готовит свои данные сам. - Общая база на параллельные тесты. Либо транзакция на тест (и тогда параллелить можно,
если тесты не пишут одни и те же ключи: такие вставки ждут друг друга, а встречные ловят
deadlock), либо схема на тест (
CREATE SCHEMA test_xxx+search_path), либо прогон последовательный. - Забытый
-count=1. Go кеширует успешные результаты тестов; если тест зависит от внешнего состояния, второй прогон вернёт закешированное(cached)и ничего не выполнит. - Таймаут не по лимиту CI. У
go testсвой-timeout, по умолчанию 10 минут. Если лимит job'а меньше, зависший контейнер съест его целиком, и вместо дампа горутин ты увидишь «cancelled». Ставь-timeoutявно и меньше лимита job'а.
Шаблонная база: применить миграции один раз в базу app_template, а перед каждым
пакетом делать CREATE DATABASE app_test_x TEMPLATE app_template. PostgreSQL
копирует файлы за десятки миллисекунд вместо прогона всех миграций. Небезопасные настройки PostgreSQL
для тестов: fsync=off, synchronous_commit=off,
full_page_writes=off - данные не переживут падение, но тестам это и не нужно,
а ускорение бывает в разы. tmpfs для PGDATA держит базу целиком в памяти.
Переиспользование контейнера между прогонами (Reuse: true и имя контейнера
в запросе) - локально, в CI не нужно. И снапшоты: модуль postgres умеет
Snapshot()/Restore(), то есть откат к чистому состоянию без
TRUNCATE.
Тестирование кода с горутинами и каналами
Всё остальное здесь вытекает из одного правила: тест не имеет права зависеть от
скорости планировщика. Планировщик Go не даёт никаких гарантий на порядок и момент запуска
горутины; на ноутбуке с 10 ядрами и на CI-раннере с двумя, да ещё под -race
(замедление в 2–20 раз), тайминги отличаются на порядок. Значит, любая «пауза» в тесте рано
или поздно станет флаком.
Так нельзя
func TestWorker_Bad(t *testing.T) {
w := NewWorker()
go w.Run()
w.Submit(Job{ID: 1})
time.Sleep(100 * time.Millisecond) // «ну, должно хватить»
if got := w.Processed(); got != 1 {
t.Fatalf("got %d", got)
}
}
// Три беды сразу:
// 1) на нагруженном раннере 100 мс не хватит, и билд упадёт на ровном месте;
// 2) даже в зелёном прогоне каждый такой тест выбрасывает 100 мс в мусор;
// 3) горутина w.Run() живёт и после теста, это утечка.
Так надо
func TestWorker_Good(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
w := NewWorker()
done := make(chan struct{})
go func() { defer close(done); w.Run(ctx) }()
processed := make(chan int, 1)
w.OnDone = func(id int) { processed <- id } // сигнал вместо угадывания времени
w.Submit(Job{ID: 1})
select {
case id := <-processed:
require.Equal(t, 1, id)
case <-time.After(2 * time.Second):
t.Fatal("задача не обработана за 2 с") // таймаут здесь только страховка
}
cancel()
select {
case <-done: // дождались завершения горутины
case <-time.After(time.Second):
t.Fatal("Run не завершился по отмене контекста")
}
}
- Сигнал вместо сна. Канал,
sync.WaitGroup,errgroup.Wait(), колбэк - что угодно, что закрывается ровно тогда, когда работа сделана. Go 1.25 добавилwg.Go(func(){...}), который сам делаетAdd(1)/Done(). - Таймаут на каждое ожидание. Не для ожидания, а чтобы упавший тест сообщил
«таймаут в TestWorker», а не повесил весь бинарь до
-timeoutс дампом на 500 строк. - Отмена и завершение входят в проверку. Тест обязан убедиться, что по
ctx.Done()/закрытию канала горутина действительно вышла. Это тот самый баг, который в проде выглядит как медленно растущая память. - Никаких
t.Fatal/requireиз побочных горутин. Оба вызываютGoexit, а он завершает только эту горутину: тест помечен проваленным, но идёт дальше, и еслиDoneвызывается не через defer, повиснет наWaitGroup. Результаты передавай каналом, ассерты делай в горутине теста. -raceи-count.go test -race -count=10 ./...для конкурентных пакетов поймает гонку, которая проявляется в одном прогоне из пяти.-cpu=1,4прогоняет при разныхGOMAXPROCS.- Детерминизм через инъекцию. Если код внутри спит или ждёт тикер, подменяй часы (см. 5.1), а не подстраивай тест под реальное время.
assert.Eventually/require.EventuallyWithTлегально заменяют sleep там, где сигнала нет физически (внешняя система, файл на диске): опрашивают с интервалом и общим дедлайном вместо одной фиксированной паузы.
goleak: поиск утечек горутин
Горутина утекает, когда остаётся заблокированной навсегда: чтение из канала,
в который больше никто не пишет, отправка в канал без читателя, забытый ticker.Stop(),
worker без отмены. Рантайм её не соберёт: горутина служит корнем для GC, так что вместе с ней утекает
весь её стек и всё, на что она ссылается. В проде это выглядит как ползущий вверх график памяти и
runtime.NumGoroutine().
go.uber.org/goleak делает простую вещь: снимает дамп стеков через
runtime.Stack и сравнивает список горутин с ожидаемым, отфильтровывая системные
(GC, финализаторы, testing сам). Подключается одной строкой:
// Вариант 1: на весь пакет.
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
// Пропускаем «вечные» горутины сторонних библиотек, иначе будет ложное срабатывание.
goleak.IgnoreTopFunction("github.com/jackc/pgx/v5/pgxpool.(*Pool).backgroundHealthCheck"),
goleak.IgnoreAnyFunction("go.opencensus.io/stats/view.(*worker).start"),
)
}
// Вариант 2: точечно, на один тест. Так удобнее: видно, какой тест течёт.
func TestPipeline(t *testing.T) {
defer goleak.VerifyNone(t) // именно defer: проверка после завершения теста
...
}
$ go test ./pipeline
--- FAIL: TestPipeline (0.45s)
pipeline_test.go:12: found unexpected goroutines:
[Goroutine 20 in state chan receive, with app/pipeline.worker on top of the stack:
app/pipeline.worker(0x1ce287422230)
/app/pipeline/worker.go:7 +0x68
created by app/pipeline.Start in goroutine 19
/app/pipeline/pipeline.go:11 +0xcc
]
FAIL
FAIL app/pipeline 0.454s
FAIL
Проверка идёт в момент вызова, а горутина может как раз корректно
завершаться, и тест начинает мигать. Внутри goleak есть ретраи с растущими паузами
(в сумме около 0,4 с, поэтому упавший тест выше и шёл 0,45 с), но полностью проблему это не
снимает. Правильнее дождаться завершения явно (закрыть канал, отменить контекст, wg.Wait()), а goleak
держать как страховку, а не как способ синхронизации. Ещё ложные срабатывания дают
фоновые горутины библиотек: пулы соединений (database/sql держит
connectionOpener), HTTP-транспорт с keep-alive
(лечится t.Cleanup(http.DefaultTransport.(*http.Transport).CloseIdleConnections)),
клиенты метрик и трейсинга. Их закрывают явно или добавляют в Ignore*, но каждый
такой Ignore надо уметь объяснить, иначе он рано или поздно спрячет настоящую утечку.
-race работает как happens-before детектор (алгоритм на векторных часах, реализация
ThreadSanitizer): находит только те гонки, что реально произошли, зато почти без ложных
срабатываний. Встроенный детектор дедлоков рантайма ловит только полную остановку
(«all goroutines are asleep»); если жива хоть одна горутина или взведён хоть один таймер - а
в go test по умолчанию взведён таймер самого -timeout, - дедлок остальных
не заметят, и тест просто повиснет до -timeout. Настройки GODEBUG
(schedtrace, gctrace, asyncpreemptoff=1) иногда остаются единственным
способом увидеть, что происходит в пайплайне. testing/synctest
(экспериментальный в Go 1.24, стабилизирован в 1.25) выполняет тест в изолированном «пузыре» с
фейковым временем, где time.Sleep мгновенен, а synctest.Wait()
ждёт, пока все горутины пузыря заблокируются. В Go 1.27 к ним добавился
synctest.Sleep(d), то есть time.Sleep и Wait одним вызовом:
так тест гарантированно просыпается после кода, который проснулся от того же тика.
Пакет закрывает вопрос «как детерминированно
тестировать таймауты и ретраи».
Вопросы
6Шаг 1. Интерфейс объявляет потребитель
В Go интерфейсы реализуются неявно, поэтому интерфейс определяется там, где он нужен,
а не там, где лежит реализация. Пакет order объявляет Repo с двумя
методами и Payments с одним - ровно тем, чем пользуется. Пакет
postgres про order ничего не знает и просто возвращает структуру.
В итоге интерфейсы маленькие, стрелка зависимости смотрит внутрь домена, а моки тривиальны.
type Repo interface {
Get(ctx context.Context, id int) (Order, error)
UpdateStatus(ctx context.Context, id int, status string) error
}
type Payments interface {
Charge(ctx context.Context, userID int, amount int64) (string, error)
}
func NewService(r Repo, p Payments) *Service { return &Service{repo: r, pay: p} }
Шаг 2. В тесте - другие аргументы того же конструктора
func TestPay_gomock(t *testing.T) {
ctrl := gomock.NewController(t) // Finish регистрируется в t.Cleanup сам
repo := mocks.NewMockRepo(ctrl)
pay := mocks.NewMockPayments(ctrl)
o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}
repo.EXPECT().Get(gomock.Any(), 1).Return(o, nil)
pay.EXPECT().Charge(gomock.Any(), 7, int64(500)).Return("tx-42", nil)
repo.EXPECT().UpdateStatus(gomock.Any(), 1, "paid").Return(nil)
got, err := order.NewService(repo, pay).Pay(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, "tx-42", got)
}
Генерация: mockgen -source=service.go -destination=mocks/mock_deps.go -package=mocks,
библиотека go.uber.org/mock (оригинальный golang/mock
заархивирован в 2023-м). На testify то же делают рукописным моком поверх mock.Mock:
repo.On("Get", mock.Anything, 1).Return(o, nil).Once() и обязательный
repo.AssertExpectations(t) в конце. Без него лишний вызов и чужие аргументы ещё
уронят тест паникой, а вот ожидаемый вызов, который так и не случился, никто не заметит.
Шаг 3. Что именно проверять
Не «сервис позвал репозиторий», а решения, которые дорого воспроизвести на живой
инфраструктуре: платёж упал - статус не поменялся; списание прошло, а БД отвалилась, но
txID вернулся наружу вместе с ошибкой, иначе деньги потеряны; за уже
оплаченный заказ повторно не списывают; ошибка обёрнута через %w и ловится
errors.Is. Плюс сбои сети: таймаут, 503, битый JSON.
Сказать, что для БД я предпочитаю фейк-репозиторий на map, а мок оставляю
для внешнего API. У репозитория есть состояние, и тест «записали - прочитали» устойчив
к рефакторингу, а мок фиксирует протокол вызовов и рассыпается при любой перекладке кода.
И сразу добавить границу: настоящий SQL, маппинг NULL, уникальные индексы и
транзакции моками не проверяются в принципе, под них пишут отдельный интеграционный тест
с testcontainers под тегом integration.
Подвопрос: «а если сервис сам делает sql.Open в конструкторе и берёт
http.DefaultClient?» Правильный ответ: тогда точки подмены физически нет,
и любые трюки (подмена пакетной переменной, build-теги, monkey patching через
unsafe) лечат симптом. Чинить надо конструктор. Второй подвопрос:
«замокай *sql.DB». Но это структура, а не интерфейс, и mockgen её не
возьмёт; из рабочих решений ближе всего sqlmock, который матчит SQL-строки, но он
привязывает тест к тексту запроса и потому хуже интеграционного теста.
Пять терминов Месароша
- Dummy просто занимает место в сигнатуре и никогда не
вызывается: например,
nilна месте зависимости, до которой сценарий не доходит. Логгер с пустыми методами (type nopLogger struct{}) уже стаб: его вызывают, он просто ничего не делает. - Stub отдаёт заранее заданные ответы и так ставит систему в нужное состояние. Ничего
не проверяет:
func (s stubRepo) Get(...) (Order, error) { return s.order, s.err }. - Spy - стаб, который дополнительно записывает вызовы; проверяют их
потом, в теле теста:
require.Equal(t, []string{"paid"}, repo.updateCalls). - Mock получает ожидания заранее и сам падает, если его вызвали не с теми
аргументами, не столько раз или не в том порядке:
m.EXPECT().Charge(gomock.Any(), 7, int64(500)).Return("tx", nil).Times(1). - Fake уже не заглушка, а работающая упрощённая реализация с настоящим поведением
и состоянием: репозиторий на
map[int]Orderс мьютексом, in-memory очередь, локальная файловая «S3».
Ключевое различие - что именно верифицируется
Со стабом и фейком получается state-based testing: тест проверяет наблюдаемое состояние
после операции («заказ стал оплаченным»). С моком получается interaction-based testing:
тест проверяет протокол («был вызван Charge с суммой 500 ровно один раз»).
Отсюда практическое следствие, ради которого вопрос и задают: мок связывает тест
с внутренним устройством метода. Перепишешь два запроса в один - поведение снаружи то же,
а тесты красные.
| Дублёр | Может провалить тест сам? | Что проверяет тест | Ломается при рефакторинге |
|---|---|---|---|
| dummy | нет | ничего | нет |
| stub | нет | результат | редко |
| spy | нет (проверка в тесте) | результат + факт вызова | иногда |
| mock | да | протокол взаимодействия | часто |
| fake | нет | состояние | почти нет |
Когда что брать
Фейк бери для всего, где есть состояние (репозитории, кеши, хранилища). Стаб годится для чтения без состояния и для моделирования ошибок. Мок нужен, только когда сам факт вызова и есть требование: отправили письмо, списали деньги, опубликовали событие в Kafka, инвалидировали кеш, записали аудит. Такой эффект снаружи не наблюдается никак иначе, и проверять его законно. Логгерам, метрикам и трейсерам хватит пустой реализации.
На возражение «фейк врёт: настоящий PostgreSQL вернёт unique violation, а твой map перезапишет»
отвечают контрактными тестами. Пишется общий набор проверок на интерфейс
func testRepoContract(t *testing.T, newRepo func(*testing.T) order.Repo), и его
гоняют дважды: на фейке в быстром прогоне и на реальной БД под тегом
integration. Разъехались - узнали в тот же день. В стандартной библиотеке есть
тот же приём: testing/fstest.TestFS проверяет любую реализацию fs.FS.
httptest.NewRequest + httptest.NewRecorder, сети нет вообще, хендлер
вызывается функцией. Клиент - httptest.NewServer, поднимается настоящий
сервер на 127.0.0.1 со случайным свободным портом.Сторона сервера
h := NewPayHandler(stubPayer{txID: "tx-42"})
req := httptest.NewRequest(http.MethodPost, "/orders/pay", strings.NewReader(`{"order_id":1}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) // прямой вызов: ни сокета, ни таймаутов, ни портов
res := rec.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
body, _ := io.ReadAll(res.Body)
require.JSONEq(t, `{"tx_id":"tx-42"}`, string(body))
ResponseRecorder реализует http.ResponseWriter и копит
статус, заголовки и тело в памяти. Проверяют обычно табличным тестом: успех, битый JSON → 400,
не найдено → 404, ошибка зависимости → 502; отдельно смотрят заголовки, коды и формат ошибки.
Из тонкостей: httptest.NewRequest делает серверный запрос (проставлены
RemoteAddr, Host и RequestURI) в отличие от
http.NewRequest; если хендлер читает path-параметры, прогонять надо через роутер,
иначе с Go 1.22 r.PathValue("id") вернёт пустую строку. Мидлварь тестируют
так же, обернув заглушечный хендлер.
Сторона клиента
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"id":"tx-42"}`))
}))
defer srv.Close() // Close дожидается активных запросов
c := paygate.New(srv.URL, "secret", srv.Client())
tx, err := c.Charge(ctx, 7, 500)
require.NoError(t, err)
require.Equal(t, "tx-42", tx)
require.Equal(t, "Bearer secret", gotAuth) // проверяем в тесте, а не в хендлере
Ради этого клиент и параметризуют базовым URL и *http.Client. Что ещё стоит
назвать: httptest.NewTLSServer с ходом через srv.Client() (там уже
подложен нужный корневой сертификат - InsecureSkipVerify не нужен); подмена
http.RoundTripper, если клиент не даёт передать URL: это точка расширения ниже
уровня адреса, и через неё удобно проверять ретраи и обработку 503 вообще без сервера;
и моделирование сбоев - 500, 429 с Retry-After, обрезанное тело,
time.Sleep в хендлере для проверки клиентского таймаута.
require.Equal внутри хендлера тестового сервера. Хендлер работает в
горутине сервера, а require вызывает t.FailNow() →
runtime.Goexit(): горутина умрёт посреди обработки, клиент получит EOF, и под
настоящей причиной в логе окажется ещё и сбивающая с толку ошибка клиента. Складывай
наблюдения в переменные и проверяй их после того, как клиент вернул управление. Вторая ошибка - забыть srv.Close(): сервер и его
горутины останутся жить, а goleak потом покажет утечку. А вот забытый defer
res.Body.Close() у rec.Result() ничего не сломает: тело лежит в памяти, и
линтер bodyclose на рекордер не ругается с октября 2024 года (в golangci-lint - с 1.63).
TRUNCATE и убирает за собой в t.Cleanup.
Файлы помечаются //go:build integration, чтобы обычный go test ./...
оставался быстрым.Зачем вообще, если есть моки
Потому что мок не проверяет ровно то, ради чего пишется слой БД: корректность самого SQL,
маппинг NULL, часовых поясов, numeric, массивов и jsonb,
превращение кодов ошибок PostgreSQL в доменные (23505 → ErrDuplicate),
работу уникальных индексов и каскадов, транзакции и уровни изоляции, миграции «вверх и вниз»,
SELECT ... FOR UPDATE. Всё это либо работает с настоящей базой, либо не проверено.
testcontainers-go
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app_test"),
postgres.WithUsername("test"), postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
if err != nil { os.Exit(1) }
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
testDB, _ = sql.Open("pgx", dsn)
_ = applyMigrations(testDB) // один раз на контейнер
code := m.Run()
_ = testDB.Close() // os.Exit не выполняет defer, убираем руками
_ = testcontainers.TerminateContainer(pg)
os.Exit(code)
}
Почему это лучше «базы, поднятой заранее на 5432»: случайный свободный порт (параллельные
прогоны не дерутся), одинаковое поведение на ноутбуке и в CI, версия зафиксирована тегом
образа, и вспомогательный контейнер Ryuk убьёт всё поднятое, даже если тест упал паникой
или его прервали по Ctrl+C. Старт стоит 1–3 секунды, поэтому контейнер поднимается
один раз на пакет (TestMain или sync.Once), а не на тест.
Изоляция между кейсами
Транзакция с откатом
tx, _ := db.BeginTx(ctx, nil)
t.Cleanup(func(){ _ = tx.Rollback() })
repo := postgres.NewOrderRepo(tx)
Быстро (миллисекунды), чужих незакоммиченных строк тест не видит, параллелить можно, если тесты не пишут одни и те же ключи: иначе они ждут друг друга. Не подходит, если сам тестируемый код открывает транзакции или проверяется реальный коммит.
Очистка таблиц
TRUNCATE orders, payments
RESTART IDENTITY CASCADE;
Работает с любым кодом и видит настоящие коммиты, но медленнее и требует
последовательного прогона (или отдельной схемы/базы на тест). Последовательного и между
пакетами: go test ./... по умолчанию гоняет пакеты параллельно, и при общей
базе очистка одного пакета стирает данные другого, так что нужен -p 1.
Билд-теги и CI
//go:build integration
// пустая строка перед package нужна старому // +build
package postgres_test
$ go test ./... # быстрый прогон, докер не нужен
$ go test -tags=integration -race -timeout=15m ./...
В CI это два независимых job'а: unit гоняется на каждый push и занимает секунды, integration -
отдельно и с большим таймаутом. Теги можно заменить рантайм-скипом
(if testing.Short() { t.Skip(...) } и go test -short ./...): так проще,
но файл всё равно компилируется. Когда сервисов много и им надо видеть друг друга (база + Kafka
+ Redis), берут docker-compose с обязательными healthcheck и
depends_on: {condition: service_healthy} либо блок services:
в GitHub Actions.
- Ожидание порта вместо ожидания сервиса: порт на хосте открыт сразу, а PostgreSQL
сначала поднимается для инициализации только на unix-сокете и потом перезапускается.
Отсюда
WithOccurrence(2)илиwait.ForSQLс реальнымSELECT 1. - Забытый
-count=1: Go кеширует успешные тесты, и второй прогон вернёт(cached), ничего не выполнив. - Тесты, зависящие от порядка: каждый готовит свои данные сам, иначе
-runодного теста краснеет. deferвTestMainпослеos.Exitне выполняется никогда.
CREATE DATABASE x TEMPLATE app_template вместо прогона миграций на каждый пакет;
fsync=off, synchronous_commit=off, PGDATA на tmpfs - для тестов
надёжность не нужна, а ускорение кратное; Reuse: true для контейнера локально;
Snapshot()/Restore() модуля postgres для отката
к чистому состоянию без TRUNCATE.
Что где тестировать
- Unit (~70–80 %) проверяет логику одной единицы: расчёты, валидацию, ветвления, обработку ошибок, граничные значения, идемпотентность. Все зависимости заменены фейками и стабами. Миллисекунды, падение сразу указывает на функцию.
- Integration (~15–25 %) отвечает за границы: SQL и маппинг типов, миграции, транзакции, коды ошибок БД, HTTP-контракт хендлера, сериализация в брокер, чтение конфига. Реальная зависимость поднимается в контейнере, чужие сервисы - заглушками.
- Contract следит, чтобы провайдер и потребитель одинаково понимали формат (Pact, схемы protobuf/Avro). Отдельный дешёвый слой, который часто забывают назвать.
- E2E (~1–5 %) - несколько сервисов вместе, только критичные пользовательские сценарии и smoke после деплоя. Минуты, самые хрупкие.
Антипаттерн «рожок мороженого»
Перевёрнутая пирамида: гора ручного тестирования сверху, много e2e через UI, немного интеграционных и почти нет юнитов. Симптомы узнаваемые: прогон CI занимает часы; красный билд не говорит, что сломалось, а сообщает только «сценарий оформления заказа упал»; флаки лечат перезапуском пайплайна, и через полгода команда перестаёт читать красные билды вообще. Причина обычно не в лени, а в архитектуре: код с зависимостями внутри, без точек подмены, просто невозможно покрыть юнит-тестами, и остаётся тестировать снаружи.
Назвать не проценты, а критерий, и сразу оговорить исключение: для сервиса с тонким доменом и толстым слоем БД буквальная пирамида вредна. Если 80 % ценности кода сидит в SQL, транзакциях и внешних вызовах, честные 40 % интеграционных тестов полезнее, чем 80 % юнит-тестов на маппинг структур. Такую форму называют «тестовым трофеем». И добавить про флаки: тест, падающий раз в двадцать прогонов, хуже отсутствующего. Он приучает команду перезапускать CI не глядя, и однажды так же не глядя проигнорируют настоящую поломку.
WaitGroup или errgroup, а не time.Sleep; на каждое
ожидание - таймаут как страховка; ассерты только из горутины теста; в CI обязательны
-race и goleak.Почему не time.Sleep
Планировщик Go не даёт гарантий на момент запуска горутины. На ноутбуке с 10 ядрами и на
CI-раннере с двумя, да ещё под -race (замедление в 2–20 раз), тайминги отличаются
на порядок. Итог: либо тест иногда падает на ровном месте, либо (в зелёном случае) ты
выбрасываешь по 100 мс на каждый такой тест; на пакете из ста тестов набегает десять секунд
впустую на каждом прогоне.
// Правильный каркас: сигнал + таймаут + проверка завершения
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
done := make(chan struct{})
go func() { defer close(done); w.Run(ctx) }()
processed := make(chan int, 1)
w.OnDone = func(id int) { processed <- id }
w.Submit(Job{ID: 1})
select {
case id := <-processed:
require.Equal(t, 1, id)
case <-time.After(2 * time.Second):
t.Fatal("задача не обработана за 2 с") // таймаут не для ожидания, а для внятного сообщения
}
cancel()
select {
case <-done: // проверяем, что горутина вышла по отмене
case <-time.After(time.Second):
t.Fatal("Run не завершился по отмене контекста")
}
Правила
- Никаких
t.Fatalиrequireиз побочных горутин. Внутри у нихruntime.Goexit(): умрёт только эта горутина, тест пойдёт дальше, а еслиDone()вызывается не через defer, повиснет. Результаты передавай каналом. - Таймаут на каждое ожидание. Без него повисший тест держит весь бинарь до
-timeoutи печатает дамп всех горутин вместо внятной причины. - Отмена входит в проверку, а не в уборку. Тест обязан убедиться, что горутина вышла: именно этот баг в проде выглядит как медленно растущая память.
go test -race -count=10 -cpu=1,4 ./...для конкурентных пакетов: так ловится гонка, которая проявляется в одном прогоне из пяти.assert.Eventually/require.EventuallyWithTлегально заменяют sleep там, где сигнала физически нет (внешняя система, файл на диске): опрос с интервалом и общим дедлайном.- Тикеры и таймауты внутри кода заводи через инжектированные часы (см. 5.1), иначе тест на «ретрай через 30 секунд» будет идти 30 секунд.
goleak
Утекает горутина, которая навсегда заблокировалась на чтении из канала, куда больше никто
не пишет, на отправке без читателя, на забытом тикере. GC её не соберёт: горутина сама считается
корнем, так что утекает весь её стек и всё, на что она ссылается.
go.uber.org/goleak снимает дамп через runtime.Stack, отфильтровывает
системные горутины и сравнивает список с ожидаемым.
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } // на весь пакет
func TestPipeline(t *testing.T) { defer goleak.VerifyNone(t); ... } // точечно
Ложные срабатывания бывают двух видов: горутина в процессе корректного завершения (внутри есть
ретраи с паузами, в сумме около 0,4 с, но правильнее дождаться завершения явно) и фоновые горутины
библиотек - пул database/sql, keep-alive HTTP-транспорта, клиенты метрик.
Их закрывают явно (db.Close(), CloseIdleConnections()) или добавляют
в goleak.IgnoreTopFunction(...), но каждый такой Ignore надо уметь
объяснить, иначе он однажды спрячет настоящую утечку.
Упомянуть testing/synctest: экспериментальный в Go 1.24, стабилизирован
в 1.25. Он выполняет тест в изолированном «пузыре» с фейковым временем, где
time.Sleep мгновенен, а synctest.Wait() ждёт, пока все горутины
пузыря заблокируются. С Go 1.27 есть ещё synctest.Sleep(d), который делает
time.Sleep(d) и Wait() одним вызовом: без него тест и код,
проснувшиеся от одного и того же тика фейковых часов, бегут в неопределённом порядке,
и это тот же флак, только внутри пузыря. Это прямой ответ на «как детерминированно
тестировать таймауты, ретраи и тикеры». И назвать границу встроенного детектора дедлоков: рантайм ловит только
полную остановку («all goroutines are asleep»); если жива хоть одна горутина или взведён
таймер - а в go test взведён таймер самого -timeout, - дедлок
остальных не заметят, и тест просто повиснет до -timeout.
Что стоит запомнить
- Stub задаёт ответы, spy записывает обращения, fake реализует упрощённое поведение, mock проверяет ожидания.
- Consumer-side interface выражает только нужное поведение.
- Fake подходит, если тесту нужны данные и семантика, а не точные вызовы.
Частые ошибки
- Mock не проверяет SQL/транзакцию.
- Не делай интерфейс копией всей реализации.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери проверку
Что fake возвращает для неизвестного id и какой context функция Load передаёт Store?
type fakeStore struct { items map[string]Item }
func (f fakeStore) Get(_ context.Context, id string) (Item, error) { return f.items[id], nil }
func Load(s Store, id string) (Item, error) { return s.Get(context.Background(), id) }
Ответ и разбор
Fake возвращает zero value с nil error для отсутствующего id, что скрывает отсутствие записи. Также Load теряет context caller. Исправь оба дефекта: передай ctx, а fake верни ErrNotFound при отсутствии ключа.
Код-ревью
2. Найди дефект и исправь
Тест проверяет результат Load, но привязывает его к числу внутренних обращений к Store. Покажи дефект ожидания и исправь проверку.
func TestLoad(t *testing.T) {
mockStore.EXPECT().Get(gomock.Any(), id).Return(item, nil).Times(2)
got, err := service.Load(ctx, id)
if err != nil { t.Fatal(err) }
if got != item { t.Fatalf("got %#v want %#v", got, item) }
}
Ответ и разбор
.Times(2) закрепляет внутреннее число обращений, хотя условие говорит проверять публичный результат. Предпочти fake Store с нужным ответом и оставь проверку результата got. Если оставляешь mock и ноль вызовов допустимы по контракту, .AnyTimes() снимает минимум вызовов, но результат всё равно нужно assert-ить; этот matcher допускает и ноль вызовов. Если хотя бы один вызов обязателен, оставь default gomock expectation (ровно один) или укажи .Times(1).
Применение
3. Реши сценарий
Load возвращает ErrNotFound для неизвестного id и конкретный Item для известного. Какой double здесь проще и почему?
Ответ и разбор
Stub/fake map реализация Store проще, потому что тесту нужны заданные данные и проверка результата, а не проверка порядка вызовов. Fake должен точно повторять существенную семантику Store, включая ErrNotFound, и передавать context. Mock нужен, если контракт требует проверить конкретное взаимодействие, например отправку команды один раз.
Применение
4. Проверь альтернативу
Сервис выполняет SQL запрос с JOIN и транзакцией. Какую часть можно проверить unit test с fake, а что оставить интеграционному тесту?
Ответ и разбор
Unit test с fake проверяет доменную реакцию сервиса на данные и ошибки Store. Интеграционный тест с реальной тестовой PostgreSQL должен проверить SQL, schema, constraints и поведение транзакции. Не пытайся доказать корректность запроса ожиданием вызова mock-метода.