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

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

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

Урок 21 из 25 ~18 мин +50 XP

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

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

Содержание урока · 10 разделов

Обработчики 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

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

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

Изоляция очередей между тестами#

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()
Хендлер со стабамивзаимодействия с зависимостями через интерфейсыфейки вместо БД и сети
Постановка на живой RedisEnqueue, опции, состояние задачи в очередиRedis в контейнере testcontainers
Воркер на ServeMuxмаршрутизацию типа задачи и путь до хендлераRedis в контейнере + srv.Run
Прогон средысовместимость клиента и воркеров на одном Redisdocker compose или стенд

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

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

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)

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

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

Строки

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

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

Каркас пакета собирается один раз и живёт долго: 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 падает на старте — проверьте окружение

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

Термин

RedisClientOpt

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

Значение подключения asynq: главное поле Addr вида host:port. Один и тот же opt принимают NewClient, NewServer и NewInspector — конфигурация задаётся один раз и не разъезжается между клиентом и воркером.

1 / 6

Песочница урока

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

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Тесты фоновых задач: asynq на Redis)

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

Почему бы не замокировать Client и не проверить постановку юнит-тестом?

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

Нужен ли тесту живой воркер — не хватит ли одного Inspector?

Зависит от утверждения. Состояние задачи после постановки видно и без воркера: GetTaskInfo читает Redis напрямую, и его досье существует независимо от того, кто заберёт задачу. Но маршрут «тип задачи — хендлер» и согласованность Queues проверяет только живой воркер: задача ушла, хендлер вызван. Ровно один такой сквозной тест на пакет закрывает класс ошибок «клиент и воркер разъехались». Всё остальное — состояния, ретраи, сроки — надёжнее проверять Inspector-ом: меньше горутин, меньше времени, меньше флейка.

Один Redis на пакет или контейнер на каждый тест?

Контейнер на тест — самая сильная изоляция, но и самая дорогая: секунды старта на каждый тест и рой контейнеров при параллельности. Один Redis на пакет через TestMain с уборкой в конце — компромисс по умолчанию: изоляцию дают уникальные очереди, а скорость остаётся приличной. Если тесты трогают Redis вне очередей — пишут ключи напрямую, — добавляйте чистку между тестами или переходите на контейнер на тест. Выбор делайте измерением, а не модой: главное, чтобы изоляция не зависела от везения.

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

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

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

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

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах