Тестовый набор выполняется 5 минут. Вы вносите маленькое изменение, запускаете cargo test и ждёте. Проверяете телефон. Всё ещё ждёте. К моменту, когда тесты заканчиваются, вы уже забыли, над чем работали.
У меня в phrase_bot было 10 тестов, работающих с базой данных. Каждому нужна чистая БД. «Безопасное» решение? Запускать последовательно через #[serial]. Результат? Перерыв на чай после каждого запуска.
Потом я разобрался, как скомбинировать #[sqlx::test] с teremock, и всё изменилось. Те же тесты, но параллельно и с полной изоляцией. Без кода очистки. Без случайных падений. Без перерывов на чай.
В этой статье разберём: почему традиционное тестирование с БД это больно, как изоляция решает проблему, и как это настроить с teremock для вашего Telegram-бота.
Содержание
Боль последовательных тестов
Знакомая ситуация. Вы пишете первый тест. Он создаёт пользователя, проверяет что-то, проходит. Отлично. Пишете второй. Он тоже создаёт пользователя с тем же ID, потому что вы скопировали первый тест. Конфликт первичного ключа. Тесты начинают падать в случайном порядке.
«Очевидное» решение: чистить базу перед каждым тестом. Пишем helper-функцию:
async fn cleanup_database(pool: &PgPool) {
sqlx::query("DELETE FROM phrases").execute(pool).await.unwrap();
sqlx::query("DELETE FROM users").execute(pool).await.unwrap();
}
#[tokio::test]
#[serial]
async fn test_create_user() {
let pool = get_test_pool().await;
cleanup_database(pool).await;
// ... код теста
}
Работает. До тех пор, пока не перестаёт. И ломается это в самых неприятных местах.
Проблема порядка удаления
Вы добавляете таблицу phrases с foreign key на users. Теперь DELETE FROM users падает, потому что на пользователей ссылаются фразы. Нужно удалять фразы первыми. Каждое изменение схемы потенциально ломает cleanup-функцию. Причём баг проявляется не сразу, а через пару недель, когда кто-то добавляет foreign key и забывает обновить порядок удаления.
Проблема забытой таблицы
Вы добавляете таблицу settings. Забываете добавить её в cleanup. Тесты проходят локально, потому что запускаются в определённом порядке. CI запускает их иначе. Случайные падения. Вы тратите час на отладку, прежде чем понимаете, что cleanup неполный.
Проблема производительности
Чтобы избежать гонок, вы вешаете #[serial] на каждый тест. Теперь они выполняются по одному. 10 тестов по 500мс — 5 секунд. 50 тестов — 25 секунд. 100 тестов — почти минута. Вы перестаёте запускать полный набор. Начинаете запускать «только тесты для этого файла». Баги просачиваются.
Проблема flaky CI
Даже с #[serial] бывает, что тесты проходят локально и падают в GitHub Actions. Другая машина, другой тайминг, другое поведение пула соединений. Соединение от предыдущего теста ещё не закрылось. Или миграции отработали в странном порядке. Это худший тип багов, потому что локально они не воспроизводятся.
Корень проблемы
Мы пытаемся переиспользовать общий ресурс (базу данных) между тестами, которые должны быть независимыми. Мы боремся с архитектурой вместо того, чтобы работать с ней.
Идея: изоляция вместо очистки
А что если вместо очистки общей базы каждый тест получает свою собственную?
Тест A работает с test_db_abc123. Тест B работает с test_db_def456. Они физически не могут мешать друг другу. Могут выполняться параллельно. Никакой очистки не нужно — просто удаляем базу, когда тест завершился.
Звучит дорого. Создание и удаление базы данных имеет overhead, да?
Да, но меньше, чем кажется. PostgreSQL создаёт пустую базу за 50-100мс. Это ничто по сравнению с выигрышем от параллельного выполнения. Если у вас 20 тестов по 500мс, последовательное выполнение занимает 10 секунд. Параллельное с overhead в 100мс на тест? Меньше 2 секунд на многоядерной машине.
Именно это делает #[sqlx::test]. Это макрос из крейта sqlx, который:
Создаёт свежую базу данных с уникальным именем перед запуском теста
Автоматически прогоняет миграции
Передаёт пул соединений в вашу тестовую функцию
Удаляет базу после завершения теста (независимо от результата)
#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_create_user(pool: PgPool) {
// pool подключён к совершенно новой базе
// С именем вроде: myapp_test_a7f3b2c1
// Никакой другой тест её не видит
// Удалится автоматически после завершения
}
Обратите внимание на то, что вам НЕ нужно писать. Никаких cleanup-функций. Никаких #[serial]. Никакого аккуратного порядка удалений. Изоляция обеспечивается на уровне базы данных, где ей и место.
Интеграция с teremock
Здесь становится интересно. teremock уже даёт изолированные инст��нсы бота: каждый MockBot имеет свой mock-сервер на случайном порту. Комбинируя это с #[sqlx::test], получаем полную изоляцию: изолированный бот, изолированная база, полностью параллельное выполнение.
Настройка состоит из двух частей. Первое: экспортируем migrator, чтобы sqlx нашёл миграции:
// src/db/mod.rs
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
Это статическая ссылка на директорию с миграциями. Макрос sqlx::migrate! читает файлы миграций на этапе компиляции, так что runtime-доступа к файловой системе нет.
Второе: пишем тесты с #[sqlx::test] вместо #[tokio::test] и инжектим pool в MockBot:
#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_start_creates_user(pool: PgPool) {
let mut bot = MockBot::new(
MockMessageText::new().text("/start"),
handler_tree()
).await;
// Критическая строка: инжектим изолированную базу
bot.dependencies(dptree::deps![pool.clone()]);
bot.dispatch().await;
let user = db::get_user(&pool, MockUser::ID as i64).await.unwrap();
assert!(user.nickname.is_none());
}
Параметр pool это ваша изолированная база. Когда вы инжектите его в зависимости бота, все обработчики, которые ожидают PgPool, получат этот изолированный инстанс. Код обработчиков не меняется вообще. Он по-прежнему принимает pool: PgPool и выполняет запросы. Но в тестах этот pool указывает на базу, которая существует только для этого одного теста.
Тестирование сложных flow
Главный выигрыш проявляется при тестировании многошаговых диалогов с обращениями к базе.
Мой phrase_bot позволяет создавать пользовательские фразы через диалог. Пользователь нажимает «Добавить фразу», вводит эмодзи, вводит триггер, вводит шаблон ответа, и бот сохраняет это в базу. Для тестирования нужно, чтобы состояние диалога и состояние базы работали вместе.
При старом подходе приходилось аккуратно готовить базу, проходить диалог, проверять результат, потом чистить. Если другой тест тоже создавал фразы, нужен #[serial] для предотвращения конфликтов. Файл с тестами превращался в минное поле зависимостей по порядку выполнения.
С #[sqlx::test] каждый тест полностью автономен:
#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_add_phrase_flow(pool: PgPool) {
// Создаём пользователя, который нужен этому тесту
db::create_user(&pool, MockUser::ID as i64).await.unwrap();
let mut bot = MockBot::new(
MockMessageText::new().text("/start"),
handler_tree()
).await;
bot.dependencies(deps![get_test_storage(), pool.clone()]);
// Проходим диалог
bot.dispatch().await;
bot.update(MockMessageText::new().text("Add phrase"));
bot.dispatch().await;
// ... пользователь вводит эмодзи, триггер, шаблон ...
// Проверяем, что сохранилось в НАШЕЙ базе (не общей ни с кем)
let phrases = db::get_user_phrases(&pool, MockUser::ID as i64).await.unwrap();
assert_eq!(phrases[0].emoji, "🤗");
}
Теперь можно написать тест удаления, который использует тот же user ID:
#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_delete_phrase_flow(pool: PgPool) {
// Готовим фразу для удаления
db::create_user(&pool, MockUser::ID as i64).await.unwrap();
db::create_phrase(&pool, MockUser::ID as i64, "🤗", "hug", "...").await.unwrap();
let mut bot = MockBot::new(/* ... */).await;
bot.dependencies(deps![get_test_storage(), pool.clone()]);
// ... переходим к удалению, подтверждаем ...
let phrases = db::get_user_phrases(&pool, MockUser::ID as i64).await.unwrap();
assert!(phrases.is_empty());
}
Оба теста используют MockUser::ID. Оба манипулируют фразами. С #[serial] и общей базой это конфликт, ждущий своего часа. С #[sqlx::test] они выполняются параллельно без малейших проблем. Разные базы, никакого пересечения.
Это меняет подход к проектированию тестов. Вы перестаёте думать «в каком состоянии будет база, когда запустится этот тест?» Каждый тест определяет свои предусловия. Каждый тест это чистый лист. Когнитивная нагрузка падает в разы.
Производительность: цифры
Я прогнал бенчмарки на тестовом наборе phrase_bot. Вот что получилось:
Подход | 8 тестов | 20 тестов | 50 тестов |
|---|---|---|---|
| 4.2с | 10.5с | 26.3с |
| 1.1с | 1.8с | 3.2с |
Ускорение в 4-8 раз, и разрыв растёт с каждым новым тестом.
Почему? Последовательное выполнение масштабируется линейно. Каждый тест по 500мс, 50 тестов — 25 секунд. Обойти это невозможно.
Параллельное выполнение масштабируется с количеством ядер. На моей 8-ядерной машине 8 тестов по 500мс завершаются примерно за 600мс (500мс на тест плюс ~100мс на создание базы). Маргинальная стоимость каждого нового теста это только overhead создания базы, а не время выполнения.
Практический эффект огромный. 26 секунд это «полезу в телефон, пока идут тесты». 3 секунды это «уже готово, не успел отвести взгляд». Когда тесты быстрые, вы запускаете их чаще. Когда запускаете чаще, ловите баги раньше. Ранние баги дешевле чинить.
Грабли и как их обойти
За несколько месяцев использования этого паттерна я наступил на несколько повторяющихся граблей.
Забыли инжектить pool
Самая частая ошибка. Тест запускается, бот диспатчит, ничего не происходит. Никакой ошибки, просто тишина. Обработчик попытался обратиться к базе, получил другой pool (или вообще ничего), и молча упал.
// Неправильно: бот не имеет доступа к вашей тестовой базе
bot.dispatch().await;
// Правильно: инжектим зависимости ДО dispatch
bot.dependencies(deps![pool.clone()]);
bot.dispatch().await;
Выработайте привычку: MockBot::new(), потом dependencies(), потом dispatch(). Всегда.
Лимиты соединений
Могут укусить при большом количестве тестов. PostgreSQL по умолчанию имеет max_connections (обычно 100). Если вы запускаете 50 тестов параллельно, каждый со своим пулом соединений, можно упереться в лимит.
Решения, в порядке предпочтения:
Использовать PgBouncer для пулинга соединений
Увеличить
max_connectionsв postgresql.confОграничить параллельность:
cargo test -- --test-threads=4
Отсутствует DATABASE_URL
#[sqlx::test] должен знать, где создавать тестовые базы. Он читает DATABASE_URL из окружения и использует этот сервер для создания/удаления временных баз. Убедитесь, что в .env файле есть эта переменная, или экспортируйте её в shell перед запуском тестов.
Когда это НЕ нужно
Никакая техника не универсальна. Вот когда #[sqlx::test] можно пропустить:
Маленький тестовый набор. Если у вас 3 теста, которые проходят за 200мс, overhead создания баз того не стоит. Используйте #[serial] и не усложняйте.
Тесты с намеренным shared state. Иногда тест B должен видеть данные, созданные тестом A. Это редкость и часто code smell, но если вам это реально нужно, изолированные базы не подойдут.
SQLite in-memory. SQLite в in-memory режиме создаёт новую базу для каждого соединения автоматически. Изоляция уже есть. #[sqlx::test] будет работать, но особого выигрыша вы не получите.
Тесты, обращающиеся к внешним сервисам. Если тесты ходят в реальные API (платёжные системы, сторонние сервисы), параллельное выполнение может вызвать rate limits. Хотя, по-хорошему, такие вызовы должны быть замоканы.
Миграция существующих тестов
Если решили мигрировать, вот пошаговый план:
Добавить экспорт migrator. Создайте
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");в модуле базы данных.Убедиться, что DATABASE_URL задан. Добавьте в
.envили в переменные окружения CI.Конвертировать тесты по одному. Замените
#[tokio::test]на#[sqlx::test(migrator = "crate::db::MIGRATOR")]. Добавьтеpool: PgPoolпервым параметром. Добавьтеbot.dependencies(deps![pool.clone()]).Удалить код очистки. Удалите функцию
cleanup_database(). Удалите атрибуты#[serial].Запустить и проверить. Запустите
cargo testи посмотрите, как тесты выполняются параллельно. Убедитесь, что всё проходит.
Конверсия механическая. Большинство тестов требуют изменения 3-4 строк. Результат виден сразу: тестовый набор ускоряется с каждым сконвертированным тестом.
Итоги
Комбинация teremock для мокирования бота и #[sqlx::test] для изоляции базы данных изменила мой рабочий процесс с тестами. То, что занимало 5 минут, теперь занимает 15 секунд. То, что случайно падало в CI, т��перь проходит стабильно каждый раз.
Но главный выигрыш не в скорости. Он в простоте. Никаких cleanup-функций, которые нужно поддерживать. Никаких зависимостей по порядку выполнения. Никаких #[serial], про которые нужно помнить. Каждый тест это остров, самодостаточный и полный.
Когда обратная связь быстрая, вы тестируете чаще. Когда тесты простые, вы пишете их больше. Когда тестов много, вы выпускаете меньше багов.
Собственно, для этого всё и затевалось.
Ссылки
Crates.io: https://crates.io/crates/teremock
Документация: https://docs.rs/teremock
Благодарности
Подход к mock-тестированию teloxide-ботов был впервые реализован в проекте teloxide_tests, который послужил важным источником вдохновения для архитектуры и дизайна teremock.
Отдельное спасибо команде teloxide за отличный фреймворк. Без их работы экосистема Telegram-ботов на Rust выглядела бы совсем иначе.
Если статья была полезна, поставьте звёздочку на GitHub. Нашли баг или есть вопрос? Открывайте issue, разберёмся.
