Testing

Rust’s type system catches many errors at compile time, but it does not verify business logic, database queries, or HTML output. Tests fill that gap. This section covers unit tests for domain logic and Maud components, integration tests against real databases and services using testcontainers, and testing Axum handlers with the axum_test crate.

Unit tests for domain logic

Standard #[test] functions in the same file as the code under test. Rust’s built-in test framework needs no external dependencies for pure logic.

pub fn slugify(input: &str) -> String {
    input
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        .trim_matches('-')
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slugify_converts_spaces_and_lowercases() {
        assert_eq!(slugify("Hello World"), "hello-world");
    }

    #[test]
    fn slugify_strips_special_characters() {
        assert_eq!(slugify("Rust & Axum!"), "rust---axum-");
    }
}

Place unit tests in a #[cfg(test)] mod tests block at the bottom of the module file. They compile only during cargo test and have access to private items in the parent module.

For async code, use #[tokio::test]:

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn sends_welcome_email() {
        let result = send_welcome("alice@example.com").await;
        assert!(result.is_ok());
    }
}

Testing Maud components

Maud components are Rust functions that return Markup. Test them by rendering to a string and asserting on the HTML output.

Simple assertions with into_string()

For small components with predictable output, compare the rendered string directly:

use maud::{html, Markup};

