Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 22 из 25 ~20 мин +50 XP

Тестирование обработчиков

Хендлер asynq — обычная функция с контекстом и задачей, и это главный подарок для тестов: большая часть логики проверяется без Redis вообще. Разложим тестирование по слоям: прямой вызов хендлера, стабы зависимостей, постановка против miniredis и таблицы ошибок — с фиксированным временем вместо живого clock.

Содержание урока · 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

GO
func 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

GO
type 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, детерминированный результат в каждом прогоне.

Интеграционный слой: 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

GO
func 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: фиксированные попытки на входе, интервалы на выходе.

Слои тестов задач#

СлойЧто проверяетИнфраструктура
Прямой вызов 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). Что сделает воркер?

Соберите прямой вызов хендлера

Допишите создание задачи и вызов метода-хендлера в тесте.

task := asynq.("payment:capture", payload) err := h.(context.Background(), task)

Соберите табличный тест ошибок

Расставьте строки табличного теста: кейсы, стаб, прогон, различение ошибок.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: тесты задач

Термин

Прямой вызов хендлера

нажмите, чтобы перевернуть
Определение

asynq.NewTask(type, payload) и h.ProcessTask(context.Background(), task): логика проверяется без Redis за микросекунды. Постановка — отдельный слой.

1 / 6

Пирамида собрана: прямой вызов 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 с константами в тестах. Прогоните набор с флагом гонок и убедитесь, что стабы не делятся между кейсами.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Тестирование обработчиков)

Вопросы и ответы по уроку

Почему нельзя тестировать постановку на настоящем Redis?

Можно — но медленно и хрупко: общий Redis делится между разработчиками и CI, очереди надо чистить, тесты начинают зависеть от окружения. Miniredis даёт те же гарантии изоляции за миллисекунды; настоящий Redis оставьте для окружения staging и дымовых прогонов, где тестируется сама инфраструктура.

Нужен ли отдельный тест на сам воркер с mux?

Один-два дымовых теста стоят того: сервер с mux против miniredis, постановка, ожидание исполнения хендлером-стабом. Такой тест ловит опечатки в имени типа и незарегистрированные маршруты — ошибки, которые прямой вызов хендлера не видит. Больше двух таких тестов обычно не окупается.

Как проверить, что хендлер уважает отмену контекста?

Передайте контекст, уже отменённый через context.WithCancel, и утверждайте возврат ошибки без долгих вызовов стаба. Для частичной отмены — контекст с коротким таймаутом и стабом, который проверяет ctx.Err() между шагами. Это обычные Go-тесты: asynq передаёт контекст транслируемым, семантика отмены та же.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Kafka25 уроков~12 чпрактика25 уроков уже доступны

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once