open atlas
↑ К треку
Go с нуля до senior GO · 04 · 03

Моки, фейки и интерфейсы: тест-дублёры без тестирования дублёра

Тест-дублёры по-гошному: потребитель объявляет маленькие интерфейсы, рукописные фейки бьют моки с проверкой вызовов, httptest закрывает HTTP-границу in-process или через настоящий листенер, testcontainers честен про свои 1–3 с там, где логика — SQL. Пере-мокинг тестирует мок.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У сервиса заказов было 96% покрытия — и он всё равно дважды списал деньги у сорока одного клиента. Каждая зависимость была сгенерированным моком с цепочками EXPECT: тест фиксировал, что ChargeCard будет вызван один раз с суммой 4999, мок послушно возвращал успех, сьют был зелёным. Баг жил в обёртке ретраев — по таймауту она повторяла запрос без idempotency-ключа, и настоящий платёжный шлюз, в отличие от мока, списывал дважды. Ни один тест не мог это поймать, потому что ни один тест не разговаривал с чем-либо, ведущим себя как шлюз: моки возвращали то, что предположил автор теста. Через три месяца безобидный рефакторинг переставил местами два независимых чтения — и 214 тестов покраснели: поведение идентично, порядок вызовов другой, каждая цепочка EXPECT теперь неверна. Команда потратила спринт на правку моков под новую реализацию. Это двойной провал пере-мокинга: он пропускает настоящие баги, потому что дублёр слишком послушен, и поднимает ложные тревоги, потому что ассерты кодируют реализацию.

Принимайте интерфейсы там, где их потребляете

Сколько методов должен реализовать ваш тест-дублёр? Скорее всего, намного меньше, чем у заменяемого типа — и именно с этого вопроса начинается философия интерфейсов в Go.

Интерфейсы Go удовлетворяются неявно, и это переворачивает привычку к зависимостям из других языков: потребитель объявляет те немногие методы, которые ему нужны, в своём собственном пакете, и любой конкретный тип с этими методами просто подходит — без implements, без общего пакета интерфейсов:

// пакет billing — объявляет, что нужно billing, и ничего больше
type OrderStore interface {
	Get(ctx context.Context, id string) (Order, error)
	Save(ctx context.Context, o Order) error
}

type Charger interface {
	Charge(ctx context.Context, key string, cents int64) error
}

func New(store OrderStore, ch Charger) *Billing { /* ... */ }

Продакшен-обвязка передаёт настоящее хранилище на Postgres; тест передаёт фейк. Держите интерфейсы маленькими — один-три метода. 14-методный интерфейс Repository заставляет каждого дублёра реализовать 14 методов и ничего не говорит читателю о том, чего касается этот код; OrderStore с двумя методами документирует точный контракт. Гошная пословица сжимает это до одной строки: чем больше интерфейс, тем слабее абстракция. Следствие: возвращайте конкретные типы, принимайте интерфейсы — экспорт интерфейса «для мокабельности» со стороны производителя воссоздаёт ту самую джавовую схему, от которой Go спроектирован уходить, и замораживает API под нужды первого потребителя.

Рукописные фейки вместо мок-фреймворков

Мок (gomock, mockery) записывает вызовы и проверяет взаимодействия: этот метод, эти аргументы, этот порядок, столько-то раз. Фейк — маленькая рабочая реализация (map за мьютексом), а тест проверяет состояние:

type fakeStore struct {
	mu     sync.Mutex
	orders map[string]Order
}

func (f *fakeStore) Get(ctx context.Context, id string) (Order, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	o, ok := f.orders[id]
	if !ok {
		return Order{}, ErrNotFound // соблюдает настоящий контракт
	}
	return o, nil
}

func (f *fakeStore) Save(ctx context.Context, o Order) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.orders[o.ID] = o
	return nil
}

Когда вы напишете фейк однажды и будете переиспользовать его в двадцати тестах, заметите кое-что: рефакторинг кода сервиса фейка не ломает. Размен — в долговечности. Фейк кодирует контракт зависимости один раз — not-found возвращает ErrNotFound, Save затем Get делает round-trip — и каждый тест на нём наследует эту реалистичность; рефакторьте код под тестом свободно, фейк ничего не заметит, потому что отвечает честно вне зависимости от того, кто, что и в каком порядке вызывает. Мок кодирует текущую последовательность вызовов тестируемого кода — потому рефакторинги и валят мок-сьюты оптом. У моков остаётся одна легитимная ниша: проверить, что побочный эффект вообще случился («audit-лог записан ровно один раз при сбое»), где взаимодействие и есть поведение. Для всего остального 20-строчный фейк, написанный однажды, бьёт сгенерированный мок, конфигурируемый в каждом тесте. Инъекция сбоев остаётся простой — добавьте поле failNextSave error и выставляйте его в тесте.

