Тестовый набор выполняется 5 минут. Вы вносите маленькое изменение, запускаете cargo test и ждёте. Проверяете телефон. Всё ещё ждёте. К моменту, когда тесты заканчиваются, вы уже забыли, над чем работали.

У меня в phrase_bot было 10 тестов, работающих с базой данных. Каждому нужна чистая БД. «Безопасное» решение? Запускать последовательно через #[serial]. Результат? Перерыв на чай после каждого запуска.

Потом я разобрался, как скомбинировать #[sqlx::test] с teremock, и всё изменилось. Те же тесты, но параллельно и с полной изоляцией. Без кода очистки. Без случайных падений. Без перерывов на чай.

В этой статье разберём: почему традиционное тестирование с БД это больно, как изоляция решает проблему, и как это настроить с teremock для вашего Telegram-бота.

Содержание

  1. Боль последовательных тестов

  2. Идея: изоляция вместо очистки

  3. Интеграция с teremock

  4. Тестирование сложных flow

  5. Производительность: цифры

  6. Грабли и как их обойти

  7. Когда это НЕ нужно

  8. Миграция существующих тестов

Боль последовательных тестов

Знакомая ситуация. Вы пишете первый тест. Он создаёт пользователя, проверяет что-то, проходит. Отлично. Пишете второй. Он тоже создаёт пользователя с тем же 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, который:

  1. Создаёт свежую базу данных с уникальным именем перед запуском теста

  2. Автоматически прогоняет миграции

  3. Передаёт пул соединений в вашу тестовую функцию

  4. Удаляет базу после завершения теста (независимо от результата)

#[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 тестов

#[serial] + cleanup

4.2с

10.5с

26.3с

#[sqlx::test] параллельно

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 тестов параллельно, каждый со своим пулом соединений, можно упереться в лимит.

Решения, в порядке предпочтения:

  1. Использовать PgBouncer для пулинга соединений

  2. Увеличить max_connections в postgresql.conf

  3. Ограничить параллельность: 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. Хотя, по-хорошему, такие вызовы должны быть замоканы.

Миграция существующих тестов

Если решили мигрировать, вот пошаговый план:

  1. Добавить экспорт migrator. Создайте pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); в модуле базы данных.

  2. Убедиться, что DATABASE_URL задан. Добавьте в .env или в переменные окружения CI.

  3. Конвертировать тесты по одному. Замените #[tokio::test] на #[sqlx::test(migrator = "crate::db::MIGRATOR")]. Добавьте pool: PgPool первым параметром. Добавьте bot.dependencies(deps![pool.clone()]).

  4. Удалить код очистки. Удалите функцию cleanup_database(). Удалите атрибуты #[serial].

  5. Запустить и проверить. Запустите cargo test и посмотрите, как тесты выполняются параллельно. Убедитесь, что всё проходит.

Конверсия механическая. Большинство тестов требуют изменения 3-4 строк. Результат виден сразу: тестовый набор ускоряется с каждым сконвертированным тестом.

Итоги

Комбинация teremock для мокирования бота и #[sqlx::test] для изоляции базы данных изменила мой рабочий процесс с тестами. То, что занимало 5 минут, теперь занимает 15 секунд. То, что случайно падало в CI, т��перь проходит стабильно каждый раз.

Но главный выигрыш не в скорости. Он в простоте. Никаких cleanup-функций, которые нужно поддерживать. Никаких зависимостей по порядку выполнения. Никаких #[serial], про которые нужно помнить. Каждый тест это остров, самодостаточный и полный.

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

Собственно, для этого всё и затевалось.

Ссылки


Благодарности

Подход к mock-тестированию teloxide-ботов был впервые реализован в проекте teloxide_tests, который послужил важным источником вдохновения для архитектуры и дизайна teremock.

Отдельное спасибо команде teloxide за отличный фреймворк. Без их работы экосистема Telegram-ботов на Rust выглядела бы совсем иначе.


Если статья была полезна, поставьте звёздочку на GitHub. Нашли баг или есть вопрос? Открывайте issue, разберёмся.