Skip to main content
Back to Blog
Guide
2026-05-07

Testcontainers Rust — Integration Testing Guide 2026

Master Testcontainers for Rust integration testing. Real PostgreSQL, MySQL, Redis, MongoDB tests with sqlx, tokio, and CI/CD patterns.

Testcontainers Rust Integration Testing Guide

Rust has become a serious contender for backend services, and most production Rust applications depend on at least one database. Testing Rust code against real databases has been challenging because Rust's borrow checker makes long-lived shared state awkward, and crates like sqlx have their own offline-checked-query mode that doesn't validate behavior against a real engine. Testcontainers-rs solves this by giving every test a fresh, real database container with one-line setup, integrated cleanly with Rust's testing ecosystem and async runtimes.

This guide is a hands-on walkthrough of testcontainers-rs in 2026. We cover the official testcontainers crate with modules for PostgreSQL, MySQL, Redis, MongoDB, and Kafka, integration with sqlx, diesel, and sea-orm, tokio runtime patterns, container reuse, and CI/CD configuration. Every code sample is working Rust with tokio and the testcontainers crate.


Key Takeaways

  • testcontainers is the official Rust crate with module support
  • AsyncRunner is the async API for tokio-based applications
  • Per-test containers are the idiomatic Rust pattern given the borrow checker
  • sqlx, diesel, sea-orm all integrate via connection URLs
  • Container reuse is supported and dramatically speeds up local iteration
  • CI/CD setup is trivial because Docker is available on GitHub Actions ubuntu runners

Installation

Cargo.toml:

[dev-dependencies]
testcontainers = { version = "0.23", features = ["http_wait"] }
testcontainers-modules = { version = "0.11", features = ["postgres", "mysql", "redis", "mongo"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] }

Verify Docker:

docker info

PostgreSQL with sqlx

use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
use sqlx::PgPool;

#[tokio::test]
async fn test_postgres_integration() {
    let container = Postgres::default()
        .with_tag("16-alpine")
        .start()
        .await
        .unwrap();

    let host_port = container.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@localhost:{}/postgres", host_port);

    let pool = PgPool::connect(&url).await.unwrap();

    let row: (i32,) = sqlx::query_as("SELECT 1 + 1")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(row.0, 2);
}

The container starts in beforeAll-equivalent, and when the container value drops at the end of the test, Testcontainers reaps it.


sqlx Migration Pattern

#[tokio::test]
async fn test_with_migrations() {
    let container = Postgres::default().with_tag("16-alpine").start().await.unwrap();
    let port = container.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@localhost:{}/postgres", port);

    let pool = PgPool::connect(&url).await.unwrap();

    sqlx::migrate!("./migrations").run(&pool).await.unwrap();

    sqlx::query("INSERT INTO users (email) VALUES ('alice@example.com')")
        .execute(&pool)
        .await
        .unwrap();

    let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(count.0, 1);
}

The sqlx::migrate! macro embeds migrations at compile time and runs them against the test container.


MySQL Pattern

use testcontainers_modules::mysql::Mysql;

#[tokio::test]
async fn test_mysql() {
    let container = Mysql::default().with_tag("8.4").start().await.unwrap();
    let port = container.get_host_port_ipv4(3306).await.unwrap();
    let url = format!("mysql://root@localhost:{}/test", port);

    let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
    let row: (i32,) = sqlx::query_as("SELECT 1 + 1").fetch_one(&pool).await.unwrap();
    assert_eq!(row.0, 2);
}

Redis Pattern

use testcontainers_modules::redis::Redis;
use redis::AsyncCommands;

#[tokio::test]
async fn test_redis() {
    let container = Redis::default().start().await.unwrap();
    let port = container.get_host_port_ipv4(6379).await.unwrap();

    let client = redis::Client::open(format!("redis://localhost:{}", port)).unwrap();
    let mut conn = client.get_multiplexed_async_connection().await.unwrap();

    let _: () = conn.set("key", "value").await.unwrap();
    let value: String = conn.get("key").await.unwrap();

    assert_eq!(value, "value");
}

MongoDB Pattern

use testcontainers_modules::mongo::Mongo;
use mongodb::{Client, bson::doc};

#[tokio::test]
async fn test_mongo() {
    let container = Mongo::default().start().await.unwrap();
    let port = container.get_host_port_ipv4(27017).await.unwrap();
    let uri = format!("mongodb://localhost:{}", port);

    let client = Client::with_uri_str(&uri).await.unwrap();
    let coll = client.database("test").collection::<mongodb::bson::Document>("users");

    coll.insert_one(doc! { "name": "alice" }).await.unwrap();

    let count = coll.count_documents(doc! {}).await.unwrap();
    assert_eq!(count, 1);
}