Викторина

Рефакторинг меняет местами два независимых чтения в сервисе. 214 тестов на моках падают; поведение доказуемо не изменилось. Какое свойство моков это вызвало?

httptest: HTTP-граница без сети — или с ней

Для HTTP редко есть причина выдумывать дублёров: net/http/httptest поставляет два инструмента под два разных предмета тестирования. Тестируем хендлер: httptest.NewRecorder() плюс httptest.NewRequest() вызывают его как обычную функцию — без порта, без горутины, микросекунды на тест, — а ResponseRecorder захватывает статус, заголовки и тело для got/want-проверок. Тестируем клиента (логика ретраев, обработка таймаутов, auth-заголовки): httptest.NewServer(handler) поднимает настоящий листенер на 127.0.0.1, и продакшен-код с http.Client работает без изменений против URL, которым управляете вы; тестовый хендлер может вернуть дважды 503, затем 200, доказывая политику ретраев, или заснуть в time.Sleep дольше клиентского таймаута, доказывая срабатывание дедлайна. Правило выводится из предмета теста: рекордер — когда ваш код и есть сервер, настоящий сервер — когда ваш код разговаривает с сервером; рекордер не способен прогнать переиспользование соединений, таймауты или TLS (httptest.NewTLSServer — способен).

testcontainers честно — и провал пере-мокинга

Слою репозитория, чья логика и есть SQL, фейк не даёт ничего: фейкая хранилище, вы тестируете свою map, а не свой запрос. testcontainers-go поднимает настоящий Postgres в Docker — честно: это 1–3 с старта контейнера плюс pull образа на холодном CI, поэтому амортизируйте: один контейнер на пакет через TestMain, изоляция между тестами — транзакциями с откатом или схемой-на-тест, и держите сьют за testing.Short(), чтобы go test -short оставался субсекундным для внутреннего цикла. Выигрыш — что ON CONFLICT, нарушения ограничений и причуды изоляции тестируются против того движка, который их и исполнит; sqlmock-подобные драйверы, ассертящие SQL-строки, — худший вид моков: ломаются на пробелах, не валидируя ничего о семантике. Общий диагностический вопрос пере-мокинга один: если реализация изменилась, а поведение осталось тем же, пройдёт ли этот тест? Если каждый рефакторинг означает правку цепочек EXPECT — сьют ассертит, как код работает: он тестирует мок. Фейки, рекордеры и настоящие движки отвечают на вопрос «да».

Викторина

Нужно протестировать, что ваш HTTP-клиент ретраит на 503 с бэкоффом и сдаётся после общего таймаута в 2 с. Какой инструмент httptest и почему?

Вспомните перед уходом
  1. 01
    Сопоставь моки и фейки: что каждый проверяет, как каждый реагирует на рефакторинг с сохранением поведения и единственная ниша, где мок — правильный инструмент.
  2. 02
    Разложи стратегию дублёров для сервиса с HTTP-клиентом к платёжному шлюзу и репозиторием на Postgres: какой инструмент что тестирует и что ты отказываешься мокать.
Итог

Неявное удовлетворение интерфейсов в Go задаёт стратегию: потребитель объявляет интерфейс в один-три метода в своём пакете, продакшен подключает конкретное хранилище, тест — дублёра; без implements, без пакета интерфейсов, и пословица держится: чем больше интерфейс, тем слабее абстракция. Дублёр по умолчанию — рукописный фейк: map за мьютексом, соблюдающая настоящий контракт, проверяющая состояние вместо взаимодействий и переживающая любой рефакторинг с сохранением поведения; инъекция сбоев — в одном поле. Моки с проверкой взаимодействий заслуживают место только там, где сам вызов и есть поведение — «audit-лог записан один раз», — потому что во всех остальных местах они кодируют реализацию: 214 красных тестов из Hook на безобидной перестановке и двойное списание, прошедшее потому, что каждый мок возвращал предположения автора вместо поведения шлюза. На HTTP-границе httptest делится по предмету: NewRecorder вызывает хендлеры как функции за микросекунды; NewServer даёт клиентскому коду настоящий листенер, чтобы транспорт, ретраи и дедлайны работали без изменений. Там, где логика — SQL, не фейкайте ничего: testcontainers гоняет настоящий движок за 1–3 с, амортизированные на пакет, с эвакуацией через -short для внутреннего цикла. Аудиторский вопрос к любому сьюту: если реализация изменилась, а поведение нет — останутся ли тесты зелёными? Если нет, они тестируют дублёра. Теперь, когда вы встретите сьют, где каждый рефакторинг красит сотни тестов, вы будете точно знать, что пошло не так — и что починит двухметодный интерфейс с двадцатистрочным фейком.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.