Дисклеймер. Это не «единственно верный путь», а кейс одного конкретного проекта: бэкенд на Litestar + SQLAlchemy + Postgres + Redis, 3316 тестов, почти все интеграционные, ходят в настоящую БД. Все замеры локальные, на моём ноутбуке — не в CI. Раннеров у нас несколько и они разные по железу, поэтому абсолютные секунды у вас будут другими; интересно, во сколько раз стало быстрее и за счёт чего. Скрипты для воспроизведения — в конце статьи.
Есть устойчивое поверье: интеграционные тесты медленные, потому что ходят в настоящую базу. «Подними SQLite в памяти», «замокай репозитории», «не гоняй Postgres в CI» — стандартный набор советов. Мокать я не люблю и уже писал почему, но крыть упрёк «настоящая база — это медленно» было нечем. Поэтому я сел, спрофилировал и померил. Оказалось, что почти всё время съедала одна правка про фикстуры. А народный совет «чисти базу через TRUNCATE, это быстрее DELETE» у меня работал ровно наоборот — что обидно вдвойне: эта рекомендация уже лежала в черновике моей следующей статьи про pytest. Пришлось переписывать черновик.
Сьют шёл ≈30 минут (1795 с). Стал — меньше двух минут (109 с). Без переписывания тестов, тремя правками инфраструктуры. Ниже — как я искал узкие места и сколько дала каждая правка.
Если узнаёте свой проект хотя бы в одном пункте — дальше будет полезно:
-
async-фикстуры (engine, redis, приложение) объявлены без
scope=— пересоздаются на каждый тест; -
в тестах используется боевой хеш пароля (argon2/bcrypt) с продакшен-параметрами;
-
между тестами база чистится через
DELETE/TRUNCATE, и есть подозрение, что «вот это и тормозит»; -
локальный прогон занимает минуты (а то и десятки), в CI — кратно больше, и точного ответа «почему» нет.
Отправная точка: 3316 тестов и полчаса ожидания
Тесты на проекте честные: интеграционные, ходят в реальные Postgres и Redis через настоящий ASGI-сервер.
Пока их немного, всё идёт быстро: локально мгновенно, в CI чуть медленнее. Терпимо. Можно даже с лёгкой иронией поглядывать на тех, у кого «тесты по полчаса». Потом прогон занимает 5 минут. Потом 10. Потом 15 — ну так тестов же стало больше. Уходишь на другой проект, возвращаешься через пару недель — полчаса. 1795 секунд на 3316 тестов.
Локально это боль. В CI хуже: каждый пуш стоит около часа.
Чинить наугад дорого — можно неделю переписывать тесты, которые в сумме дают 2 % времени. Сначала надо понять, куда уходит время.
Профилирование: где на самом деле горит
Первый инструмент уже встроен в pytest:
pytest --durations=0 # все длительности; --durations=25 — топ-25
И первый сюрприз: виноватого теста нет. Нет десяти тяжёлых тестов, которые всё портят, — время размазано ровным слоем по всем 3316. Это налог, который платит каждый тест на входе. --durations показывает, какие тесты дольше, но когда дольше примерно все одинаково, он не отвечает на вопрос «почему».
Для «почему» нужен профайлер уровнем ниже:
python -m cProfile -o profile_v0.out -m pytest ./tests -p no:randomly -q
Оговорка: cProfile не лучший друг async-кода — время ожидания в await он размазывает по внутренностям селектора, и для красивых flame-графов больше подходят py-spy или pyinstrument. Мне хватило грубой группировки по собственному времени функций, но знать про это стоит.
Файл профиля — 186 млн вызовов и десятки тысяч строк по функциям (снимал на подвыборке тестов: полный прогон под cProfile в неоптимизированном состоянии невыносимо долгий). Глазами такое не отсматривают — легко зацепиться за случайную строку и придумать узкое место, которого нет. Я скормил profile_v0.out LLM с одной задачей: сгруппировать расходы по причине и отранжировать по совокупному tottime. Тот же результат дали бы snakeviz или gprof2dot плюс пара часов внимательности — мне было лень. Решает в любом случае замер, профайлер только показывает, куда смотреть.
Группировка получилась такая:
|
Группа расходов |
Доля |
|---|---|
|
Ожидание I/O на сокетах (реальные Postgres/Redis) |
64 % |
|
Создание event loop на каждый тест |
5,6 % |
|
Открытие новых соединений ( |
3,7 % |
|
Хеширование паролей ( |
2,3 % |
|
Пересоздание схемы БД ( |
1,2 % |
|
greenlet-мост async↔sync SQLAlchemy |
0,7 % |
Что из этого следует.
Во-первых, сьют I/O-bound: 64 % времени процесс просто ждёт сокеты. CPU почти простаивает — тесты не «считают», они общаются с Postgres и Redis. Это не лечится оптимизацией кода тестов; вопрос в том, сколько раз мы за это общение платим.
Во-вторых, заметная часть — повторный setup на каждом тесте: новый event loop (5,6 %), новые соединения (3,7 %), пересоздание схемы (1,2 %). Ничего не переиспользуется. Причём сами переподключения и CREATE TABLE тоже идут по сокету, то есть раздувают те самые 64 %. Поэтому create_all и не торчит в профиле отдельным столбиком — его цена размазана по I/O.
В-третьих, очистки БД в топе нет. Способ очистки между тестами (на тот момент TRUNCATE) не входил в значимые расходы — на этой, медленной версии. К очистке ещё вернёмся.
Подозреваемый назван: мы пересоздаём весь мир на каждый тест. Доказывать будем замером.
Как мерить, чтобы себе не соврать
Главная ошибка — поменять несколько вещей сразу и радоваться суммарной цифре: так не понять, что сработало, а что плацебо. Правила:
-
применяю улучшения по очереди и замеряю после каждого;
-
-p no:randomly— в проекте стоит pytest-randomly, для поиска зависимостей между тестами перемешивание благо, для замера времени шум; -
несколько прогонов и медиана: первый запуск греет кэши, сосед дёрнул диск — вот вам «регрессия» на ровном месте;
-
откат — скриптом, восстановление —
git checkout. Никаких правок руками, иначе не отличишь эффект от собственной невнимательности; -
всё локально, на одном и том же ноутбуке, при сравнимой фоновой нагрузке. Цифры из CI здесь не фигурируют сознательно: раннеры разные, и сравнивать их секунды между собой бессмысленно.
Условия эксперимента, чтобы было с чем сравнивать:
|
Python |
3.13 |
|
Фреймворк |
|
|
БД |
PostgreSQL 18 — официальный образ в Docker, настройки по умолчанию; SQLAlchemy 2.0.46 + asyncpg 0.31, миграции Alembic |
|
Кэш / брокер |
Redis 8 и NATS 2.10 (FastStream) — тоже настоящие, в контейнерах |
|
Тесты |
pytest 9.0 + pytest-asyncio 1.3; 3316 тестов через реальный ASGI-сервер |
|
Железо |
MacBook Pro (M4 Max, 36 ГБ); контейнеры в Docker Desktop на macOS — то есть внутри Linux-VM, соединение по TCP на localhost |
Про macOS отдельно: Docker здесь — виртуальная машина, и весь трафик до контейнеров идёт через проброс портов виртуализации. Для I/O-bound сьюта это значит, что абсолютные цифры консервативны — на нативном Linux будет быстрее. Но соотношение шагов от этого не меняется, а интересует нас именно оно.
# benchmark/apply_variant.py — фрагментdef function_scope() -> None: """Вернуть всё к function-scope — состояние 'до оптимизации'.""" replace( PYPROJECT, 'asyncio_default_fixture_loop_scope = "session"', 'asyncio_default_fixture_loop_scope = "function"', ) # ...и так для ВСЕХ session-scoped фикстур в plugins/instances (у меня их 29). # Полумеры не годятся: оставишь хоть одну session-scoped async-фикстуру при # function loop scope — pytest-asyncio падает со ScopeMismatch на каждом тесте.
Правка 1. Session-scope фикстуры и общий event loop
Это и есть «пересоздаём весь мир на каждый тест». Дефолт pytest-asyncio с asyncio_mode = "auto": на каждый тест создаётся новый event loop. А раз новый loop — заново поднимаются подключение к Postgres, пул Redis, инстанс приложения и, в нашем случае, пересоздаётся схема БД. Этот налог платится 3316 раз за прогон:
Лечится одной настройкой и scope="session" на дорогих фикстурах:
# pyproject.toml[tool.pytest.ini_options]asyncio_mode = "auto"asyncio_default_fixture_loop_scope = "session"asyncio_default_test_loop_scope = "session"
@pytest.fixture(scope="session")async def engine(db_config: DatabaseConfig) -> AsyncIterator[AsyncEngine]: # create_engine — наша обёртка над create_async_engine (контекстный менеджер) async with create_engine(dsn=str(db_config.dsn), ...) as engine: async with engine.begin() as conn: await conn.run_sync(BaseTable.metadata.drop_all) await conn.run_sync(BaseTable.metadata.create_all) yield engine # схема создаётся ОДИН раз на сессию
Подвох: session-scope async-фикстуры теперь живут весь прогон, и если в них копится состояние — оно протечёт во все тесты. Изоляцию данных делаем отдельно, очисткой таблиц между тестами, а не пересозданием всего окружения.
Замер: 1795 с (≈30 мин) → 221 с. Минус 88 %, в 8 раз. Ни один тест не переписан — мы просто перестали пересоздавать окружение 3316 раз. По соотношению цены и эффекта это, пожалуй, самая недооценённая оптимизация для async-проектов.
Правка 2. TRUNCATE → DELETE: контринтуитивный сюрприз
Очистка БД между тестами изначально была сделана «как принято» — одним TRUNCATE ... RESTART IDENTITY CASCADE на все таблицы. Один запрос вместо тридцати, логично же. На медленной версии профиль её даже не подсвечивал — она тонула в пересоздании окружения.
Но прогон уже не 30 минут, а три с половиной. Перемеряю — и теперь, когда крупное убрано, очистка стала заметной долей. Пробую заменить один TRUNCATE на пачку DELETE по таблицам в одной транзакции:
# было: один TRUNCATE ... CASCADE на каждый тест# стало: DELETE по таблицам в порядке зависимостей, в одной транзакции_DELETE_STATEMENTS = tuple( text(f'DELETE FROM "{t.name}"') for t in reversed(BaseTable.metadata.sorted_tables))
Замер: 221 с → 125 с (−43 %). Тридцать DELETE оказались заметно быстрее одного TRUNCATE. Мало того, TRUNCATE был ещё и диким по разбросу: от прогона к прогону давал от 152 до 254 секунд (см. CSV в репозитории), тогда как DELETE держался ровно, в пределах нескольких секунд.
Почему так. TRUNCATE — это не «быстрый DELETE», а DDL-операция: она берёт ACCESS EXCLUSIVE блокировку на каждую таблицу, с CASCADE обходит дерево зависимостей, создаёт новые файлы таблиц и обновляет каталог — и так на каждом из 3316 вызовов. Стоимость этого зависит от состояния каталога и файловой системы — отсюда и нестабильность. DELETE из почти пустых таблиц в одной транзакции — это удаление примерно нуля строк: дёшево и предсказуемо.
Важная оговорка: это не универсальное правило «DELETE быстрее TRUNCATE». На таблице с миллионами строк TRUNCATE выиграет с разгромным счётом — он для того и сделан. Мой результат — про конкретный сценарий: очистка почти пустых таблиц, повторяемая тысячи раз подряд. Здесь фиксированные накладные расходы TRUNCATE (блокировки, каталог, файлы) платятся на каждом вызове, а переменная часть DELETE — собственно удаление строк — почти нулевая. Версии и конфигурация стенда — в таблице выше.
Отдельный урок — про итеративность. На медленной версии эта правка не дала бы ничего: её выигрыш утонул бы в получасовом прогоне как шум, профиль его и не видел. Оптимизация меняет то, что важно, поэтому профилировать и мерить надо после каждого крупного шага, а не один раз в начале.
Правка 3. Быстрый argon2 — добиваем остаток
Последнее, что подсветил профиль, — криптография. argon2 намеренно медленный, это его работа: один хеш на боевых параметрах считается десятки миллисекунд, и для продакшена это правильно. Но в тестах криптостойкость не нужна — нужен round-trip «захешировали → проверили», а тестов, которые создают пользователей и логинятся, очень много.
Сбиваем параметры до минимума только для тестов:
# tests/conftest.pyimport passlib.hashpasslib.hash.argon2.default_rounds = 1passlib.hash.argon2.default_memory_cost = 8passlib.hash.argon2.default_parallelism = 1
(Да, passlib давно не развивается — мы в курсе и в проде смотрим на альтернативы; на механику этой оптимизации это не влияет, тот же приём работает с любой обёрткой над argon2/bcrypt.)
Замер: 125 с → 109 с (−13 %). Немного, но это бесплатные секунды на каждом прогоне, одной правкой в conftest.py.
Чего здесь нет — и почему
Три вещи, о которых вы, возможно, уже хотите написать в комментариях.
Транзакция с rollback вместо очистки. Классический паттерн: каждый тест в транзакции, в конце rollback, чистить вообще ничего не надо. Нам он не подходит осознанно: часть тестов проходит полный путь через роуты и хендлеры с честным коммитом — и затем проверяет, что данные действительно легли в БД. Заворачивать всё в одну никогда не коммитящуюся транзакцию означало бы тестировать не то поведение, которое работает в проде. Если у вас таких тестов нет — rollback-паттерн стоит попробовать раньше всего остального.
fsync = off / synchronous_commit = off для тестового Postgres. Известный способ срезать стоимость коммитов, и что он даёт — я знаю не понаслышке: недавно на курсе по оптимизации Postgres делал домашку с нагрузочными тестами на Raspberry Pi (да, изврат, но что вы мне сделаете). На microSD-карте, где каждый коммит честно ждёт записи WAL, отключение fsync подняло пишущий TPC-B в 2,4–2,9 раза, а latency одиночной транзакции упала с 3,7 до 1,55 мс — замеры и графики тут. Но это история про то, что бывает, когда упираешься в диск. Наш сьют после правок 1–3 в диск не упирался, а менять поведение СУБД под тестами не хотелось — пусть тестовая база ведёт себя как боевая. Если же ваши тесты тонут именно в коммитах — это законный кандидат на проверку замером.
Параллельный запуск, pytest-xdist. Самый очевидный кандидат — и он у нас в работе. Там свои грабли: воркерам нужны изолированные БД, брокер и Redis, иначе тесты начинают видеть чужие данные. Когда доведём до стабильного состояния и замерим, будет вторая часть.
Итог
|
Шаг |
Состояние |
Время |
Прирост |
|---|---|---|---|
|
0 |
без оптимизаций: function-scope + боевой argon2 + TRUNCATE |
1795 с (≈30 мин) |
— |
|
1 |
+ session-scope фикстуры и общий loop |
221 с |
−88 % (×8) |
|
2 |
замена TRUNCATE → DELETE |
125 с |
−43 % |
|
3 |
+ быстрый argon2 в тестах |
109 с (≈1,8 мин) |
−13 % |
Из получаса до полутора минут — ×16,5, и львиная доля выигрыша в одной правке про scope фикстур. Самая дорогая вещь в интеграционных тестах — не сама БД, а то, сколько раз за прогон вы заново поднимаете окружение: event loop, подключения, схему. Session-scope превращает «заплати за вход 3316 раз» в «заплати один раз». А способ очистки сначала вообще не виден и выходит на первый план только после того, как крупное убрано — и там «очевидный» TRUNCATE проигрывает обычному DELETE.
Чек-лист, который можно прогнать на своём проекте за вечер:
-
Сначала профиль, потом правки.
--durations→ если виноватого теста нет, у вас ровный налог на вход, и чинить надо scope, а не отдельные тесты. Дальше — cProfile (или py-spy) на медленной версии: на оптимизированной он покажет скучное «ждём сеть» и ничего не подскажет. -
Большой профиль не читайте глазами. Группировка по причинам (snakeviz, gprof2dot, LLM — что вам ближе) → топ кандидатов → проверка замером. Профайлер находит, замер доказывает.
-
Event loop и scope фикстур — первым делом. Для async-проекта
*_loop_scope = "session"иscope="session"на дорогих ресурсах — однострочники с самым большим выигрышем. -
Сбейте параметры криптохеша в тестах. argon2/bcrypt на боевых параметрах — сожжённое время на каждом логине.
-
Меняйте по одной вещи, медиана из нескольких прогонов,
-p no:randomly, одно и то же железо. Иначе любые «ускорения» — шум. -
Прежде чем мокать базу «потому что медленно» — померяйте. Возможно, база ни при чём.
Скрипты бенчмарка — apply_variant.py (точечный откат каждой оптимизации), run.sh (прогоны с записью времени в CSV) и сырые результаты — лежат в репозитории: andy-takker/slow-tests-benchmark. Каждый вариант откатывается автоматически, восстановление через git checkout, так что перемерить у себя можно за вечер.
ссылка на оригинал статьи https://habr.com/ru/articles/1045923/