pub fn status_badge(active: bool) -> Markup {
    html! {
        @if active {
            span.badge.bg-success { "Active" }
        } @else {
            span.badge.bg-secondary { "Inactive" }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn badge_renders_active() {
        let html = status_badge(true).into_string();
        assert_eq!(html, r#"<span class="badge bg-success">Active</span>"#);
    }

    #[test]
    fn badge_renders_inactive() {
        let html = status_badge(false).into_string();
        assert_eq!(html, r#"<span class="badge bg-secondary">Inactive</span>"#);
    }
}

into_string() consumes the Markup and returns the inner String. Maud produces deterministic output with no extra whitespace, so exact string comparison works reliably for small components.

Structured assertions with scraper

For larger components or pages where exact string matching is fragile, use the scraper crate to parse the HTML and query it with CSS selectors.

[dev-dependencies]
scraper = "0.23"

Define a few test helpers that the rest of the test suite can reuse:

// tests/support/html.rs
use scraper::{Html, Selector};

pub fn parse(markup: maud::Markup) -> Html {
    Html::parse_fragment(&markup.into_string())
}

pub fn select_one<'a>(doc: &'a Html, css: &str) -> scraper::ElementRef<'a> {
    let sel = Selector::parse(css).unwrap();
    doc.select(&sel)
        .next()
        .unwrap_or_else(|| panic!("no element matching '{css}'"))
}

pub fn select_count(doc: &Html, css: &str) -> usize {
    let sel = Selector::parse(css).unwrap();
    doc.select(&sel).count()
}

pub fn text_of(doc: &Html, css: &str) -> String {
    select_one(doc, css).text().collect()
}

Use the helpers to make structural assertions:

use crate::support::html::{parse, select_one, select_count, text_of};

#[test]
fn user_card_renders_name_and_link() {
    let doc = parse(user_card(42, "Alice"));

    assert_eq!(text_of(&doc, ".user-card h2"), "Alice");

    let link = select_one(&doc, "a.profile-link");
    assert_eq!(link.value().attr("href"), Some("/users/42"));
}

#[test]
fn user_list_renders_all_users() {
    let users = vec![
        User { id: 1, name: "Alice".into() },
        User { id: 2, name: "Bob".into() },
    ];
    let doc = parse(user_list(&users));

    assert_eq!(select_count(&doc, ".user-card"), 2);
}

This approach survives attribute reordering, whitespace changes, and additions to unrelated parts of the template. It breaks only when the structural contract changes, which is what you want.

Verifying HTML escaping

Maud auto-escapes interpolated content. Verify this in tests whenever a component renders user-provided input:

#[test]
fn escapes_user_input() {
    let doc = parse(user_card(1, "<script>alert('xss')</script>"));
    let html = select_one(&doc, ".user-card h2").inner_html();

    assert!(!html.contains("<script>"));
    assert!(html.contains("&lt;script&gt;"));
}

Integration tests with testcontainers

testcontainers starts real Docker containers for PostgreSQL, Redis, Restate, or any other service your application depends on. Each test gets isolated infrastructure that is torn down automatically when the test finishes.

[dev-dependencies]
testcontainers = "0.27"
testcontainers-modules = { version = "0.15", features = ["postgres", "redis"] }

The testcontainers-modules crate re-exports the core testcontainers crate, so you can import from either.

PostgreSQL

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

async fn start_postgres() -> (testcontainers::ContainerAsync<Postgres>, PgPool) {
    let container = Postgres::default()
        .with_db_name("test_db")
        .with_user("test")
        .with_password("test")
        .start()
        .await
        .expect("failed to start postgres container");

    let host = container.get_host().await.unwrap();
    let port = container.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://test:test@{host}:{port}/test_db");

    let pool = PgPool::connect(&url).await.expect("failed to connect to test database");
    sqlx::migrate!().run(&pool).await.expect("failed to run migrations");

    (container, pool)
}

The container variable must stay in scope for the duration of the test. When it is dropped, testcontainers stops and removes the Docker container.

sqlx::migrate!() reads migrations from the migrations/ directory (relative to the crate’s Cargo.toml) and applies them to the fresh database. Every test starts with a clean schema.

Redis

use testcontainers_modules::redis::{Redis, REDIS_PORT};
use testcontainers_modules::testcontainers::runners::AsyncRunner;

async fn start_redis() -> (testcontainers::ContainerAsync<Redis>, String) {
    let container = Redis::default()
        .start()
        .await
        .expect("failed to start redis container");

    let host = container.get_host().await.unwrap();
    let port = container.get_host_port_ipv4(REDIS_PORT).await.unwrap();
    let url = format!("redis://{host}:{port}");

    (container, url)
}

Restate

Restate provides its own testcontainers integration through the restate-sdk-testcontainers crate:

[dev-dependencies]
restate-sdk = "0.9"
restate-sdk-testcontainers = "0.9"
use restate_sdk_testcontainers::TestEnvironment;

#[tokio::test]
async fn test_workflow() {
    let env = TestEnvironment::new()
        .start(my_service_endpoint)
        .await;

    let ingress_url = env.ingress_url();
    // Send requests to the Restate ingress...
}

TestEnvironment starts a Restate container, binds your service endpoint to a random port, health-checks it, and registers the endpoint with Restate’s admin API. The ingress_url() gives you the URL for sending requests through Restate.

For services without a dedicated testcontainers module, use GenericImage:

use testcontainers::{GenericImage, runners::AsyncRunner};
use testcontainers::core::{IntoContainerPort, WaitFor};

let container = GenericImage::new("my-service", "1.0")
    .with_exposed_port(8080.tcp())
    .with_wait_for(WaitFor::message_on_stdout("ready"))
    .start()
    .await
    .unwrap();

Testing Axum handlers

The axum_test crate provides an ergonomic test client for Axum applications. It handles request construction, response parsing, and cookie persistence.

[dev-dependencies]
axum-test = "0.22"

Basic setup

Create a function that builds the application Router with its state. This same function serves both production and tests:

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    // redis, config, etc.
}

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/{id}", get(get_user))
        .with_state(state)
}

In tests, build the state with a testcontainers-backed pool and pass it to TestServer:

use axum_test::TestServer;

#[tokio::test]
async fn test_list_users() {
    let (_pg, pool) = start_postgres().await;
    let state = AppState { db: pool };
    let server = TestServer::new(app(state)).unwrap();

    let response = server.get("/users").await;

    response.assert_status_ok();
    response.assert_header("content-type", "text/html; charset=utf-8");
}

The _pg binding keeps the PostgreSQL container alive for the duration of the test. Dropping it stops the container.

Testing HTML responses

