Це просто чудова тема! Вона знаходиться на перетині асинхронності, архітектури та надійності — саме те, що люблять розбирати на CS50.
Вмикайте свої редактори коду, наливайте каву, і поїхали! 🚀
🧠 Урок: Тестування Celery-задач
(або: Як перевірити відкладене, не чекаючи вічність)
1. 🔥 Вступ: Проблема та мотивація
Уявіть, що ви будуєте новий Amazon. Користувач натискає кнопку «Купити», і ви хочете відправити йому email-підтвердження. Але відправка email — це довго. Секунда, дві, іноді п'ять. Ви не хочете, щоб користувач дивився на кружечок завантаження, правда?
Ви вирішуєте: "О! Я використаю Celery!" Ви кидаєте задачу в чергу («відправити лист»), а користувачу миттєво кажете: «Дякуємо, все буде!».
Але тут виникає проблема, коли ви сідаєте писати тести.
Ви запускаєте pytest, і... тиша. Або тест проходить миттєво, але лист не прийшов. Або ще гірше — під час тестів ви випадково відправляєте 1000 реальних листів на свою тестову пошту (або, борони Боже, реальним клієнтам).
Питання до вас: Як нам протестувати логіку задачі, яка створена для того, щоб запускатися десь там, колись потім і іншим процесом?
Чи потрібно нам піднімати реальний Redis та запускати окремий worker-процес для кожного юніт-тесту? Це звучить як жах для швидкості розробки, чи не так?
Сьогодні ми навчимося "зупиняти час" і змушувати Celery грати за нашими правилами.
2. 🧠 Теоретична база (без сухої академічності)
Щоб зрозуміти, як це тестувати, згадаймо, як працює Celery.
У нас є три гравці: 1. Producer (Ваш код): Каже "Зроби це". 2. Broker (Redis/RabbitMQ): Поштова скринька, де лежить задача. 3. Worker (Celery процес): Той, хто забирає задачу і виконує її.
У звичайному житті вони розділені. Але в тестах нам часто це заважає.
🔑 Два головні підходи до тестування:
-
Режим "Eager" (Нетерплячий режим). Це — магія. Ми кажемо Celery: "Забудь про черги. Забудь про воркерів. Як тільки я викликаю
.delay(), просто візьми і виконай цю функцію прямо тут і зараз, синхронно".- Аналогія: Замість того, щоб кидати лист у поштову скриньку і чекати листоношу, ви самі берете лист, біжите за адресою і вручаєте його.
- Навіщо: Щоб перевірити логіку всередині задачі.
-
Mocking (Імітація). Ми взагалі не виконуємо задачу. Ми просто перевіряємо, чи спробував наш код викликати цю задачу з правильними аргументами.
- Аналогія: Ви перевіряєте, чи правильно написана адреса на конверті, але нікуди його не несете.
💡 Що треба запам'ятати залізно:
Unit-тести не повинні залежати від зовнішніх сервісів (Redis, RabbitMQ). Вони мають бути швидкими та ізольованими.
3. 🧪 Приклади (від простого до реального)
Давайте подивимось на код. Припустимо, у нас є проста задача в tasks.py:
# tasks.py
from celery import shared_task
@shared_task
def add(x, y):
return x + y
Приклад 1: Наївна спроба (Провал)
# test_tasks.py
from tasks import add
def test_add_naive():
result = add.delay(4, 4)
# Що тут буде?
assert result.get() == 8
Чому це погано? Якщо у вас не запущений локальний Redis і Celery worker під час тесту, цей код "зависне" або впаде з помилкою з’єднання. Тест залежить від інфраструктури. Це — поганий тон.
Приклад 2: Використання CELERY_TASK_ALWAYS_EAGER (Успіх)
Ми змушуємо Celery виконувати все синхронно.
# test_tasks_eager.py
import pytest
from tasks import add
# У Django це робиться через override_settings,
# у чистому Celery — через конфігурацію app.conf.task_always_eager = True
def test_add_eager(celery_app, celery_worker):
# Вмикаємо "нетерплячий" режим
celery_app.conf.update(task_always_eager=True)
# Викликаємо .delay(), але код виконується миттєво!
result = add.delay(4, 4)
# У Eager-режимі result.get() повертає результат відразу
assert result.get() == 8
Тепер це звичайний виклик функції, просто трохи обгорнутий.
Приклад 3: Реальний кейс (Mocking)
Уявіть, що у вас є функція реєстрації, яка викликає задачу.
# services.py
from tasks import send_welcome_email
def register_user(email):
# ... логіка створення юзера в БД ...
send_welcome_email.delay(email)
return "User created"
Ми хочемо перевірити register_user. Нам байдуже, чи працює send_welcome_email (ми перевіримо її окремо). Нам важливо, чи викликали ми її.
# test_services.py
from unittest.mock import patch
from services import register_user
@patch('services.send_welcome_email.delay')
def test_register_triggers_email(mock_task):
email = "david@cs50.harvard.edu"
register_user(email)
# Перевіряємо: "Чи викликали ми задачу з цим email?"
mock_task.assert_called_once_with(email)
Результат: Миттєвий тест, жодних Redis, повна впевненість, що потік управління правильний.
4. 🛠 Практична частина
А тепер ваша черга! Відкривайте IDE або блокнот.
Завдання 1: Класика
Напишіть тест для задачі multiply(x, y), використовуючи режим task_always_eager. Переконайтеся, що multiply.delay(3, 3) повертає 9.
Завдання 2: Обробка помилок
Створіть задачу divide(x, y). Напишіть тест, який передає y=0.
Питання: Як поводиться тест у Eager-режимі? Чи викидає він ZeroDivisionError прямо в тесті? (Спробуйте і побачите, що так!). Огорніть це в pytest.raises.
Завдання 3: "Шпигун" (Spy)
У вас є задача, яка нічого не повертає, але робить print("Hello").
Використайте unittest.mock.patch на builtins.print і запустіть задачу в Eager-режимі. Перевірте, чи був викликаний print.
Завдання 4: Реальний виклик (Міні-кейс)
Задача: process_order(order_id). Вона має:
1. Знайти замовлення в БД (можна замокати).
2. Якщо статус "Paid" — нічого не робити.
3. Якщо "New" — змінити на "Processing".
Завдання: Напишіть тест, який перевіряє логіку зміни статусу, запустивши задачу синхронно (без .delay(), просто викликавши функцію як process_order(1) — так, так теж можна тестувати задачі!).
Завдання 5: А що, якщо... Retry?
Задача налаштована на повтор (autoretry_for=(ConnectionError,)).
Спробуйте написати тест, де ви мокаєте внутрішній виклик так, щоб він викликав ConnectionError. Перевірте, чи Celery (в eager режимі) намагається повторити задачу або викидає Retry exception.
5. 💡 Мислення як у розробника
Ось де новачки часто помиляються, а сеньйори — посміхаються.
1. Пастка "Інтеграційного пекла"
Новачок намагається підняти Docker-контейнери з Redis для кожного прогону тестів.
Як думає профі: "Я довіряю розробникам Redis і Celery, що їхній софт працює. Мені треба перевірити мій код. task_always_eager покриває 95% моїх потреб. Для решти 5% я запущу один великий end-to-end тест перед релізом".
2. Пастка аргументів
Celery серіалізує дані (перетворює в JSON/Pickle). Помилка: Передавати в задачу складний об'єкт (наприклад, об'єкт моделі Django або з'єднання з БД). У тестах в Eager-режимі це спрацює! А на проді — впаде, бо JSON не вміє зберігати об'єкти Python. Порада: Завжди передавайте в задачі лише прості ID (int, string). В тесті теж передавайте ID.
3. Тестування .delay() vs тестування функції
Ви можете імпортувати задачу і викликати її без .delay() як звичайну функцію: my_task(arg). Це абсолютно нормально для перевірки внутрішньої "кухні" задачі.
6. 🧩 Підсумок
Отже, що ми сьогодні зробили? Ми взяли асинхронного звіра Celery і навчили його ходити по струнці.
Тепер ви вмієте:
1. Тестувати логіку задач миттєво за допомогою task_always_eager (синхронний режим).
2. Використовувати mocks, щоб перевірити сам факт відправки задачі, не виконуючи її.
3. Розумієте різницю між перевіркою інфраструктури (чи працює черга) і бізнес-логіки (чи правильно рахує задача).
Ти справді розібрався з цим! Тепер твої тести не будуть відправляти спам клієнтам, а CI/CD проходитиме за хвилини, а не години.
Далі в курсі: Ми навчилися запускати задачі. Але що робити, коли задач стає мільйон, і Redis починає "захлинатися"? Наступного разу поговоримо про Моніторинг черг та Flower. Готуйтеся, будемо дивитися на графіки! 📈
А поки що — щасливого кодингу!