Тестирование асинхронного кода
Написал тест, который вызывает асинхронную функцию и забывает её дождаться. Тест ЗЕЛЁНЫЙ. Он не запустил цикл событий, не выполнил ни строчки проверяемой функции и ничего не проверил - но формально прошёл. Рядом честный тест с запуском цикла проверяет настоящий результат и тоже зелёный. Отличить их по отчёту невозможно.
Стержень: асинхронный тест требует явного запуска цикла событий, иначе он молча проходит, ничего не проверив.
// Формулировки: «как тестировать async-код?», «почему асинхронный тест зелёный, хотя код сломан?», «как подменять асинхронные зависимости?»
Главная ловушка
Вызов асинхронной функции возвращает объект корутины и не выполняет ничего. Если в тесте нет ни запуска цикла, ни специального оформления, проверка вида «результат не None» пройдёт: объект корутины действительно не None.
Мой замер показывает именно это: два теста, один настоящий, второй пустой, оба зелёные. При этом обычный запуск даже не выдаёт предупреждения, если корутину аккуратно закрыть, - а без закрытия в лог прилетает «корутина никогда не ожидалась», и вот его-то и стоит превращать в ошибку.
// Лечится тремя вещами. Первая: расширение для асинхронных тестов, где тест помечается и запускается в цикле автоматически. Вторая: строгий режим этого расширения, при котором асинхронный тест без пометки не пропускается молча, а падает. Третья: настройка, превращающая предупреждения в ошибки, - тогда «корутина никогда не ожидалась» останавливает набор.
- пустой асинхронный тест
- не запустил цикл, ничего не выполнил, но формально прошёл
- строгий режим
- асинхронный тест без пометки падает, а не пропускается молча
Цикл событий и фикстуры
У асинхронных тестов есть своя особенность: цикл событий тоже фикстура, и у неё есть область жизни. По умолчанию цикл создаётся на каждый тест, и это правильно для изоляции.
Проблема появляется, когда долгоживущая фикстура (например, пул соединений к базе) создана в одном цикле, а тест работает в другом: соединения оказываются привязаны к закрытому циклу, и тесты падают с невнятными ошибками. Лечение - согласовать области жизни: если пул на всю сессию, то и цикл должен быть на сессию.
// Второй типовой источник боли - фоновые задачи. Если тест запустил задачу и не дождался её, цикл закроется, а задача останется незавершённой; в лучшем случае в логе будет предупреждение, в худшем - плавающее падение соседнего теста. Правило то же, что и в рабочем коде: задачи дожидаются или отменяют явно.
- область жизни цикла
- цикл событий - тоже фикстура; должна совпадать с областью долгоживущих ресурсов
- незавершённая задача
- фоновая задача пережила тест и портит соседние
Подмены и время
Асинхронную зависимость подменяют асинхронным же объектом: обычный мок вернёт не корутину, а обычное значение, и вызывающий код упадёт на попытке его дождаться. Специальный асинхронный мок это решает.
Отдельная тема - время. Тест, который честно ждёт пять секунд, - плохой тест. Вместо этого либо подменяют функцию ожидания, либо строят логику так, чтобы задержка задавалась параметром и в тестах была нулевой. Проверять поведение при задержках всё равно надо, но не реальным ожиданием.
// И полезная привычка: ставить предел времени на асинхронные тесты. Зависший тест без предела висит до тайм-аута всего конвейера, а с пределом честно падает и показывает, где именно. Это особенно ценно, потому что зависание в асинхронном коде - самый частый вид поломки: кто-то не отпустил замок или ждёт события, которое никто не пошлёт.
- асинхронный мок
- подмена, возвращающая корутину; обычный мок ломает вызывающий код
- предел времени теста
- зависший тест падает сам, а не вешает весь конвейер
Как отвечать: «Почему асинхронный тест может быть зелёным, хотя ничего не проверил?»
Потому что вызов асинхронной функции возвращает объект корутины и не выполняет ничего. Если тест не запустил цикл событий, то проверка вида «результат не None» пройдёт: объект корутины действительно не None. Я специально ставил такой опыт - два теста, один настоящий, второй пустой, оба зелёные, и по отчёту их не отличить. Защищаюсь тремя вещами. Расширение для асинхронных тестов в строгом режиме: тест без пометки не пропускается молча, а падает. Настройка, которая превращает предупреждения в ошибки, - тогда «корутина никогда не ожидалась» останавливает набор. И предел времени на каждый асинхронный тест, потому что зависание - самый частый вид поломки в таком коде. Отдельно слежу за областями жизни: цикл событий тоже фикстура, и если пул соединений создан в одном цикле, а тест идёт в другом, будут падения с невнятными ошибками.
Ответ объясняет механику молчаливого прохода и даёт три конкретные защиты. Замечание про области жизни цикла - то, обо что спотыкаются при первой попытке тестировать асинхронный сервис.
На чём валятся
- −Пишут асинхронный тест без запуска цикла и получают зелёный тест, который ничего не проверил.
- −Игнорируют предупреждение «корутина никогда не ожидалась».
- −Подменяют асинхронную зависимость обычным моком, и код падает на ожидании.
- −Создают пул соединений в одном цикле событий, а тесты гоняют в другом.
- −Ждут в тестах реальные секунды вместо подмены задержки.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- В тесте нужно замокать async-метод (await client.fetch()). Какой мок использовать?A)Обычный MagicMock — он одинаково хорошо подходит и для синхронных, и для async-вызововB)Функцию-заглушку, обязательно синхронную, — тогда await по ней отработает как надоC)AsyncMock: его вызов возвращает awaitable, который можно awaitD)Никакой мок не годится: асинхронные методы подменять в тестах не выйдет
показать ответ и разбор
+C)AsyncMock: его вызов возвращает awaitable, который можно await// разбор: Замена корутины обычным MagicMock ломает await: вызов вернёт мок-объект, а не awaitable, и await по нему упадёт. AsyncMock (из unittest.mock, Python 3.8+) при вызове возвращает awaitable, разрешающийся в return_value, и поддерживает await. side_effect у него тоже можно сделать корутиной. Для async-кода в тестах подменяют именно AsyncMock.
- Тесту нужна 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-сессию/клиент переиспользуют чисто и с корректной очисткой.
- Async-тест неожиданно медленный: внутри стоит time.sleep(1). В чём подвох?A)time.sleep в async-тесте вызовет исключение, ведь его не вызвать без awaitB)Проблема лишь в том, что секунда — слишком большая задержка; хватило бы миллисекундC)Ничего страшного: в тестах блокирующий sleep безопасен и на цикл событий не влияетD)time.sleep блокирует цикл теста; в async ждут через await asyncio.sleep
показать ответ и разбор
+D)time.sleep блокирует цикл теста; в async ждут через await asyncio.sleep// разбор: Тот же принцип, что и в проде: time.sleep — синхронная блокировка потока цикла событий, и в async-тесте она морозит loop, мешая любым конкурентным операциям. Если пауза нужна — await asyncio.sleep. А вообще детерминированные тесты стараются не строить на реальных задержках: время и таймауты лучше мокать/сдвигать, а не спать по-настоящему.
- Async-тесты то падают, то проходят, а между ними течёт состояние. Частая причина?A)Виноват сам pytest-asyncio: он случайно меняет порядок тестов при каждом прогонеB)Async-тесты нестабильны по своей природе, и с этим ничего не поделатьC)Проблема только в слишком коротких таймаутах, их достаточно везде увеличить вдвоеD)Общие ресурсы/один event loop без изоляции — тесты влияют друг на друга
показать ответ и разбор
+D)Общие ресурсы/один event loop без изоляции — тесты влияют друг на друга// разбор: Флакающие async-тесты чаще всего страдают от разделяемого состояния: незакрытые соединения/задачи, общий клиент или сессия без сброса, глобальные переменные, зависимость от порядка. Лечение — изоляция: свежие фикстуры на тест, закрытие ресурсов в teardown, отсутствие висящих фоновых задач, детерминированное время. Увеличение таймаутов и ретраи лишь прячут причину.
- Синхронный TestClient (Starlette) и httpx.AsyncClient для тестов — когда что?A)TestClient — из синхронного теста; AsyncClient — из async-теста, где нужен awaitB)Разницы нет: оба клиента идентичны и взаимозаменяемы в тестах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; выбор диктует стиль теста.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.