Skip to content
rusthacks.dev
Databases

Testing Database Code in Rust With Test Containers

Run tests against a real PostgreSQL in a disposable container with the testcontainers crate. Start one per test run, migrate, isolate tests with transactions or databases, and keep CI fast with reuse.

4 min read
Summarize in AI
Flat illustration for the post “Testing Database Code in Rust With Test Containers”, composed around deploy.

Database code is mostly SQL, and SQL only means something relative to a particular engine. The most reliable way to test it is to run it against the engine you deploy. Containers make that cheap: tests start a PostgreSQL, use it and delete it. This post shows the setup. Versions: Rust 1.90 with edition 2024, testcontainers 0.25, testcontainers-modules 0.12, SQLx 0.8, Tokio 1.47, Docker running locally.

Dependencies

TOML
[dev-dependencies]
testcontainers-modules = { version = "0.12", features = ["postgres"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate"] }
tokio = { version = "1.47", features = ["full"] }

One container, one connection

rust
use sqlx::{postgres::PgPoolOptions, PgPool};
use testcontainers_modules::{postgres::Postgres, testcontainers::runners::AsyncRunner};

#[tokio::test]
async fn inserts_and_reads_a_row() -> Result<(), Box<dyn std::error::Error>> {
    let container = Postgres::default().start().await?;
    let port = container.get_host_port_ipv4(5432).await?;
    let url = format!("postgres://postgres:[email protected]:{port}/postgres");

    let pool: PgPool = PgPoolOptions::new().max_connections(5).connect(&url).await?;
    sqlx::migrate!("./migrations").run(&pool).await?;

    sqlx::query("INSERT INTO users (email) VALUES ($1)").bind("[email protected]").execute(&pool).await?;
    let n: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?;
    assert_eq!(n, 1);
    Ok(())
}

start() pulls the image if needed, starts the container and waits for the readiness message in its log. The get_host_port_ipv4 call returns the random host port Docker mapped to 5432, so parallel test runs and a local Postgres never collide. The container is stopped and removed when container is dropped, so keep the handle alive for the duration of the test.

Share the container, isolate the tests

One container per test is a few seconds each. Share one per test binary and give each test its own database:

rust
use sqlx::{Connection, PgConnection, PgPool};
use testcontainers_modules::testcontainers::ContainerAsync;
use tokio::sync::OnceCell;

struct Shared {
    _container: ContainerAsync<Postgres>,
    admin_url: String,
    port: u16,
}

static SHARED: OnceCell<Shared> = OnceCell::const_new();

async fn shared() -> &'static Shared {
    SHARED
        .get_or_init(|| async {
            let container = Postgres::default().start().await.unwrap();
            let port = container.get_host_port_ipv4(5432).await.unwrap();
            let admin_url = format!("postgres://postgres:[email protected]:{port}/postgres");
            Shared { _container: container, admin_url, port }
        })
        .await
}

pub async fn fresh_database() -> PgPool {
    let s = shared().await;
    let name = format!("test_{}", uuid::Uuid::new_v4().simple());
    let mut admin = PgConnection::connect(&s.admin_url).await.unwrap();
    sqlx::query(&format!("CREATE DATABASE {name}")).execute(&mut admin).await.unwrap();
    let url = format!("postgres://postgres:[email protected]:{}/{name}", s.port);
    let pool = PgPool::connect(&url).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

Each test calls fresh_database() and gets an empty, migrated database no other test can see, so tests run in parallel without cleanup. Migrating every time is simple; to speed it up, migrate one template database and use CREATE DATABASE ... TEMPLATE template_db, which copies it in milliseconds. A caveat of a static shared container: statics are never dropped, so the container may survive after the test run. Testcontainers starts a reaper container (Ryuk) that removes leftovers, so this normally cleans up, but check that your CI allows it.

Other isolation strategies

StrategyProsCons
Database per testFull isolation, code can commit transactionsSlower than the next one without a template
Transaction rolled back at the endVery fast, simpleCannot test code that commits or uses multiple connections
Truncate tables between testsSimpleForces serial execution, slower on big schemas

For the attribute that automates database-per-test, see SQLx's #[sqlx::test], which needs a DATABASE_URL you can point at a container. It handles creation, migration and cleanup for you.

CI

YAML
jobs:
  test:
    runs-on: ubuntu-latest      # Docker is preinstalled
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all-features

GitHub-hosted Linux runners include Docker, so the tests work unchanged. Pin the image tag (Postgres::default().with_tag("17-alpine")) to match production's major version. Services containers in the workflow are an alternative, started before the tests, but they do not give you per-test control.

When not to use containers

  • Pure logic tests with no database: keep them fast and separate (repository pattern makes this easier).
  • Environments without Docker (some sandboxed CI): use a service database through DATABASE_URL and sqlx::test.
  • Tests of unit-level behavior of a query builder: no database needed.

A short suite of real-database tests for the queries and migrations, plus fast unit tests everywhere else, balances speed and confidence (test organization).

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.