Содержание урока · 10 разделов
К этому моменту обработчики уже делают серьёзную работу: валидируют payload, зовут внешние сервисы, возвращают ошибки, решают судьбу ретраев. Тестировать это подъёмом настоящего воркера с настоящим Redis — медленно и хрупко: гонки, засыпание на секунды, чистка очередей между тестами. Правильный разрез другой: хендлер — обычная функция, и почти всё о нём проверяется прямыми вызовами; очередь — отдельный слой, который тестируется маленьким числом интеграционных тестов против Redis в памяти. В этом уроке пройдём все слои: вызов ProcessTask с context.Background() и без Redis, сборка payload как отдельная тестируемая функция, struct-хендлер с интерфейсами Mailer и Wallet из семнадцатого урока и стабы вместо них, интеграционный тест постановки с miniredis и Inspector, инжект фиксированного времени для всего, что зависит от clock, и табличные тесты, различающие SkipRetry от обычной ошибки. Бонус — честный разговор о том, почему хелперы из самого репозитория asynq вам недоступны. Правило простое: чем ниже слой теста, тем чаще он запускается и тем раньше ловит регресс; очередь стоит в самом верху пирамиды и потому тестируется реже всего.
Хендлер — обычная функция#
asynq.Handler — это интерфейс с одним методом ProcessTask(ctx context.Context, t *asynq.Task) error, а asynq.HandlerFunc — адаптер для функций. Отсюда главный приём: тест создаёт задачу через asynq.NewTask(type, payload) и вызывает хендлер напрямую с context.Background() — Redis, сервер и очередь не нужны, тест выполняется за микросекунды. Внутри теста задача неотличима от боевой: t.Type() вернёт имя типа, t.Payload() — байты payload, так что хендлер распакует JSON ровно так же, как в продакшене. Если хендлер — метод структуры, тестируется метод: HandleCapture{}.ProcessTask(ctx, task). Что такой тест проверяет: разбор payload, бизнес-логику, возвращаемую ошибку. Что не проверяет: постановку, опции, ретраи и время — это ответственность других слоёв, и смешивать их в один тест не стоит. Отдельный маленький контракт: хендлер обязан уважать контекст, и это тоже тестируется без asynq — передайте контекст с отменой и убедитесь, что функция вернулась, а не уснула до конца работы. Приём масштабируется и на контекст из семнадцатого урока: если хендлер читает из ctx значения, тест кладёт их туда теми же хелперами — сигнатура вызова не меняется, меняется только подставленный контекст.
Прямой вызов без Redis
GOfunc TestHandleCapture(t *testing.T) {
h := HandleCapture{}
payload, err := json.Marshal(PaymentPayload{OrderID: "ord-1", Amount: 9900})
if err != nil {
t.Fatal(err)
}
task := asynq.NewTask("payment:capture", payload)
if err := h.ProcessTask(context.Background(), task); err != nil {
t.Fatalf("хендлер вернул ошибку: %v", err)
}
}Тест собирает задачу тем же конструктором NewTask, которым пользуется постановка, и зовёт ProcessTask с context.Background(): никаких Redis и воркеров, поэтому прогон занимает миллисекунды. Такой тест проверяет разбор payload и бизнес-логику; всё, что связано с очередью, останется интеграционным слоям — и это нормально, слои разделили осознанно.
Payload: тестируйте сборку отдельно#
Половина поломок задач — не в логике хендлера, а в payload: забытое поле, поменянный тег JSON, вложенная структура с указателем. Ловить это интеграционным тестом — дорого, ловить нечем вообще, если сборка размазана по хендлерам. Практика: функция сборки payload — отдельная тестируемая единица. Например, BuildPaymentPayload(order Order) ([]byte, error) сериализует структуру и проверяется таблично: какие поля попадают в JSON при нулевых значениях, что происходит с датами в нестандартных таймзонах, какая ошибка возвращается на невалидном заказе. Хендлер тогда тестируется на готовых []byte — фиксация payload прямо в тесте, в читаемом виде, становится документацией формата: апгрейд формата ломает тест раньше, чем живые очереди. Обратная сторона: не тестируйте json.Marshal сам по себе — тестируйте свою структуру и её теги; стандартная библиотека уже покрыта её собственными тестами, а ваши теги — нет. Отдельно проверяйте нулевые значения: JSON не различает пустую строку и отсутствие поля, и именно на нулях форматы ломаются чаще всего. Держите сборку рядом с типом payload в одном файле: правка структуры и правка теста едут одним коммитом, и рассинхрон между продакшеном и тестом становится невозможным.
Проверка: нужен ли Redis
Тест создаёт asynq.NewTask и вызывает h.ProcessTask(context.Background(), task). Нужен ли запущенный Redis?
Фейковые зависимости: struct-хендлер с интерфейсами#
Хендлер, который пишет письма и списывает деньги, тестируется как любой другой код с зависимостями: зависимости объявляются интерфейсами, а в тест подставляются стабы. Struct-хендлер из семнадцатого урока для этого идеален: HandleWelcome{Mail Mailer} — где Mailer — интерфейс с методом Send. В тесте Mailer заменяется структурой-стабом, которая запоминает вызовы: сколько раз позвали Send, с каким адресом и темой, вернула ли ошибку. Проверки становятся содержательными: после удачного ProcessTask стаб получил один вызов с правильным адресом; после ошибки SMTP-стаба хендлер вернул ошибку — и задача уйдёт в retry по политике; после битого payload хендлер обернул ошибку в SkipRetry. Тот же приём работает для Wallet: стаб-кошелёк с управляемым балансом покрывает сценарии «денег хватило» и «недостаточно средств» без единого реального счёта. Важно, что интерфейсы объявляет ваш код, а не asynq: очередь здесь ни при чём, это обычный юнит-тест Go, и пишется он теми же table-driven конструкциями, что и весь ваш код. Вторая польза стабов — скорость: хендлер, который в бою ждёт SMTP секунды, в тесте отрабатывает за микросекунды, поэтому весь набор гоняется на каждый коммит, а не ночью по расписанию.
Стаб вместо Mailer
GOtype stubMailer struct {
sent []string
err error
}
func (s *stubMailer) Send(to, subj string) error {
if s.err != nil {
return s.err
}
s.sent = append(s.sent, to)
return nil
}
func TestHandleWelcome(t *testing.T) {
mail := &stubMailer{}
h := HandleWelcome{Mail: mail}
// ... сборка task из payload с email
if err := h.ProcessTask(context.Background(), task); err != nil {
t.Fatalf("ProcessTask: %v", err)
}
if len(mail.sent) != 1 {
t.Fatalf("писем отправлено %d, хочу 1", len(mail.sent))
}
}Стаб реализует тот же интерфейс Mailer, что и боевой SMTP-клиент, и запоминает факты вызовов: тест утверждает не «как-то отправилось», а ровно один вызов нужному адресату. Управляемое поле err переключает сценарии: nil — успех, ошибка — путь ретрая. Никакой сети, никакого Redis, детерминированный результат в каждом прогоне.
Первый враг тестов задач — живое время: хендлер, зовущий time.Now(), даст разное поведение в полночь и на границе месяца, а тест со time.Sleep(little while) будет мигать на CI. Лечится инжектом: поле now func() time.Time в struct-хендлере, в тесте — функция-константа. Второй враг — общее состояние стабов: один stubMailer на все случаи теста превращает прогоны в лотерею — создавайте свежий стаб в каждом кейсе таблицы, table-driven тесты для того и существуют. Третий враг — глобальные стабы в пакетных переменных: они связывают тесты невидимыми нитями; держите стабы локальными и передавайте их явно через поля структуры.
Интеграционный слой: miniredis и Inspector#
Слой постановки тоже заслуживает теста: правильно ли собраны опции, попадает ли задача в нужную очередь, корректен ли scheduled-срок. Здесь без Redis не обойтись, но настоящий не нужен: miniredis поднимает Redis-сервер в памяти прямо внутри тестового процесса. Схема теста: mr := miniredis.RunT(t), затем клиент asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()}) — и Enqueue против него. Проверка результата — через Inspector из пятнадцатого урока, построенный на тот же адрес: GetTaskInfo(queue, info.ID) вернёт TaskInfo, у которого утверждается State и NextProcessAt. Это честная проверка: она проходит через настоящий код постановки asynq, включая scheduled-состояния и TaskID-конфликты, — а не через подглядывание в internals. О честности стоит сказать прямо: публичного пакета тест-хелперов у asynq нет — инструменты, которыми пользуются тесты самой библиотеки, живут в internal/testutil и internal/testbroker, брокер целиком в памяти, и извне не импортируются. Изоляция — вторая награда: тест, упавший против общего Redis, может испортить чужой прогон, а тест против своей копии в памяти не умеет мешать соседям даже при параллельном запуске. Практический минимум поэтому прост: miniredis, настоящий Client и Inspector — этого хватает на весь интеграционный слой. Сколько таких тестов нужно: по одному на каждый шаблон постановки — немедленная, отложенная, идемпотентная с TaskID; три-четыре теста закрывают почти любой сервис, и каждый выполняется за десятки миллисекунд.
Постановка против miniredis
GOfunc TestEnqueuePayment(t *testing.T) {
mr := miniredis.RunT(t)
client := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()})
defer client.Close()
info, err := client.Enqueue(
asynq.NewTask("payment:capture", payload),
asynq.ProcessIn(30*time.Minute),
)
if err != nil {
t.Fatal(err)
}
insp := asynq.NewInspector(asynq.RedisClientOpt{Addr: mr.Addr()})
ti, err := insp.GetTaskInfo("default", info.ID)
if err != nil {
t.Fatal(err)
}
if ti.State != asynq.TaskStateScheduled {
t.Fatalf("state = %v, хочу scheduled", ti.State)
}
}miniredis.RunT(t) поднимает сервер в памяти и прибирает за собой по завершении теста; клиент и Inspector строятся на его адрес и работают с настоящим кодом asynq. Утверждение через GetTaskInfo проверяет эффект постановки — состояние scheduled и срок — так же, как это увидит продакшен. Интеграционных тестов такого рода нужно немного: по одному на шаблон постановки.
Какой тест поймает смену тега JSON?
Прочитайте код и выберите, что он выведет:
type PaymentPayload struct {
OrderID string `json:"order_id"`
Amount int `json:"amont"` // опечатка в теге
}Фиксированное время: инжект clock#
Всё, что зависит от времени, тестируется подстановкой времени, а не ожиданием. Минимальный clock-интерфейс — это поле now func() time.Time: боевой код получает его значением time.Now, тест — константой. Так проверяются расчёты «окно списания — первые числа месяца», дедлайны из одиннадцатого урока и логика «повторная попытка не раньше, чем через час». Для хендлеров, живущих в контексте, работает и второй приём: тест передаёт контекст с context.WithDeadline(ctx, fixedTime) — код, уважающий дедлайн, отработает одинаково в любой день года. Отдельно про scheduled-задачи: ProcessIn и ProcessAt в тесте постановки проверяются через NextProcessAt из TaskInfo — это время, вычисленное asynq от настоящего clock, поэтому утверждать стоит интервал «примерно тридцать минут от старта теста», а не равенство до секунды; проверки равенства добейтесь в своей логике, где время инжектится, а не в библиотеке, где время живёт. Приём работает и для повторов: собственная RetryDelayFunc из тринадцатого урока — чистая функция от счётчика попыток и ошибки, и тестируется она отдельно, без Redis: фиксированные попытки на входе, интервалы на выходе.
Песочница этого урока показывает программу на чистом Go: тесты хендлера, стаб-почтальон и фиксированный clock запускаются без Redis и без сети — ровно те слои, которые в реальном проекте не требуют инфраструктуры. Интеграционный слой с miniredis в песочнице имитируется проверками на локальной структуре-очереди: форма утверждений та же, что с Inspector. Если захочется глубже, перенесите стаб-почтальона в свой проект: один интерфейс, одна структура — и хендлеры перестают быть непроверяемыми.
Слои тестов задач#
| Слой | Что проверяет | Инфраструктура |
|---|---|---|
| Прямой вызов ProcessTask | Разбор payload, бизнес-логика, ошибки | Не нужна |
| Стабы Mailer и Wallet | Взаимодействие с зависимостями | Не нужна |
| Функция сборки payload | Теги JSON, поля, нулевые значения | Не нужна |
| Постановка с miniredis | Опции, очередь, scheduled-срок | Redis в памяти |
| Inspector-проверка | Состояние и NextProcessAt задачи | Redis в памяти |
Правила тестов задач#
- Хендлер тестируется прямым вызовом ProcessTask с context.Background() — без Redis.
- Сборка payload — отдельная функция с табличным тестом на ожидаемый JSON.
- Зависимости — интерфейсы в struct-хендлере; стабы создаются заново в каждом кейсе.
- Время инжектится полем now func() time.Time; спящих тестов в проекте быть не должно.
- Интеграционных тестов постановки немного: miniredis, Client, Inspector — по шаблону.
- SkipRetry и RevokeTask проверяются errors.Is в табличных тестах ошибок.
- Ошибки — часть контракта хендлера: битый payload и недоступный SMTP — разные ветки.
Проверка: судьба SkipRetry
Хендлер вернул ошибку, обёрнутую в asynq.SkipRetry, при MaxRetry(5). Что сделает воркер?
Соберите прямой вызов хендлера
Допишите создание задачи и вызов метода-хендлера в тесте.
Соберите табличный тест ошибок
Расставьте строки табличного теста: кейсы, стаб, прогон, различение ошибок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты задач
Пирамида собрана: прямой вызов ProcessTask закрывает логику, стабы Mailer и Wallet — взаимодействия, отдельная функция сборки — формат payload, miniredis с Client и Inspector — постановку и scheduled-сроки, инжект clock — всё, что зависит от времени, а табличные тесты ошибок различают обычную ошибку, SkipRetry и RevokeTask через errors.Is. Мини-эксперимент: возьмите хендлер из семнадцатого урока, отрежьте ему Mailer-стаб и гоните таблицу из трёх кейсов — успех, сетевая ошибка, битый payload; затем добавьте miniredis-тест постановки с ProcessIn и убедитесь в TaskStateScheduled. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без Redis. Проверка зрелости набора: ни один тест не знает про сеть, и всё проходит быстрее, чем стартует контейнер с Redis. Дальше по курсу: двадцать третий урок покажет, что происходит с этими хендлерами в бою — логи и метрики, а стабы из семнадцатого урока и ошибки хендлеров из четырнадцатого уже готовили вас к этому разбору.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- asynq.Handler — интерфейс с одним методом ProcessTask: хендлер тестируется как обычная функция.
- Прямой вызов: asynq.NewTask(type, payload) плюс context.Background() — без Redis, быстро и детерминированно.
- Сборка payload — отдельная функция с табличным тестом: ловит опечатки в тегах JSON.
- Struct-хендлер с интерфейсами Mailer и Wallet принимает стабы, запоминающие вызовы.
- Публичного пакета тест-хелперов у asynq нет: internal/testutil и internal/testbroker не импортируются.
- Интеграционный слой — miniredis: RunT(t), Client на его адресе, проверка через Inspector.GetTaskInfo.
- Время инжектится: поле now func() time.Time, а NextProcessAt утверждается интервалом.
- Табличные тесты ошибок различают через errors.Is обычную ошибку, SkipRetry и RevokeTask.
- Один-два дымовых теста с сервером и mux ловят опечатки в именах типов задач.
Домашнее задание
Покройте тестами хендлер HandleCapture: прямой вызов без Redis, стаб Wallet с тремя балансами, табличный тест ошибок с ветками обычной ошибки и SkipRetry. Добавьте функцию сборки payload с проверкой ожидаемого JSON и miniredis-тест постановки с ProcessIn(30*time.Minute), утверждающий TaskStateScheduled через Inspector. Добейтесь, чтобы весь набор проходил без Docker и быстрее секунды, а время в хендлере шло через инжект now func() time.Time с константами в тестах. Прогоните набор с флагом гонок и убедитесь, что стабы не делятся между кейсами.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя тестировать постановку на настоящем Redis?
Можно — но медленно и хрупко: общий Redis делится между разработчиками и CI, очереди надо чистить, тесты начинают зависеть от окружения. Miniredis даёт те же гарантии изоляции за миллисекунды; настоящий Redis оставьте для окружения staging и дымовых прогонов, где тестируется сама инфраструктура.
Нужен ли отдельный тест на сам воркер с mux?
Один-два дымовых теста стоят того: сервер с mux против miniredis, постановка, ожидание исполнения хендлером-стабом. Такой тест ловит опечатки в имени типа и незарегистрированные маршруты — ошибки, которые прямой вызов хендлера не видит. Больше двух таких тестов обычно не окупается.
Как проверить, что хендлер уважает отмену контекста?
Передайте контекст, уже отменённый через context.WithCancel, и утверждайте возврат ошибки без долгих вызовов стаба. Для частичной отмены — контекст с коротким таймаутом и стабом, который проверяет ctx.Err() между шагами. Это обычные Go-тесты: asynq передаёт контекст транслируемым, семантика отмены та же.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once