Shared Container with OnceCell

For tests that share a single container across many tests:

use tokio::sync::OnceCell;

static POOL: OnceCell<PgPool> = OnceCell::const_new();
static CONTAINER: OnceCell<ContainerAsync<Postgres>> = OnceCell::const_new();

async fn get_pool() -> &'static PgPool {
    POOL.get_or_init(|| async {
        let container = Postgres::default().with_tag("16-alpine").start().await.unwrap();
        let port = container.get_host_port_ipv4(5432).await.unwrap();
        let url = format!("postgres://postgres:postgres@localhost:{}/postgres", port);
        CONTAINER.set(container).ok();
        let pool = PgPool::connect(&url).await.unwrap();
        sqlx::migrate!("./migrations").run(&pool).await.unwrap();
        pool
    }).await
}

#[tokio::test]
async fn test_one() {
    let pool = get_pool().await;
    // use pool
}

#[tokio::test]
async fn test_two() {
    let pool = get_pool().await;
    // use pool, same container
}

This pattern saves the container startup cost across hundreds of tests.


Container Methods

MethodPurpose
Postgres::default()Default config
.with_tag(tag)Specific image tag
.with_env_var(key, val)Set env var
.with_init_sql(path)Run SQL on startup
.start() (async)Start container, returns ContainerAsync
.start() (sync)Synchronous start

After start:

MethodReturns
.get_host_port_ipv4(port)Mapped port
.get_host()Hostname
.id()Container ID

Per-Test Isolation

Three patterns:

PatternSpeedUse
Container per testSlowMaximum isolation, simplest
Shared container + transaction rollbackFastMost cases
Shared container + TRUNCATEMediumDDL-heavy tests

Transactional rollback in sqlx:

async fn run_in_transaction<F>(pool: &PgPool, f: F)
where F: for<'a> AsyncFnOnce(&mut PgConnection) -> ()
{
    let mut tx = pool.begin().await.unwrap();
    f(&mut tx).await;
    tx.rollback().await.unwrap();
}

Diesel Integration

use diesel::pg::PgConnection;
use diesel::prelude::*;

#[tokio::test]
async fn test_diesel() {
    let container = Postgres::default().start().await.unwrap();
    let port = container.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@localhost:{}/postgres", port);

    let mut conn = PgConnection::establish(&url).unwrap();
    diesel_migrations::run_pending_migrations(&mut conn).unwrap();

    diesel::sql_query("INSERT INTO users (email) VALUES ('a@b.com')")
        .execute(&mut conn)
        .unwrap();
}

sea-orm Integration

use sea_orm::{Database, DbConn};

#[tokio::test]
async fn test_sea_orm() {
    let container = Postgres::default().start().await.unwrap();
    let port = container.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@localhost:{}/postgres", port);

    let db: DbConn = Database::connect(&url).await.unwrap();
    // run migrations, use db
}

Container Reuse

use testcontainers::core::ContainerPort;
use testcontainers::ImageExt;

let container = Postgres::default()
    .with_tag("16-alpine")
    .with_reuse(testcontainers::ReuseDirective::Always)
    .start()
    .await
    .unwrap();

Enable in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

CI/CD Configuration

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
      - run: cargo test --all

Common Pitfalls

Container lifetime. Containers stop when their value drops. If you store the container in a local variable, it lives only as long as the test function. For shared containers, use OnceCell or static lifetimes.

Async runtime confusion. Testcontainers async API requires tokio. If your tests use async-std, you'll need an adapter.

Port assumptions. Don't hardcode port 5432. Always use get_host_port_ipv4() to get the actual mapped port.

sqlx offline mode. sqlx's compile-time query checking requires either a live database or .sqlx cache files. In CI, you have a live container so set SQLX_OFFLINE=false.


Conclusion

Testcontainers-rs brings real database integration testing to the Rust ecosystem with idiomatic async patterns. PostgreSQL, MySQL, Redis, MongoDB — all work with one-line setup, sqlx, diesel, and sea-orm all integrate cleanly. Container reuse keeps local iteration fast, and CI requires no configuration.

Browse the QA skills directory for related Rust testing patterns, or read our Go guide for systems-language comparison.

Testcontainers Rust — Integration Testing Guide 2026 | QASkills.sh