Ваши тесты медленные не из-за базы данных. Я измерил

от автора

Дисклеймер. Это не «единственно верный путь», а кейс одного конкретного проекта: бэкенд на 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 %

Открытие новых соединений (asyncpg.connect)

3,7 %

Хеширование паролей (argon2)

2,3 %

Пересоздание схемы БД (create_all, DDL)

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

Фреймворк

Litestar 2.20 (async ASGI), DI — Dishka

БД

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 раз за прогон:

До: каждый тест платит за event loop, соединения, приложение и схему; после: окружение поднимается один раз на прогон

До: каждый тест платит за event loop, соединения, приложение и схему; после: окружение поднимается один раз на прогон

Лечится одной настройкой и 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.

Чек-лист, который можно прогнать на своём проекте за вечер:

  1. Сначала профиль, потом правки. --durations → если виноватого теста нет, у вас ровный налог на вход, и чинить надо scope, а не отдельные тесты. Дальше — cProfile (или py-spy) на медленной версии: на оптимизированной он покажет скучное «ждём сеть» и ничего не подскажет.

  2. Большой профиль не читайте глазами. Группировка по причинам (snakeviz, gprof2dot, LLM — что вам ближе) → топ кандидатов → проверка замером. Профайлер находит, замер доказывает.

  3. Event loop и scope фикстур — первым делом. Для async-проекта *_loop_scope = "session" и scope="session" на дорогих ресурсах — однострочники с самым большим выигрышем.

  4. Сбейте параметры криптохеша в тестах. argon2/bcrypt на боевых параметрах — сожжённое время на каждом логине.

  5. Меняйте по одной вещи, медиана из нескольких прогонов, -p no:randomly, одно и то же железо. Иначе любые «ускорения» — шум.

  6. Прежде чем мокать базу «потому что медленно» — померяйте. Возможно, база ни при чём.

Скрипты бенчмарка — apply_variant.py (точечный откат каждой оптимизации), run.sh (прогоны с записью времени в CSV) и сырые результаты — лежат в репозитории: andy-takker/slow-tests-benchmark. Каждый вариант откатывается автоматически, восстановление через git checkout, так что перемерить у себя можно за вечер.

ссылка на оригинал статьи https://habr.com/ru/articles/1045923/