сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Тесты в Python

Тестирование асинхронного кода

Тестирование асинхронного кода

Написал тест, который вызывает асинхронную функцию и забывает её дождаться. Тест ЗЕЛЁНЫЙ. Он не запустил цикл событий, не выполнил ни строчки проверяемой функции и ничего не проверил - но формально прошёл. Рядом честный тест с запуском цикла проверяет настоящий результат и тоже зелёный. Отличить их по отчёту невозможно.

Стержень: асинхронный тест требует явного запуска цикла событий, иначе он молча проходит, ничего не проверив.

// Формулировки: «как тестировать async-код?», «почему асинхронный тест зелёный, хотя код сломан?», «как подменять асинхронные зависимости?»

Главная ловушка

Вызов асинхронной функции возвращает объект корутины и не выполняет ничего. Если в тесте нет ни запуска цикла, ни специального оформления, проверка вида «результат не None» пройдёт: объект корутины действительно не None.

Мой замер показывает именно это: два теста, один настоящий, второй пустой, оба зелёные. При этом обычный запуск даже не выдаёт предупреждения, если корутину аккуратно закрыть, - а без закрытия в лог прилетает «корутина никогда не ожидалась», и вот его-то и стоит превращать в ошибку.

// Лечится тремя вещами. Первая: расширение для асинхронных тестов, где тест помечается и запускается в цикле автоматически. Вторая: строгий режим этого расширения, при котором асинхронный тест без пометки не пропускается молча, а падает. Третья: настройка, превращающая предупреждения в ошибки, - тогда «корутина никогда не ожидалась» останавливает набор.

пустой асинхронный тест
не запустил цикл, ничего не выполнил, но формально прошёл
строгий режим
асинхронный тест без пометки падает, а не пропускается молча

Цикл событий и фикстуры

У асинхронных тестов есть своя особенность: цикл событий тоже фикстура, и у неё есть область жизни. По умолчанию цикл создаётся на каждый тест, и это правильно для изоляции.

Проблема появляется, когда долгоживущая фикстура (например, пул соединений к базе) создана в одном цикле, а тест работает в другом: соединения оказываются привязаны к закрытому циклу, и тесты падают с невнятными ошибками. Лечение - согласовать области жизни: если пул на всю сессию, то и цикл должен быть на сессию.

// Второй типовой источник боли - фоновые задачи. Если тест запустил задачу и не дождался её, цикл закроется, а задача останется незавершённой; в лучшем случае в логе будет предупреждение, в худшем - плавающее падение соседнего теста. Правило то же, что и в рабочем коде: задачи дожидаются или отменяют явно.

область жизни цикла
цикл событий - тоже фикстура; должна совпадать с областью долгоживущих ресурсов
незавершённая задача
фоновая задача пережила тест и портит соседние

Подмены и время

Асинхронную зависимость подменяют асинхронным же объектом: обычный мок вернёт не корутину, а обычное значение, и вызывающий код упадёт на попытке его дождаться. Специальный асинхронный мок это решает.

Отдельная тема - время. Тест, который честно ждёт пять секунд, - плохой тест. Вместо этого либо подменяют функцию ожидания, либо строят логику так, чтобы задержка задавалась параметром и в тестах была нулевой. Проверять поведение при задержках всё равно надо, но не реальным ожиданием.

// И полезная привычка: ставить предел времени на асинхронные тесты. Зависший тест без предела висит до тайм-аута всего конвейера, а с пределом честно падает и показывает, где именно. Это особенно ценно, потому что зависание в асинхронном коде - самый частый вид поломки: кто-то не отпустил замок или ждёт события, которое никто не пошлёт.

асинхронный мок
подмена, возвращающая корутину; обычный мок ломает вызывающий код
предел времени теста
зависший тест падает сам, а не вешает весь конвейер

Как отвечать: «Почему асинхронный тест может быть зелёным, хотя ничего не проверил?»

Потому что вызов асинхронной функции возвращает объект корутины и не выполняет ничего. Если тест не запустил цикл событий, то проверка вида «результат не None» пройдёт: объект корутины действительно не None. Я специально ставил такой опыт - два теста, один настоящий, второй пустой, оба зелёные, и по отчёту их не отличить. Защищаюсь тремя вещами. Расширение для асинхронных тестов в строгом режиме: тест без пометки не пропускается молча, а падает. Настройка, которая превращает предупреждения в ошибки, - тогда «корутина никогда не ожидалась» останавливает набор. И предел времени на каждый асинхронный тест, потому что зависание - самый частый вид поломки в таком коде. Отдельно слежу за областями жизни: цикл событий тоже фикстура, и если пул соединений создан в одном цикле, а тест идёт в другом, будут падения с невнятными ошибками.

Ответ объясняет механику молчаливого прохода и даёт три конкретные защиты. Замечание про области жизни цикла - то, обо что спотыкаются при первой попытке тестировать асинхронный сервис.

