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.

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
[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
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:
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
| Strategy | Pros | Cons |
|---|---|---|
| Database per test | Full isolation, code can commit transactions | Slower than the next one without a template |
| Transaction rolled back at the end | Very fast, simple | Cannot test code that commits or uses multiple connections |
| Truncate tables between tests | Simple | Forces 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
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-featuresGitHub-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_URLandsqlx::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).


