Юнит-тест на цикл воркера из седьмого урока в лоб не пишется: чтобы проверить одну ветку обработки, нужны живой брокер, подписка и сеть. Так тесты становятся медленными и дрожащими: контейнер не поднялся, порт занят, соседний прогон оставил сообщения в общей очереди. Выход — разрезать цикл на слои: решение о доставке принимает чистая функция, транспорт со своими Ack и Nack — тонкая обёртка, а брокер нужен только самым внешним тестам. Ключ к разделению — тип amqp.Delivery: это обычная структура с экспортными полями, её можно собрать в тесте руками и скормить обработчику, как любой другой аргумент. Никакой магии в доставке нет: Body, DeliveryTag, Headers, Redelivered — поля, которые в бою заполняет библиотека, а в тесте — вы. Ниже — как вытащить логику из цикла, подменить канал фейком и что при этом честно остаётся проверять на живом брокере.
Тестирование rabbitmq: обработчик как функция#
Начинается тестирование rabbitmq с одной перестановки: обработчик не зовёт методы канала, а возвращает решение. Причина честная: d.Ack и d.Nack требуют живого канала — в юнит-тесте его нет, а мокать целый канал ради одного кейса жалко. Три решения покрывают цикл воркера: Ack — успех, Requeue — временная неудача из восьмого урока, Park — яд в DLQ из шестнадцатого. Функция принимает Delivery и зависимости через интерфейсы: Sender для отправки письма, словарь для дедупликации дублей после requeue. Зависимости через интерфейсы — старая история из курса по Go: тест подставляет фейк, продакшен — реальный клиент SMTP, и никто из них не знает о брокере. Нюанс про JSON: поля структуры Event совпадают с ключами без тегов, Unmarshal сопоставляет имена без учёта регистра; в своём проекте вы, скорее всего, поставите теги — механика тестирования от этого не меняется. Главное приобретение: каждая ветка решения — теперь вызов функции с заранее собранными аргументами, а не сценарий с сетью.
Обработчик возвращает решение
GOtype Sender interface {
Send(channel, orderID string) error
}
type Decision int
const (
Ack Decision = iota
Requeue
Park
)
func handle(d amqp.Delivery, s Sender, seen map[string]bool) Decision {
var ev Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
return Park // яд: повторение не лечит сломанный формат
}
if seen[ev.OrderID] {
return Ack // дубль после requeue: работа уже сделана
}
if err := s.Send(ev.Channel, ev.OrderID); err != nil {
return Requeue // временная неудача: повтор из урока 16
}
return Ack
}Функция чистая ровно настолько, насколько чисты зависимости: Sender подменяется, seen передаётся снаружи, Delivery собирается в тесте. Битый JSON — Park, дубль — Ack без второй отправки, ошибка Sender — Requeue: три ветки из боевого цикла, каждая проверяется вызовом без сети. Транспорт отдельно решает, как исполнить решение: это следующий блок.
Мок amqp go: фейк на своём интерфейсе#
Запрос мок amqp go в поисковике упирается в стену: amqp.Channel — конкретная структура без экспортированного интерфейса, подменить её целиком нельзя, и это осознанный дизайн библиотеки. Правильный ход — не мокать чужой тип, а определить свой узкий интерфейс из тех методов, что реально использует транспорт: Ack и Nack с точными сигнатурами amqp091-go. Конкретный amqp.Channel реализует такой интерфейс автоматически — код транспорта не меняется, а тест получает фейк со счётчиками. Правило экономности: интерфейс растёт вместе с кодом, который его использует; интерфейс на весь канал с двумя десятками методов возвращает боль мокания, от которой уходили. Такой фейк проверяет главное: что решение обработчика превратилось в правильный вызов транспорта — ack по нужному тегу, nack с верным requeue. Логику брокера фейк не проверяет: для неё есть интеграционный слой в конце урока, и путать эти уровни так же вредно, как мокать всё подряд.
Фейки, транспорт и тест решений
GOtype Completer interface {
Ack(tag uint64, multiple bool) error
Nack(tag uint64, multiple, requeue bool) error
}
type fakeSender struct{ err error }
func (f *fakeSender) Send(channel, orderID string) error { return f.err }
type fakeCompleter struct {
acks, nacks int
requeue bool
}
func (f *fakeCompleter) Ack(tag uint64, multiple bool) error {
f.acks++
return nil
}
func (f *fakeCompleter) Nack(tag uint64, multiple, requeue bool) error {
f.nacks++
f.requeue = requeue
return nil
}
func run(d amqp.Delivery, c Completer, s Sender, seen map[string]bool) error {
switch handle(d, s, seen) {
case Ack:
return c.Ack(d.DeliveryTag, false)
case Requeue:
return c.Nack(d.DeliveryTag, false, true)
case Park:
return c.Nack(d.DeliveryTag, false, false) // в DLQ
}
return nil
}
func TestHandle(t *testing.T) {
ok := amqp.Delivery{
Body: []byte("{"orderid":"42","channel":"email"}"),
DeliveryTag: 1,
}
bad := amqp.Delivery{Body: []byte("битый формат"), DeliveryTag: 2}
s := &fakeSender{err: errors.New("smtp лежит")}
if got := handle(bad, s, map[string]bool{}); got != Park {
t.Errorf("яд: хотели Park, получили %v", got)
}
if got := handle(ok, s, map[string]bool{}); got != Requeue {
t.Errorf("smtp лежит: хотели Requeue, получили %v", got)
}
}Completer повторяет два метода *amqp.Channel — реальный канал реализует интерфейс сам, без обёрток. Фейки — структуры со счётчиками: тест утверждает не то, что было вызвано «что-то», а что вызван правильный метод с правильными флагами. Сигнатуры совпадают с библиотекой до буквы, поэтому при переходе на боевой канал транспорт не замечает подмены.
Что проверяем и где#
| Слой | Что проверяет | Инструменты |
|---|---|---|
| решения | ветки handle: яд, дубль, ошибка, успех | собранный Delivery, фейковый Sender |
| транспорт | решение превратилось в правильный вызов | fakeCompleter со счётчиками |
| дожим | воркер доедает поток и выходит | chan amqp.Delivery с close |
| интеграция | DLX, TTL, подтверждения, обрывы | живой брокер, уникальные имена |
| гонки | горутины не воюют за состояние | go test -race |
Интеграционные тесты против одного брокера умеют саботировать друг друга: общее имя очереди у двух тестов означает чужие сообщения в подписке — утверждения срабатывают через раз, и билд краснеет без всякой вины кода. Классическая картина: локально зелёный, в CI красный, при повторном запуске зелёный — тайный обмен данными через общую топологию. Лечение механическое: уникальное имя очереди на тест — имя теста плюс случайный суффикс, очистка через t.Cleanup с QueueDelete, отказ от t.Parallel для сценариев, которые считают сообщения, и отдельный vhost для CI из девятнадцатого урока. Отдельно помните: тестовый брокер с гостевым доступом на общедоступной машине — не тест, а приглашение сканерам.
Подмена потока доставок: тест дожима#
Цикл воркера из семнадцатого урока читает Go-канал с Delivery — и это подарок для тестов: поток подписки подменяется обычным chan amqp.Delivery, который тест наполняет и закрывает. Закрытие потока — то же событие, что отмена контекста из двадцать первого урока: воркер доедает взятые доставки и выходит из range, не зная, что подписка была тестовой. Так дожим проверяется без сети за миллисекунды: две доставки, close, утверждения по счётчикам фейка. В тесте ценны границы: пустой поток, доставка с битым JSON после валидной, повторная доставка с Redelivered=true из восьмого урока. Если воркер запускается горутиной, разделяйте завершение через WaitGroup или закрытие канала — иначе гонка будет в тесте, а не в коде, и race detector укажет на неё честно.
Тест дожима без сети
GOfunc worker(tag string, msgs <-chan amqp.Delivery, c Completer, s Sender) {
seen := map[string]bool{}
for d := range msgs { // закрытие канала имитирует отмену ctx
_ = run(d, c, s, seen)
}
}
func TestDrain(t *testing.T) {
c := &fakeCompleter{}
s := &fakeSender{}
msgs := make(chan amqp.Delivery, 2)
msgs <- amqp.Delivery{
Body: []byte("{"orderid":"1","channel":"push"}"),
DeliveryTag: 1,
}
msgs <- amqp.Delivery{Body: []byte("битый"), DeliveryTag: 2}
close(msgs)
worker("t", msgs, c, s)
if c.acks != 1 || c.nacks != 1 {
t.Fatalf("хотели ack и nack, получили ack=%d nack=%d", c.acks, c.nacks)
}
}Две доставки, закрытие, один прогон воркера — и счётчики фейка показывают, что успешная доставка получила ack, а битая ушла в DLQ через nack. Тот же воркер в бою читает поток из ConsumeWithContext: разница только в источнике. Тест выполняется за микросекунды и прогоняется с -race на каждом коммите.
Интеграционные тесты с брокером#
Слой без брокера не отвечает на вопросы, ради которых очередь существует: доходят ли сообщения до DLX, срабатывает ли TTL, признает ли брокер подтверждения в правильном порядке. Это закрывают интеграционные тесты: поднимите контейнер из второго урока или testcontainers-go, пометьте файл билд-тегом go:build integration, чтобы обычный go test его не собирал. Имена очередей в тестах уникальные — имя теста плюс случайный суффикс — и удаляются в t.Cleanup: параллельные пакеты и два прогона подряд не мешают друг другу. Проверять только здесь: маршруты dead-lettering, x-message-ttl, подтверждения канала, поведение при обрыве соединения из двадцатого урока. Без Docker такие тесты пропускаются через t.Skip по проверке порта — честная деградация вместо красного билда, о которой команда знает по правилам CI.
Флаг -race обязателен для тестов с горутинами: он ловит и гонку в продакшен-коде, и гонку в фейках, которые сами пишут в счётчики. Второй слой, который удобно проверить фейком, — ошибки: закрытый фейк-поток возвращает ошибку вида amqp.ErrClosed, и цикл воркера обязан выйти спокойно, без паники и крика в логах. Тесты на ошибку — самое дешёвое место, где закрепляется поведение из двадцатого урока: воркер умирает тихо, восстановление делает уровень выше.
В песочнице нет пакета testing и encoding/json, но сама идея решений разминается чистой логикой: напишите decide на префиксах строк и переберите кейсы через fmt и strings — там доступны fmt, strings, strconv, math, sort, errors и time. Полные тесты с фейками и -race выполняются локально обычным go test, а интеграционный слой — против контейнера из второго урока.
Пирамида тестов потребителя#
- Чистые решения: handle возвращает Decision — тесты без брокера и сети за микросекунды.
- Транспорт: фейковый Completer проверяет, что решение превратилось в правильный Ack или Nack.
- Дожим: подменённый chan amqp.Delivery с close вместо отмены контекста.
- Интеграция: живой брокер с уникальными именами очередей и очисткой через t.Cleanup.
- Гонки: go test -race на всём, где есть горутины, включая фейки.
- Сценарий: внешний тест деплоя — сообщение проходит путь от продюсера до письма.
Разрезали мы циклы из соседних глав: седьмой урок про ConsumeWithContext дал цикл воркера, семнадцатый про worker pool — дожим с WaitGroup, восьмой про ack — решения Requeue и флаг Redelivered, шестнадцатый про dead letter — маршрут Park. Брокер для интеграционного слоя поднимает второй урок, а приёмы тестирования на Go остались в шестнадцатом уроке курса по Go. Карта курса — на странице курса.
Проверка: почему не мокать канал
Почему *amqp.Channel нельзя подменить моком через интерфейс библиотеки?
Проверка: уникальные имена очередей
Интеграционные тесты идут против одного брокера. Зачем уникальное имя очереди на каждый тест?
Какой порядок правил в decide?
Прочитайте код и выберите, что он выведет:
func decide(parseErr, redelivered bool) string {
switch {
case parseErr:
return "park"
case redelivered:
return "requeue"
default:
return "ack"
}
}
fmt.Print(decide(false, false), decide(true, false), decide(false, true))Соберите тест решения
Допишите сборку доставки, вызов обработчика, ожидаемое решение и фейк.
Соберите тест цикла воркера
Расставьте строки: поток, доставка, закрытие, фейки, прогон, проверка счётчиков.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тестирование потребителя
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Обработчик возвращает решение, а не зовёт методы канала: d.Ack требует живого канала, которого в юнит-тесте нет.
- *amqp.Channel — конкретная структура: мокают свой узкий интерфейс, который боевой канал реализует сам.
- amqp.Delivery собирается в тесте руками: Body, DeliveryTag и Redelivered — обычные поля.
- Дожим тестируется подменой потока: chan amqp.Delivery с close — то же событие, что отмена контекста.
- Интеграционный слой — живой брокер, уникальные имена очередей, t.Cleanup и билд-тег integration.
- go test -race обязателен: гонки ловятся и в коде, и в фейках.
- Тесты на ошибки дешевле всего: закрытый фейк-поток закрепляет тихий выход воркера.
Домашнее задание
Разрежьте воркера из семнадцатого урока по слоям урока: handle с решениями, run с Completer, цикл с подменяемым потоком. Покройте handle таблицей тестов — битый JSON, дубль по OrderID, ошибка Sender, успех — и прогоните с -race. Затем напишите тест дожима на закрытом канале с тремя доставками и один интеграционный тест против контейнера из второго урока: сообщение доходит до DLQ после трёх ошибок, очередь удаляется в t.Cleanup, а без Docker тест пропускается через t.Skip.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему не сделать один интерфейс на весь канал?
Интерфейс на два десятка методов означает, что каждый новый метод канала требует обновления фейка, а тесты начинают проверять фейк вместо кода. Узкий интерфейс из того, что реально использует транспорт, дешевле в поддержке и точнее описывает контракт. Если бизнес-коду понадобятся другие методы — например, Publish, — добавляйте их по мере появления, не заранее.
Стоит ли гонять юнит-тесты против эмулятора брокера?
Эмуляторов AMQP, повторяющих поведение брокера в деталях — dead-lettering, TTL, подтверждения, — на сегодня нет, а частичные имитируют ровно то, что ломается в продакшене. Честнее разделение: юнит — на решениях и фейках, интеграция — на живом брокере в контейнере. Посередине остаются трата времени и ложная уверенность, которые хуже отсутствия теста.
Как тестировать publish-сторону?
Той же техникой: узкий интерфейс Publisher с сигнатурой PublishWithContext, фейк со слайсом сохранённых Publishing, утверждения по mandatory, DeliveryMode и заголовкам. Интеграционный тест с NotifyReturn и подтверждениями проверит то, что фейк не может: маршрутизацию и вердикты брокера. Симметрия слоёв читается на ревью как единый стиль тестирования очереди.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Excel в Go: чтение и генерация xlsx на excelize
Курс excelize на Go: создание xlsx, чтение, стили, формулы, диаграммы и StreamWriter для больших файлов. 25 уроков с примерами кода и проектом.
- Создавать и читать xlsx-файлы средствами excelize
- Работать с листами, ячейками, строками и столбцами
- Оформлять таблицы: стили, шрифты, границы, форматы чисел и дат
- Вставлять формулы, диаграммы и изображения