Combine axum_test with the scraper helpers to assert on HTML structure:

#[tokio::test]
async fn test_user_list_renders_users() {
    let (_pg, pool) = start_postgres().await;
    seed_users(&pool).await;
    let server = TestServer::new(app(AppState { db: pool })).unwrap();

    let response = server.get("/users").await;
    response.assert_status_ok();

    let doc = Html::parse_document(&response.text());
    assert_eq!(select_count(&doc, ".user-card"), 3);
    assert_eq!(text_of(&doc, ".user-card:first-child h2"), "Alice");
}

Testing form submissions

Submit forms with .form() and verify the response:

#[tokio::test]
async fn test_create_user() {
    let (_pg, pool) = start_postgres().await;
    let server = TestServer::new(app(AppState { db: pool.clone() })).unwrap();

    let response = server
        .post("/users")
        .form(&[("name", "Charlie"), ("email", "charlie@example.com")])
        .await;

    response.assert_status_ok();

    // Verify the user was persisted
    let user = sqlx::query!("SELECT name FROM users WHERE email = 'charlie@example.com'")
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(user.name, "Charlie");
}

Testing htmx requests

htmx adds an HX-Request: true header to every request. Add it in tests to exercise the fragment-returning code path:

#[tokio::test]
async fn test_htmx_search_returns_fragment() {
    let (_pg, pool) = start_postgres().await;
    seed_users(&pool).await;
    let server = TestServer::new(app(AppState { db: pool })).unwrap();

    let response = server
        .get("/users/search")
        .add_query_param("q", "alice")
        .add_header("HX-Request", "true")
        .await;

    response.assert_status_ok();

    let html = response.text();
    // Fragment response: no <html> wrapper, just the search results
    assert!(!html.contains("<!DOCTYPE"));
    assert!(html.contains("Alice"));
}

Enable cookie persistence on the test server for testing authenticated flows:

#[tokio::test]
async fn test_authenticated_page() {
    let (_pg, pool) = start_postgres().await;
    seed_users(&pool).await;
    let server = TestServer::builder()
        .save_cookies()
        .build(app(AppState { db: pool }))
        .unwrap();

    // Log in (sets a session cookie)
    server
        .post("/login")
        .form(&[("email", "alice@example.com"), ("password", "correct-password")])
        .await
        .assert_status_ok();

    // Subsequent requests carry the session cookie
    let response = server.get("/dashboard").await;
    response.assert_status_ok();
    response.assert_text_contains("Welcome, Alice");
}

save_cookies() tells TestServer to persist cookies across requests, simulating a browser session.

Test fixtures and data setup

Seed test data with plain async functions that run after migrations:

async fn seed_users(pool: &PgPool) {
    sqlx::query!(
        "INSERT INTO users (name, email) VALUES ($1, $2), ($3, $4), ($5, $6)",
        "Alice", "alice@example.com",
        "Bob", "bob@example.com",
        "Charlie", "charlie@example.com"
    )
    .execute(pool)
    .await
    .unwrap();
}

Call seed_users(&pool).await at the start of any test that needs data. Each test gets its own database container with its own schema, so fixtures do not conflict between tests.

For larger fixture sets, organise seed functions by domain:

// tests/support/fixtures.rs
pub async fn seed_users(pool: &PgPool) { /* ... */ }
pub async fn seed_projects(pool: &PgPool) { /* ... */ }
pub async fn seed_users_with_projects(pool: &PgPool) {
    seed_users(pool).await;
    seed_projects(pool).await;
}

Shared test setup

Extract container startup and state construction into a helper to avoid repeating the boilerplate in every test:

// tests/support/mod.rs
pub mod fixtures;
pub mod html;

use testcontainers::ContainerAsync;
use testcontainers_modules::postgres::Postgres;

pub struct TestContext {
    pub server: axum_test::TestServer,
    pub pool: PgPool,
    _pg: ContainerAsync<Postgres>,
}

impl TestContext {
    pub async fn new() -> Self {
        let (pg, pool) = start_postgres().await;
        let state = AppState { db: pool.clone() };
        let server = axum_test::TestServer::new(app(state)).unwrap();

        Self { server, pool, _pg: pg }
    }
}

Tests become concise:

#[tokio::test]
async fn test_user_creation() {
    let ctx = TestContext::new().await;
    seed_users(&ctx.pool).await;

    let response = ctx.server.get("/users").await;
    response.assert_status_ok();
}

Running tests in CI

Container requirements

Testcontainers requires Docker. In GitHub Actions, Docker is available on the default ubuntu-latest runner. No special setup is needed.

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@v2

      - name: Run tests
        run: cargo test --workspace

rust-cache caches compiled dependencies between runs. Without it, every CI run rebuilds the dependency tree from scratch.

SQLx offline mode

SQLx compile-time query checking requires a live database during compilation. In CI, compilation happens before Docker containers start, so the build would fail without offline mode.

Generate the query cache locally (with your database running):

cargo sqlx prepare --workspace

Commit the resulting .sqlx/ directory to version control. When DATABASE_URL is absent at compile time, SQLx uses the cached metadata instead of connecting to a database.

In CI, verify the cache is current:

      - name: Check SQLx cache
        run: cargo sqlx prepare --workspace --check

This fails if any query has changed without regenerating the cache.

Parallel test execution

cargo test runs tests in parallel by default using one thread per test. Testcontainers starts a separate Docker container per test, so parallelism works without shared state conflicts. Each test has its own database.

If container startup becomes a bottleneck (each PostgreSQL container takes 2-3 seconds), reduce parallelism:

cargo test -- --test-threads=4

Or use the TESTCONTAINERS environment variable with reusable-containers feature to share containers across tests in the same binary. This trades isolation for speed.

End-to-end browser testing

For testing the fully rendered application in a real browser, the most mature option is Playwright running via Node.js. Playwright does not care what language your server is written in. Start the Rust application, point Playwright at http://localhost:PORT, and write tests against the rendered HTML.

Rust-native alternatives exist. fantoccini is an async WebDriver client that works with Chrome, Firefox, and Safari via their respective drivers. thirtyfour provides similar WebDriver-based testing with a richer query and waiting API. Both are mature and actively maintained.

For most teams, Playwright provides the best E2E testing experience: auto-waiting, tracing, video recording, and multi-browser support. The trade-off is a Node.js dependency in your test toolchain. If that dependency is unacceptable, fantoccini or thirtyfour are solid pure-Rust options.

E2E tests are a complement to the unit and integration tests described above, not a replacement. Run them separately from cargo test, typically as a dedicated CI step after the application is built and running.

Gotchas

Container startup time. Each PostgreSQL container takes 2-3 seconds to start. For a test suite with dozens of integration tests, this adds up. Consider grouping related assertions into fewer, larger tests rather than many small ones, or using the reusable-containers feature to share containers.

Docker must be running. Testcontainers communicates with the Docker daemon. Tests fail immediately if Docker is not available. In CI, this is handled by the default runner. Locally, ensure Docker Desktop or the Docker daemon is running before running cargo test.

Port mapping. Testcontainers maps container ports to random host ports. Always use container.get_host_port_ipv4(5432) to get the mapped port. Never hardcode port numbers.

Container variable lifetime. The container handle (ContainerAsync<Postgres>) must remain in scope for the test’s duration. If it is dropped, the container stops. A common mistake is discarding the handle:

// Wrong: container is dropped immediately
let pool = {
    let (container, pool) = start_postgres().await;
    pool
}; // container dropped here, database gone

// Right: keep the container alive
let (_container, pool) = start_postgres().await;
// _container lives until end of test

SQLx compile-time checking vs test databases. The query! macros check against DATABASE_URL at compile time. This is your development database, not the testcontainers database (which does not exist yet at compile time). The offline cache (.sqlx/) bridges this gap in CI. Locally, keep your development PostgreSQL running for compilation and let testcontainers handle test databases at runtime.