---
title: "Тесты фоновых задач: asynq на Redis"
description: "Интеграционные тесты фоновых задач: asynq-клиент и воркер на Redis из testcontainers, постановка с Enqueue, проверка Inspector.GetTaskInfo и изоляция очередей."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/21-asynq-i-redis/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тесты фоновых задач: asynq на Redis

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 21 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/21-asynq-i-redis/*

Фоновые задачи — это Redis, воркеры и сеть, и юнит-тесты этой связки не касаются: подменённый клиент честно врёт о постановке. Сценарий «тесты фоновых задач go» собирается иначе: поднимаем Redis в контейнере через testcontainers, подключаем к нему asynq-клиент, воркер и Inspector и проверяем путь задачи целиком — от Enqueue до хендлера. В уроке соберём каркас TestMain с redis.Run, напишем тест «задача дошла до очереди» через Inspector.GetTaskInfo, прогоним обработчик через ServeMux в живом воркере и научимся изолировать очереди между тестами. asynq тесты от этого становятся честными: работает настоящий код постановки и настоящий Redis, а не двойник из стаба. Оговорка про песочницу — ниже: коду нужен живой Docker. Утверждения пишутся тем же словарём, что и в боевых тестах пакета: сравнение состояний, досье и ошибок.

Обработчики asynq — обычные функции, и логику payload они отдают юнит-тестам почти бесплатно. Но у фоновой задачи есть вторая половина жизни, которую мок не увидит: постановка с опциями, маршрут в нужную очередь, состояние внутри Redis, доставка живому воркеру. Проверять это подменой бессмысленно — вы утверждаете поведение собственного стаба, а не библиотеки. Схема честнее: Redis поднимается модулем testcontainers так же, как в [шестом уроке про модуль Redis](/courses/testcontainers/lessons/06-redis-module/), а дальше с ним работают настоящие asynq.NewClient, NewServer и NewInspector — тот же API, который разбирает [курс asynq](/courses/asynq/). Подключение RedisClientOpt, опции постановки и Inspector-методы здесь те же, что в боевой постановке: интеграционный тест отличается только источником адреса, а не стилем кода. Разница с продакшеном ровно одна: адрес Redis приходит из ConnectionString контейнера, а не из конфига, и живёт задача минуты, а не сутки. Всё остальное — постановка, состояние, доставка, повторы — честное, потому что код один и тот же.

### Asynq тесты: три слоя проверки

Тесты фоновых задач go делятся на три слоя, и нагрузка распределена между ними неравномерно. Первый слой — юнит: хендлер вызывается напрямую ProcessTask с context.Background(), без Redis, за микросекунды; зависимости подставляются интерфейсами и стабами. Второй слой — постановка: настоящий клиент кладёт задачу в живой Redis, а Inspector подтверждает эффект — состояние, очередь, отложенный срок. Третий слой — сквозной: воркер с ServeMux забирает задачу и доводит её до хендлера, проверяя маршрутизацию и согласованность конфигов. Ключевое отличие от привычной пирамиды: второму и третьему слою нужна инфраструктура, поэтому пишут их мало — по одному тесту на каждый шаблон постановки и один-два на саму доставку. Юнит-слоя может быть сколько угодно, он бесплатный. Перекос в любую сторону врёт: только юниты пропускают ошибки конфигурации очередей, а только интеграционные тесты делают набор медленным и хрупким. Дисциплина в том, чтобы каждый уровень получал свои утверждения: перетаскивание проверок вверх или вниз только путает диагностику.

### Каркас: TestMain поднимает Redis

```go
var 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

```go
func 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. Что происходит в момент вызова конструктора?

- Паника: клиент обязан подключаться при создании
- ✅ Клиент создастся без сети: соединение ленивое, первая ошибка вернётся на Enqueue
- Конструктор заблокируется, пока Redis не поднимется
- Клиент сам поднимет Redis в контейнере

Пул соединений открывается на первой операции, поэтому конструктор мгновенно проходит даже без Redis — это нормальное поведение, а не лазейка. Именно поэтому контейнер поднимают в TestMain до всех тестов: ошибка сети должна всплыть на первом Enqueue с ясным сообщением, а не превратиться в загадочный провал посреди набора.

### Обработчик через ServeMux: прогон до хендлера

Постановка — половина контракта; вторая половина — что воркер дойдёт до хендлера. Прямой вызов ProcessTask маршрутизацию не проверяет: она живёт в ServeMux и конфиге сервера. Интеграционный тест поднимает настоящий asynq.NewServer на адресе контейнера: NewServeMux, регистрация хендлера по типу задачи, srv.Run(mux) в горутине. Хендлер в тесте сигналит о доставке — обычно каналом, в который пишет факт вызова и ошибку. Тест ставит задачу, ждёт сигнал через select с таймаутом и утверждает результат. Такой тест ловит то, что не увидит ни один юнит: опечатку в типе задачи между клиентом и mux, очередь, не включённую в Queues конфига, хендлер, забытый при рефакторинге. Цена — секунды на прогон и живая горутина воркера, которую нужно аккуратно остановить Shutdown-ом в Cleanup. Параллельность здесь тоже честная: Concurrency в конфиге задаёт число горутин-обработчиков, и для теста хватает единиц — скорость доставки от этого не страдает, а нагрузка на машину минимальна. Меньше одного сквозного теста на пакет не стоит: он единственный проверяет, что клиент и воркер вообще договорились.

### Сквозной тест обработчика

```go
func 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 останавливает воркер, а буферизованный канал не даст горутине зависнуть после таймаута.

> **Живое место: канал вместо sleep.** Плохой вариант — 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 воркера её не упоминает. Что произойдёт?

- Воркер прочитает очередь один раз и закроет её
- Постановка вернёт ошибку: очередь не зарегистрирована
- asynq автоматически добавит очередь в конфиг воркера
- ✅ Задачи лягут в очередь, но воркер их не заберёт: они вечно pending

Воркер обрабатывает только очереди из Config.Queues — карта «имя: вес» собрана под конкретный тест, и всё, что в неё не вошло, он не читает. Постановка при этом проходит успешно: клиенту всё равно, куда легла задача. Отсюда правило изоляции: имя тестовой очереди появляется в двух местах — в asynq.Queue при постановке и в Queues конфига воркера.

### Что вернёт GetTaskInfo после обработки

```go
// Воркер успешно обработал задачу и удалил её из очереди.
// Retention не настроен — выполненные задачи не хранятся.

ti, err := inspector.GetTaskInfo("billing-it", info.ID)
```

**Что выведет программа?**

- TaskStateCompleted: задача сохранилась после обработки
- ✅ Ошибку ErrTaskNotFound: выполненная задача удалена из Redis
- Задачу с пустым State: досье есть, состояние обнулено
- Панику Inspector: такого ID больше не существует

Без Retention asynq удаляет задачу сразу после успешной обработки, поэтому GetTaskInfo вернёт ошибку с ErrTaskNotFound — её проверяют errors.Is и трактуют как «выполнена, возможно уже удалена». Сентинелл — не экзотика, а часть контракта API: отсутствующая задача и несуществующая очередь — ожидаемые ответы, а не сбои инфраструктуры.

### Юнит-моки против 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 или стенд |

### Соберите тест постановки

Восстановите тест: клиент, задача, опция очереди и проверка досье.

```go
client := asynq.___(redisOpt)
defer client.Close()

task := asynq.___(tasks.TypeCharge, payload)
info, err := client.Enqueue(task, asynq.___(queue))

inspector := asynq.NewInspector(redisOpt)
ti, err := inspector.___(queue, info.ID)
```

Подсказка: Клиент строит NewClient, задачу создаёт NewTask, имя очереди передаётся опцией Queue, а досье по ID достаёт GetTaskInfo.

Правильные ответы в пропусках: `NewClient`, `NewTask`, `Queue`, `GetTaskInfo`

### Соберите хронологию пакета тестов

Расставьте шаги пакета: от запуска контейнера до остановки воркера.

Правильный порядок строк:

1. `TestMain поднимает Redis: tcredis.Run с образом`
2. `defer testcontainers.TerminateContainer регистрирует уборку`
3. `ConnectionString отдаёт redis://адрес маппированного порта`
4. `RedisClientOpt собирается один на клиент, воркер и Inspector`
5. `клиент ставит задачу: Enqueue с asynq.Queue имени теста`
6. `Inspector.GetTaskInfo подтверждает задачу в очереди`
7. `воркер на ServeMux забирает задачу и зовёт хендлер`
8. `srv.Shutdown в Cleanup останавливает воркер, тест завершается`

Каркас пакета собирается один раз и живёт долго: TestMain с контейнером, общий RedisClientOpt, тест постановки на каждый шаблон, один сквозной тест воркера — дальше только добавляются типы задач и опции. Как вписать такой TestMain в общий пакет с базой и кэшем, чтобы контейнеры стартовали один раз на все наборы, — разбор в [уроке про Testify и TestMain](/courses/testcontainers/lessons/19-testify-suite/); там же хелпер CleanupContainer, которым удобно убирать контейнер Redis, не пиша defer руками. Утверждения в тестах аккуратно писать [testify](/courses/testify/): require для остановки при фатальной ошибке, assert для досье задачи. Когда прогон разрастётся и контейнеров станет много, вернитесь к теме переиспользования: один Redis на пакет дешевле, чем по контейнеру на каждый тест, — но тогда изоляция ложится целиком на имена очередей, и держать их уникальными становится не правилом хорошего тона, а обязательством. Если тесты этого пакета падают чаще прочих, начинайте диагностику с каркаса: поднимется ли контейнер, отдался ли адрес, дошло ли досье — половина проблем ищется до всякой бизнес-логики.

### Типовые ошибки тестов фоновых задач

- проверка связи в конструкторе NewClient: соединение ленивое — ловите ошибку на Enqueue
- очередь теста не включена в Queues конфига воркера: задачи вечно pending без единой ошибки
- одно имя очереди на все тесты: параллельный прогон подмешивает чужие задачи
- sleep вместо канала с таймаутом: тест флейкает на нагруженной машине
- GetTaskInfo без errors.Is(ErrTaskNotFound): тест падает на уже выполненных задачах
- забытый srv.Shutdown в t.Cleanup: воркер переживает тест и забирает чужие задачи
- моки вместо Redis: тест не видит ни постановку, ни состояние, ни ретраи
- запуск набора без демона Docker: testcontainers падает на старте — проверьте окружение

> **Про песочницу: нужен живой Docker.** Честная оговорка: в песочнице сайта этот урок не запустится. Коду нужен живой демон Docker: testcontainers поднимает контейнер Redis, а asynq и Inspector ходят в него по сети. Рабочая схема локальная: сохраните тесты в файл *_test.go рядом с go.mod и запустите go test — Docker должен быть запущен, образ Redis подтянется при первом прогоне. В CI код проходит без изменений, если в раннере есть Docker, — об этом был двадцатый урок. Отсутствие Docker чтению не мешает: весь код разобран построчно. Домашний эксперимент: docker ps до и после прогона — контейнер Redis обязан исчезнуть.

### Карточки: asynq на Redis

- **RedisClientOpt** — Значение подключения asynq: главное поле Addr вида host:port. Один и тот же opt принимают NewClient, NewServer и NewInspector — конфигурация задаётся один раз и не разъезжается между клиентом и воркером.
- **Enqueue** — Постановка задачи настоящим клиентом: возвращает TaskInfo с ID и ошибку. Адрес Redis здесь уже настоящий — от контейнера testcontainers, поэтому ошибка постановки честная, а не выдуманная стабом.
- **Inspector.GetTaskInfo** — Досье задачи по имени очереди и ID: State, Retried, LastErr, NextProcessAt. Для выполненной и удалённой задачи вернёт ErrTaskNotFound — проверяйте через errors.Is и трактуйте как норму.
- **ServeMux** — Маршрутизатор asynq: связывает строку типа задачи с хендлером. HandleFunc — для функций, Handle — для структур с зависимостями. Готовый mux передаётся в srv.Run.
- **asynq.Queue** — Опция постановки: имя очереди. Уникальное имя на тест не даёт задачам перемешиваться, но воркер читает только очереди из Config.Queues — имя должно появиться в обоих местах.
- **srv.Shutdown** — Штатная остановка воркера: дожидается активных задач и закрывает сервер. Регистрируется в t.Cleanup, иначе горутина с Run переживёт тест и будет молча забирать задачи.

## Коротко: что запомнить

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