Содержание урока · 10 разделов
- Asynq тесты: три слоя проверки
- Клиент и Inspector: задача дошла до очереди
- Обработчик через ServeMux: прогон до хендлера
- Изоляция очередей между тестами
- Юнит-моки против Redis в контейнере
- Уровень теста → что проверяем
- Типовые ошибки тестов фоновых задач
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Обработчики asynq — обычные функции, и логику payload они отдают юнит-тестам почти бесплатно. Но у фоновой задачи есть вторая половина жизни, которую мок не увидит: постановка с опциями, маршрут в нужную очередь, состояние внутри Redis, доставка живому воркеру. Проверять это подменой бессмысленно — вы утверждаете поведение собственного стаба, а не библиотеки. Схема честнее: Redis поднимается модулем testcontainers так же, как в шестом уроке про модуль Redis, а дальше с ним работают настоящие asynq.NewClient, NewServer и NewInspector — тот же API, который разбирает курс asynq. Подключение RedisClientOpt, опции постановки и Inspector-методы здесь те же, что в боевой постановке: интеграционный тест отличается только источником адреса, а не стилем кода. Разница с продакшеном ровно одна: адрес Redis приходит из ConnectionString контейнера, а не из конфига, и живёт задача минуты, а не сутки. Всё остальное — постановка, состояние, доставка, повторы — честное, потому что код один и тот же.
Asynq тесты: три слоя проверки#
Тесты фоновых задач go делятся на три слоя, и нагрузка распределена между ними неравномерно. Первый слой — юнит: хендлер вызывается напрямую ProcessTask с context.Background(), без Redis, за микросекунды; зависимости подставляются интерфейсами и стабами. Второй слой — постановка: настоящий клиент кладёт задачу в живой Redis, а Inspector подтверждает эффект — состояние, очередь, отложенный срок. Третий слой — сквозной: воркер с ServeMux забирает задачу и доводит её до хендлера, проверяя маршрутизацию и согласованность конфигов. Ключевое отличие от привычной пирамиды: второму и третьему слою нужна инфраструктура, поэтому пишут их мало — по одному тесту на каждый шаблон постановки и один-два на саму доставку. Юнит-слоя может быть сколько угодно, он бесплатный. Перекос в любую сторону врёт: только юниты пропускают ошибки конфигурации очередей, а только интеграционные тесты делают набор медленным и хрупким. Дисциплина в том, чтобы каждый уровень получал свои утверждения: перетаскивание проверок вверх или вниз только путает диагностику.
Каркас: TestMain поднимает Redis
GOvar redisOpt asynq.RedisClientOpt
func TestMain(m *testing.M) {
ctx := context.Background()
r, err := tcredis.Run(ctx, "redis:7")
if err != nil {
log.Fatalf("redis не поднялся: %v", err)
}
defer testcontainers.TerminateContainer(r)
uri, err := r.ConnectionString(ctx)
if err != nil {
log.Fatalf("connection string: %v", err)
}
redisOpt = asynq.RedisClientOpt{
Addr: strings.TrimPrefix(uri, "redis://"),
}
os.Exit(m.Run())
}TestMain поднимает Redis модулем один раз на пакет: порт и готовность модуль берёт на себя, управление вернётся только когда сервер принимает команды. ConnectionString отдаёт redis://-адрес маппированного порта, TrimPrefix снимает схему — RedisClientOpt принимает голый host:port. Одно значение redisOpt достанется клиенту, воркеру и Inspector, поэтому конфигурация не разъедется. TerminateContainer в defer уберёт контейнер даже при падении тестов.
Клиент и Inspector: задача дошла до очереди#
Тест постановки отвечает на вопрос «задача вообще легла в очередь?». Механика простая: asynq.NewClient строится на адрес контейнера — тот же RedisClientOpt, что и боевой, отличается только Addr. Enqueue возвращает TaskInfo с идентификатором — он и становится ключом проверки. Дальше включается Inspector: NewInspector на тот же адрес, GetTaskInfo(queue, id) — и у теста в руках полное досье: State, Retried, LastErr, NextProcessAt. Немедленная постановка лежит в состоянии pending, ProcessIn переводит её в scheduled с расчётным сроком — оба утверждения записываются сравнением State с TaskStatePending и TaskStateScheduled. Проверка честная: она проходит через весь код постановки asynq, включая разбор опций и раскладку по ключам Redis, а не подглядывает в internals. Ошибку Enqueue проверяйте обязательно: клиент не подключается в конструкторе, и первая настоящая сетевая ошибка вернётся именно здесь — тест упадёт с ясной причиной, а не зависнет. Inspector при этом живёт независимо от воркеров: он читает Redis сам, поэтому постановку можно проверять, пока ни один воркер ещё не поднят, — и это правильный порядок: сначала очередь, потом доставка.
Тест постановки: Enqueue и GetTaskInfo
GOfunc TestEnqueueDeliversTask(t *testing.T) {
client := asynq.NewClient(redisOpt)
defer client.Close()
inspector := asynq.NewInspector(redisOpt)
defer inspector.Close()
const queue = "billing-it" // имя очереди — на тест
task := asynq.NewTask(tasks.TypeCharge, []byte(`{"order":42}`))
info, err := client.Enqueue(task, asynq.Queue(queue))
if err != nil {
t.Fatalf("enqueue: %v", err)
}
ti, err := inspector.GetTaskInfo(queue, info.ID)
if err != nil {
t.Fatalf("get task info: %v", err)
}
if ti.State != asynq.TaskStatePending {
t.Fatalf("состояние = %v, хотим pending", ti.State)
}
}Постановка идёт настоящим клиентом asynq: Enqueue вернёт TaskInfo с ID, по которому Inspector достаёт досье из Redis. Утверждение сравнивает State — задача легла в pending. Константа типа и имя очереди берутся из общего пакета задач, как в боевой постановке, поэтому тест проверяет именно тот код, который уйдёт в продакшен, включая сборку payload и опции.
Проверка: конструктор без сети
asynq.NewClient создан с адресом ещё не поднятого Redis. Что происходит в момент вызова конструктора?
Обработчик через ServeMux: прогон до хендлера#
Постановка — половина контракта; вторая половина — что воркер дойдёт до хендлера. Прямой вызов ProcessTask маршрутизацию не проверяет: она живёт в ServeMux и конфиге сервера. Интеграционный тест поднимает настоящий asynq.NewServer на адресе контейнера: NewServeMux, регистрация хендлера по типу задачи, srv.Run(mux) в горутине. Хендлер в тесте сигналит о доставке — обычно каналом, в который пишет факт вызова и ошибку. Тест ставит задачу, ждёт сигнал через select с таймаутом и утверждает результат. Такой тест ловит то, что не увидит ни один юнит: опечатку в типе задачи между клиентом и mux, очередь, не включённую в Queues конфига, хендлер, забытый при рефакторинге. Цена — секунды на прогон и живая горутина воркера, которую нужно аккуратно остановить Shutdown-ом в Cleanup. Параллельность здесь тоже честная: Concurrency в конфиге задаёт число горутин-обработчиков, и для теста хватает единиц — скорость доставки от этого не страдает, а нагрузка на машину минимальна. Меньше одного сквозного теста на пакет не стоит: он единственный проверяет, что клиент и воркер вообще договорились.
Сквозной тест обработчика
GOfunc TestHandlerRuns(t *testing.T) {
mux := asynq.NewServeMux()
done := make(chan error, 1)
mux.HandleFunc(tasks.TypeCharge, func(ctx context.Context, tsk *asynq.Task) error {
done <- nil
return nil
})
srv := asynq.NewServer(redisOpt, asynq.Config{
Concurrency: 2,
Queues: map[string]int{"billing-it": 10},
})
go func() { _ = srv.Run(mux) }()
t.Cleanup(func() { _ = srv.Shutdown() })
client := asynq.NewClient(redisOpt)
defer client.Close()
if _, err := client.Enqueue(asynq.NewTask(tasks.TypeCharge, nil), asynq.Queue("billing-it")); err != nil {
t.Fatalf("enqueue: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("хендлер вернул ошибку: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("хендлер не получил задачу за пять секунд")
}
}Воркер запускается настоящим NewServer в горутине: Run блокирует управление, поэтому тест читает канал done. Хендлер регистрируется на ServeMux тем же типом задачи, что уходит в Enqueue, — маршрутизация проверяется по-боевому. Очередь billing-it обязана значиться в Queues конфига, иначе воркер её не возьмёт. Shutdown в t.Cleanup останавливает воркер, а буферизованный канал не даст горутине зависнуть после таймаута.
Плохой вариант — time.Sleep на полсекунды и надежда: на нагруженной машине тест то зелёный, то красный. Правильный — канал done и select с time.After: тест просыпается ровно в момент вызова хендлера, не раньше и не позже. Тонкость: если воркер опоздал за таймаут, горутина хендлера может написать в done уже после провала теста — берите буферизованный канал ёмкости один, тогда запись не зависнет и не уронит прогон. И останавливайте воркер Shutdown-ом в t.Cleanup: Run в горутине без остановки переживает тест, а повторный прогон пакета наткнётся на воркер-зомби, который молча заберёт чужие задачи. Гоните такой тест и с флагом гонок: воркер и хендлер — это уже две горутины.
Изоляция очередей между тестами#
Redis в контейнере — общий на пакет, а тесты должны оставаться независимыми. Первый инструмент — уникальные имена очередей: asynq.Queue(queue) при постановке, где queue выводится из t.Name() или пишется константой конкретного теста. Задачи не перемешиваются: у каждой тестовой очереди свой набор ключей в Redis, asynq хранит очереди раздельно. Обратная сторона: воркер обрабатывает только очереди из Config.Queues — карту «имя: вес» собирают под конкретный тест, иначе задачи зависнут в pending навечно, и никакая ошибка об этом не скажет. Второй инструмент — логическая база Redis: поле DB в RedisClientOpt разводит тесты по разным пространствам ключей, но рядом с уникальными очередями это избыточно. Третий — чистка между тестами: FlushDB стирает всё, включая чужие данные, поэтому безопасен только при строгой сериализации прогона. На практике хватает первого инструмента: уникальная очередь на тест плюс ровно она в Queues — и параллельность изоляции не мешает. Имена с slug'ом теста удобнее формировать хелпером: один вызов — и каждая проверка получила собственное пространство задач без дублей.
Проверка: Queues в конфиге
Тест ставит задачи в очередь billing-it, а карта Queues в asynq.Config воркера её не упоминает. Что произойдёт?
Что вернёт GetTaskInfo после обработки
Прочитайте код и выберите, что он выведет:
// Воркер успешно обработал задачу и удалил её из очереди.
// Retention не настроен — выполненные задачи не хранятся.
ti, err := inspector.GetTaskInfo("billing-it", info.ID)Юнит-моки против Redis в контейнере#
Границу слоёв удобно держать в голове как таблицу: что проверяем — чем проверяем. Юнит-тесты отвечают за логику хендлера и взаимодействия через интерфейсы: они не знают ни Redis, ни Docker и гоняются при каждом сохранении файлов. Интеграционные — за контракт с очередью: постановку, опции, состояние, маршрутизацию, согласованность конфигов. Это деление не вкусовщина, а экономика: поднять Redis в контейнере — секунды, и на каждой такой проверке прогон растёт; в то же время ошибку «поставили задачу не в ту очередь» юнит-тест не поймает в принципе, как и не поймает рассинхрон Queues между постановкой и воркером. Мок врёт ровно там, где начинается библиотека: у стаба нет состояний pending и scheduled, нет ретраев и нет досье по ID. Поэтому моки оставляют хендлеру и его зависимостям, а очередь проверяют живой — настоящим клиентом против настоящего Redis в контейнере. Отсюда практический вывод: сколько бы ни было хендлеров, интеграционных тестов постановки обычно три-четыре — немедленная, отложенная, идемпотентная с фиксированным TaskID и одна на ошибку постановки.
Уровень теста → что проверяем#
| Уровень теста | Что проверяем | Инфраструктура |
|---|---|---|
| Прямой вызов ProcessTask | логику хендлера: payload, ветки, возвращаемую ошибку | ничего: context.Background() |
| Хендлер со стабами | взаимодействия с зависимостями через интерфейсы | фейки вместо БД и сети |
| Постановка на живой Redis | Enqueue, опции, состояние задачи в очереди | Redis в контейнере testcontainers |
| Воркер на ServeMux | маршрутизацию типа задачи и путь до хендлера | Redis в контейнере + srv.Run |
| Прогон среды | совместимость клиента и воркеров на одном Redis | docker compose или стенд |
Соберите тест постановки
Восстановите тест: клиент, задача, опция очереди и проверка досье.
Соберите хронологию пакета тестов
Расставьте шаги пакета: от запуска контейнера до остановки воркера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Каркас пакета собирается один раз и живёт долго: TestMain с контейнером, общий RedisClientOpt, тест постановки на каждый шаблон, один сквозной тест воркера — дальше только добавляются типы задач и опции. Как вписать такой TestMain в общий пакет с базой и кэшем, чтобы контейнеры стартовали один раз на все наборы, — разбор в уроке про Testify и TestMain; там же хелпер CleanupContainer, которым удобно убирать контейнер Redis, не пиша defer руками. Утверждения в тестах аккуратно писать testify: require для остановки при фатальной ошибке, assert для досье задачи. Когда прогон разрастётся и контейнеров станет много, вернитесь к теме переиспользования: один Redis на пакет дешевле, чем по контейнеру на каждый тест, — но тогда изоляция ложится целиком на имена очередей, и держать их уникальными становится не правилом хорошего тона, а обязательством. Если тесты этого пакета падают чаще прочих, начинайте диагностику с каркаса: поднимется ли контейнер, отдался ли адрес, дошло ли досье — половина проблем ищется до всякой бизнес-логики.
Типовые ошибки тестов фоновых задач#
- проверка связи в конструкторе NewClient: соединение ленивое — ловите ошибку на Enqueue
- очередь теста не включена в Queues конфига воркера: задачи вечно pending без единой ошибки
- одно имя очереди на все тесты: параллельный прогон подмешивает чужие задачи
- sleep вместо канала с таймаутом: тест флейкает на нагруженной машине
- GetTaskInfo без errors.Is(ErrTaskNotFound): тест падает на уже выполненных задачах
- забытый srv.Shutdown в t.Cleanup: воркер переживает тест и забирает чужие задачи
- моки вместо Redis: тест не видит ни постановку, ни состояние, ни ретраи
- запуск набора без демона Docker: testcontainers падает на старте — проверьте окружение
Честная оговорка: в песочнице сайта этот урок не запустится. Коду нужен живой демон Docker: testcontainers поднимает контейнер Redis, а asynq и Inspector ходят в него по сети. Рабочая схема локальная: сохраните тесты в файл *_test.go рядом с go.mod и запустите go test — Docker должен быть запущен, образ Redis подтянется при первом прогоне. В CI код проходит без изменений, если в раннере есть Docker, — об этом был двадцатый урок. Отсутствие Docker чтению не мешает: весь код разобран построчно. Домашний эксперимент: docker ps до и после прогона — контейнер Redis обязан исчезнуть.
Карточки: asynq на Redis
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Redis поднимается модулем testcontainers в TestMain; ConnectionString отдаёт адрес, из которого собирается один RedisClientOpt на клиент, воркер и Inspector.
- Тест постановки: Enqueue возвращает TaskInfo, GetTaskInfo подтверждает State — pending для немедленной постановки, scheduled для ProcessIn.
- Сквозной тест: NewServer с ServeMux в горутине, хендлер сигналит каналом, select с таймаутом, Shutdown в t.Cleanup.
- Изоляция — уникальная очередь на тест через asynq.Queue и ровно она в Queues конфига; логику хендлера оставьте мокам, очередь проверяйте живой.
Домашнее задание
Соберите пакет: TestMain с redis.Run и общим RedisClientOpt, тест постановки с утверждением TaskStatePending, сквозной тест воркера с каналом и таймаутом. Затем добавьте ProcessIn(30*time.Second) и проверку TaskStateScheduled с NextProcessAt в будущем. Финал: прогоните пакет с флагом гонок и убедитесь, что Shutdown не оставил горутин.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы не замокировать Client и не проверить постановку юнит-тестом?
Мок повторяет контракт, который придумали вы, а не поведение библиотеки: он не раскладывает задачу по ключам Redis, не разбирает опции и не умеет состояний pending и scheduled. Тест против мока утверждает ваш стаб, а не Enqueue. Интеграционный тест с настоящим клиентом и Inspector проходит через весь код постановки asynq и ловит реальные ошибки: битую опцию, недопустимое имя очереди, отказ сети. Моки оставьте зависимостям хендлера — почтовику, базе, кошельку, — там контракт ваш и стабы честны.
Нужен ли тесту живой воркер — не хватит ли одного Inspector?
Зависит от утверждения. Состояние задачи после постановки видно и без воркера: GetTaskInfo читает Redis напрямую, и его досье существует независимо от того, кто заберёт задачу. Но маршрут «тип задачи — хендлер» и согласованность Queues проверяет только живой воркер: задача ушла, хендлер вызван. Ровно один такой сквозной тест на пакет закрывает класс ошибок «клиент и воркер разъехались». Всё остальное — состояния, ретраи, сроки — надёжнее проверять Inspector-ом: меньше горутин, меньше времени, меньше флейка.
Один Redis на пакет или контейнер на каждый тест?
Контейнер на тест — самая сильная изоляция, но и самая дорогая: секунды старта на каждый тест и рой контейнеров при параллельности. Один Redis на пакет через TestMain с уборкой в конце — компромисс по умолчанию: изоляцию дают уникальные очереди, а скорость остаётся приличной. Если тесты трогают Redis вне очередей — пишут ключи напрямую, — добавляйте чистку между тестами или переходите на контейнер на тест. Выбор делайте измерением, а не модой: главное, чтобы изоляция не зависела от везения.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах