DEV Community

Cover image for From 5 Minutes to 15 Seconds: Parallel Database Tests for Telegram Bots
Stepan Romankov
Stepan Romankov

Posted on

From 5 Minutes to 15 Seconds: Parallel Database Tests for Telegram Bots

Your test suite takes 5 minutes to run. You make a small change, hit cargo test, and wait. And wait. You check Twitter. Still waiting. By the time tests finish, you've forgotten what you were working on.

I've been there. My phrase_bot had 10 database tests. Each one needed a clean database. The "safe" solution? Run them sequentially with #[serial]. The result? A coffee break every time I ran the suite.

Then I discovered how to combine #[sqlx::test] with teremock and everything changed. Same tests, but running in parallel with complete isolation. No cleanup code. No flaky failures. No more coffee breaks.

This article walks through why traditional database testing is painful, how isolation-based testing solves it, and how to set it up with teremock for your Telegram bot. By the end, you'll have a pattern that scales to hundreds of tests without slowing down.

The Pain of Sequential Database Tests

Let's talk about what most of us do when we first add database tests to a project.

You write your first test. It creates a user, checks something, passes. Great. You write a second test. It also creates a user with the same ID because you copied the first test. Now you have a primary key conflict. Tests fail randomly depending on execution order.

The "obvious" fix is to clean up the database before each test. You write a helper function that deletes everything:

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;
    // ... test code
}
Enter fullscreen mode Exit fullscreen mode

This works until it doesn't. And it stops working in subtle, frustrating ways.

The ordering problem. You add a phrases table with a foreign key to users. Now DELETE FROM users fails because phrases reference those users. You need to delete phrases first. Every schema change potentially breaks your cleanup function. These bugs don't appear immediately — they appear weeks later when someone adds a foreign key and forgets to update the cleanup order.

The forgotten table problem. You add a settings table. You forget to add it to cleanup. Tests pass locally because you're running them in a certain order. CI runs them differently. Random failures. You spend an hour debugging before realizing the cleanup is incomplete.

The performance problem. To avoid all these race conditions, you slap #[serial] on every test. Now they run one at a time. Ten tests at 500ms each is 5 seconds. Fifty tests is 25 seconds. A hundred tests is almost a minute. You stop running the full suite. You start running "just the tests for this file." Bugs slip through.

The CI flakiness problem. Even with #[serial], I've seen tests that pass locally and fail in GitHub Actions. Different machines, different timing, different connection pooling behavior. The database connection from the previous test hasn't fully closed yet. Or migrations run in a weird order. These are the worst bugs because they're not reproducible locally.

The fundamental issue is that we're trying to reuse a shared resource (the database) across tests that should be independent. We're fighting the architecture instead of working with it.

The Isolation Insight

What if instead of cleaning a shared database, each test got its own database?

Think about it. Test A runs against test_db_abc123. Test B runs against test_db_def456. They can't possibly interfere with each other. They can run in parallel. No cleanup needed — just drop the database when the test ends.

This sounds expensive. Creating and dropping databases has overhead, right?

Yes, but less than you'd think. PostgreSQL can create an empty database in about 50-100ms. That's nothing compared to the time you save by running tests in parallel. If you have 20 tests that each take 500ms, sequential execution takes 10 seconds. Parallel execution with 100ms overhead per test? Under 2 seconds on a multi-core machine.

This is exactly what #[sqlx::test] does. It's a macro from the sqlx crate that:

  1. Creates a fresh database with a unique name before your test runs
  2. Runs your migrations automatically
  3. Passes a connection pool to your test function
  4. Drops the database after the test completes (pass or fail)
#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_create_user(pool: PgPool) {
    // This pool connects to a brand new database
    // Named something like: myapp_test_a7f3b2c1
    // No other test can see this data
    // Dropped automatically when the test ends
}
Enter fullscreen mode Exit fullscreen mode

The beauty is in what you don't have to write. No cleanup functions. No #[serial]. No careful ordering of deletions. The isolation is handled at the database level, which is exactly where it should be.

Integrating with teremock

Here's where it gets interesting for Telegram bot developers. teremock already gives you isolated bot instances — each MockBot has its own mock server on a random port. Combining that with #[sqlx::test] gives you complete test isolation: isolated bot, isolated database, fully parallel execution.

The setup requires two pieces. First, you need to export your migrator so sqlx can find your migrations:

// src/db/mod.rs
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
Enter fullscreen mode Exit fullscreen mode

This is a static reference to your migrations directory. The sqlx::migrate! macro reads the migration files at compile time, so there's no runtime file system access.

Second, you write your tests using #[sqlx::test] instead of #[tokio::test], and you inject the pool into your 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;

    // This is the critical line — inject the isolated database
    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());
}
Enter fullscreen mode Exit fullscreen mode

The pool parameter is your isolated database. When you inject it into the bot's dependencies, all your handlers that expect a PgPool will receive this isolated instance. Your handler code doesn't change at all — it still accepts pool: PgPool and runs queries against it. But in tests, that pool points to a database that exists only for this one test.

Testing Complex Flows

The real payoff comes when you're testing multi-step dialogues with database interactions.

My phrase_bot lets users create custom phrases through a conversation. The user clicks "Add phrase," enters an emoji, enters a trigger word, enters a response template, and the bot saves it to the database. Testing this requires both dialogue state and database state to work together.

With the old approach, I'd need to carefully set up the database, run through the dialogue, verify the result, then clean up. If another test also created phrases, I'd need #[serial] to prevent conflicts. The test file became a minefield of ordering dependencies.

With #[sqlx::test], each test is completely self-contained:

#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_add_phrase_flow(pool: PgPool) {
    // Create the user this test needs
    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()]);

    // Walk through the dialogue
    bot.dispatch().await;
    bot.update(MockMessageText::new().text("Add phrase"));
    bot.dispatch().await;

    // ... user enters emoji, trigger text, response template ...

    // Verify it saved to OUR database (not shared with anyone)
    let phrases = db::get_user_phrases(&pool, MockUser::ID as i64).await.unwrap();
    assert_eq!(phrases[0].emoji, "🤗");
}
Enter fullscreen mode Exit fullscreen mode

Now I can write a deletion test that also uses the same user ID:

#[sqlx::test(migrator = "crate::db::MIGRATOR")]
async fn test_delete_phrase_flow(pool: PgPool) {
    // Set up a phrase to delete
    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()]);

    // ... navigate to delete, confirm deletion ...

    let phrases = db::get_user_phrases(&pool, MockUser::ID as i64).await.unwrap();
    assert!(phrases.is_empty());
}
Enter fullscreen mode Exit fullscreen mode

Both tests use MockUser::ID. Both manipulate phrases. With #[serial] and shared databases, that's a conflict waiting to happen. With #[sqlx::test], they run in parallel without a care in the world. Different databases, no interference.

This changes how you think about test design. You stop worrying about "what state will the database be in when this test runs?" Each test defines its own preconditions. Each test is a fresh start. The cognitive load drops dramatically.

Performance: The Numbers

I ran benchmarks on my phrase_bot test suite to quantify the difference. Here's what I found:

Approach 8 tests 20 tests 50 tests
#[serial] + cleanup 4.2s 10.5s 26.3s
#[sqlx::test] parallel 1.1s 1.8s 3.2s

The speedup is 4-8x, and the gap widens as you add more tests.

Why? Sequential execution scales linearly with test count. If each test takes 500ms, 50 tests take 25 seconds. There's no way around it.

Parallel execution scales with your CPU cores. On my 8-core machine, 8 tests that each take 500ms complete in roughly 600ms (500ms for the test plus ~100ms database overhead). The marginal cost of additional tests is just the overhead of creating the database, not the execution time.

The practical impact is huge. 26 seconds is "I'll check my phone while this runs." 3 seconds is "already done before I looked away." When tests are fast, you run them more often. When you run them more often, you catch bugs earlier. Earlier bugs are cheaper to fix.

Gotchas and Solutions

After using this pattern for several months, I've hit a few recurring issues.

Forgetting to inject the pool is the most common mistake. Your test runs, your bot dispatches, and nothing happens. No error, just silence. The handler tried to access the database, got a different pool (or none), and quietly failed.

// Wrong: bot has no access to your test database
bot.dispatch().await;

// Right: inject dependencies BEFORE dispatch
bot.dependencies(deps![pool.clone()]);
bot.dispatch().await;
Enter fullscreen mode Exit fullscreen mode

Make this a habit: MockBot::new(), then dependencies(), then dispatch(). Every time.

Connection limits can bite you with large test suites. PostgreSQL has a default max_connections setting (usually 100). If you're running 50 tests in parallel, each with its own connection pool, you might exceed that limit.

Solutions, in order of preference:

  1. Use PgBouncer for connection pooling
  2. Increase max_connections in postgresql.conf
  3. Limit test parallelism: cargo test -- --test-threads=4

Missing DATABASE_URL is another gotcha. #[sqlx::test] needs to know where to create test databases. It reads DATABASE_URL from the environment and uses that server to create/drop the temporary databases. Make sure your .env file has it set, or export it in your shell before running tests.

When Not to Use This

No technique is universally applicable. Here's when you might skip #[sqlx::test]:

Very small test suites. If you have 3 tests that run in 200ms total, the overhead of creating databases isn't worth it. Just use #[serial] and move on.

Tests that intentionally share state. Sometimes you want test B to see data created by test A. This is rare and often a code smell, but if you genuinely need it, isolated databases won't work.

SQLite in-memory databases. SQLite's in-memory mode creates a new database per connection automatically. You already have isolation. #[sqlx::test] still works, but you're not gaining much.

Tests hitting external services. If your tests call real APIs (payment processors, third-party services), parallel execution might trigger rate limits. Though arguably, those calls should be mocked anyway.

Making the Switch

If you're convinced and want to migrate an existing test suite, here's the process:

  1. Add the migrator export. Create pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); in your database module.

  2. Ensure DATABASE_URL is set. Add it to .env or your CI environment variables.

  3. Convert tests one at a time. Replace #[tokio::test] with #[sqlx::test(migrator = "crate::db::MIGRATOR")]. Add pool: PgPool as the first parameter. Add bot.dependencies(deps![pool.clone()]).

  4. Remove cleanup code. Delete your cleanup_database() function. Delete #[serial] attributes.

  5. Run and verify. Run cargo test and watch them execute in parallel. Check that all tests still pass.

The conversion is mechanical. Most tests require changing 3-4 lines. The payoff is immediate — your test suite gets faster with every test you convert.

Wrapping Up

The combination of teremock for bot mocking and #[sqlx::test] for database isolation transformed my testing workflow. What used to take 5 minutes now takes 15 seconds. What used to fail randomly in CI now passes reliably every time.

But the real win isn't the speed. It's the simplicity. No cleanup functions to maintain. No ordering dependencies to track. No #[serial] attributes to remember. Each test is an island, complete and self-sufficient.

When the feedback loop is fast, you test more. When tests are simple, you write more of them. When you have more tests, you ship fewer bugs.

That's the goal, isn't it?

Links


Acknowledgments

The approach to mock testing for teloxide bots was pioneered by the teloxide_tests project, which served as a major source of inspiration for teremock's architecture and design patterns.

Special thanks to the teloxide team for building such an excellent framework. Without their work, the Rust Telegram bot ecosystem would be a very different place.


If this article was helpful, consider starring the repo on GitHub. Found a bug or have a question? Open an issue and we'll figure it out together.

Top comments (0)