На чём валятся

  • Пишут асинхронный тест без запуска цикла и получают зелёный тест, который ничего не проверил.
  • Игнорируют предупреждение «корутина никогда не ожидалась».
  • Подменяют асинхронную зависимость обычным моком, и код падает на ожидании.
  • Создают пул соединений в одном цикле событий, а тесты гоняют в другом.
  • Ждут в тестах реальные секунды вместо подмены задержки.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #async_testing1 / 5
    В тесте нужно замокать async-метод (await client.fetch()). Какой мок использовать?
    A)Обычный MagicMock — он одинаково хорошо подходит и для синхронных, и для async-вызовов
    B)Функцию-заглушку, обязательно синхронную, — тогда await по ней отработает как надо
    C)AsyncMock: его вызов возвращает awaitable, который можно await
    D)Никакой мок не годится: асинхронные методы подменять в тестах не выйдет
    показать ответ и разбор
    +C)AsyncMock: его вызов возвращает awaitable, который можно await

    // разбор: Замена корутины обычным MagicMock ломает await: вызов вернёт мок-объект, а не awaitable, и await по нему упадёт. AsyncMock (из unittest.mock, Python 3.8+) при вызове возвращает awaitable, разрешающийся в return_value, и поддерживает await. side_effect у него тоже можно сделать корутиной. Для async-кода в тестах подменяют именно AsyncMock.

  2. #async_testing2 / 5
    Тесту нужна async-фикстура (открыть AsyncSession, потом закрыть). Как её оформить?
    A)Никак: асинхронные ресурсы каждый тест обязан создавать сам в своём теле напрямую
    B)Обычной фикстурой, внутри которой ресурс открывают через asyncio.run на каждый вызов
    C)Только синхронной фикстурой: асинхронную подготовку ресурсов в фикстурах не сделать
    D)async-фикстурой с async with/await и yield для teardown
    показать ответ и разбор
    +D)async-фикстурой с async with/await и yield для teardown

    // разбор: pytest-asyncio (и anyio) поддерживают асинхронные фикстуры: их объявляют async, внутри делают await для подготовки (открыть сессию/клиент), отдают значение через yield, а после yield — async-teardown (закрыть, откатить). Они исполняются в том же событийном цикле, что и async-тесты, поэтому ресурс и тест разделяют один loop. Так async-сессию/клиент переиспользуют чисто и с корректной очисткой.

  3. #async_testing3 / 5
    Async-тест неожиданно медленный: внутри стоит time.sleep(1). В чём подвох?
    A)time.sleep в async-тесте вызовет исключение, ведь его не вызвать без await
    B)Проблема лишь в том, что секунда — слишком большая задержка; хватило бы миллисекунд
    C)Ничего страшного: в тестах блокирующий sleep безопасен и на цикл событий не влияет
    D)time.sleep блокирует цикл теста; в async ждут через await asyncio.sleep
    показать ответ и разбор
    +D)time.sleep блокирует цикл теста; в async ждут через await asyncio.sleep

    // разбор: Тот же принцип, что и в проде: time.sleep — синхронная блокировка потока цикла событий, и в async-тесте она морозит loop, мешая любым конкурентным операциям. Если пауза нужна — await asyncio.sleep. А вообще детерминированные тесты стараются не строить на реальных задержках: время и таймауты лучше мокать/сдвигать, а не спать по-настоящему.

  4. #async_testing4 / 5
    Async-тесты то падают, то проходят, а между ними течёт состояние. Частая причина?
    A)Виноват сам pytest-asyncio: он случайно меняет порядок тестов при каждом прогоне
    B)Async-тесты нестабильны по своей природе, и с этим ничего не поделать
    C)Проблема только в слишком коротких таймаутах, их достаточно везде увеличить вдвое
    D)Общие ресурсы/один event loop без изоляции — тесты влияют друг на друга
    показать ответ и разбор
    +D)Общие ресурсы/один event loop без изоляции — тесты влияют друг на друга

    // разбор: Флакающие async-тесты чаще всего страдают от разделяемого состояния: незакрытые соединения/задачи, общий клиент или сессия без сброса, глобальные переменные, зависимость от порядка. Лечение — изоляция: свежие фикстуры на тест, закрытие ресурсов в teardown, отсутствие висящих фоновых задач, детерминированное время. Увеличение таймаутов и ретраи лишь прячут причину.

  5. #async_testing5 / 5
    Синхронный TestClient (Starlette) и httpx.AsyncClient для тестов — когда что?
    A)TestClient — из синхронного теста; AsyncClient — из async-теста, где нужен await
    B)Разницы нет: оба клиента идентичны и взаимозаменяемы в тестах
    C)Только AsyncClient: синхронный TestClient устарел и в тестах давно не применяется
    D)Только TestClient: httpx.AsyncClient для тестирования приложений не предназначен
    показать ответ и разбор
    +A)TestClient — из синхронного теста; AsyncClient — из async-теста, где нужен await

    // разбор: Starlette/FastAPI TestClient синхронный (внутри сам крутит цикл) — удобен в обычных синхронных тестах: client.get(...) без await. httpx.AsyncClient с ASGITransport — асинхронный, его берут в async-тестах, где по ходу нужно делать await (например, вперемешку с обращениями к async-сессии БД в том же цикле). Оба ходят в приложение in-process; выбор диктует стиль теста.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.