Testing Backend Services: Unit, Integration, and the Awkward Middle

Testing a backend service is harder than testing a library because your code is entangled with infrastructure: databases, HTTP, file systems, external APIs. The standard advice is "mock everything" or "test everything against real infrastructure." Both extremes have problems. This chapter is about finding the pragmatic middle.

Unit Tests: The Easy Part

Pure functions with no I/O are straightforward to test. Rust's built-in test runner handles them well:

#![allow(unused)]
fn main() {
// src/domain/pricing.rs

pub fn calculate_discount(
    base_price_cents: i64,
    discount_percent: f64,
) -> Result<i64, PricingError> {
    if discount_percent < 0.0 || discount_percent > 100.0 {
        return Err(PricingError::InvalidDiscount { percent: discount_percent });
    }
    let discount = (base_price_cents as f64 * discount_percent / 100.0) as i64;
    Ok(base_price_cents - discount)
}

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

    #[test]
    fn test_discount_calculation() {
        assert_eq!(calculate_discount(1000, 10.0), Ok(900));
        assert_eq!(calculate_discount(1000, 0.0), Ok(1000));
        assert_eq!(calculate_discount(1000, 100.0), Ok(0));
    }

    #[test]
    fn test_discount_rejects_invalid_percent() {
        assert!(calculate_discount(1000, -1.0).is_err());
        assert!(calculate_discount(1000, 101.0).is_err());
    }

    #[test]
    fn test_discount_rounds_down() {
        // 1/3 of $9.99 = $3.33, not $3.34
        assert_eq!(calculate_discount(999, 33.33), Ok(666));
    }
}
}

No dependencies, no fixtures, no infrastructure. The compiler and the test runner are all you need.

Testing Async Code

Async unit tests use #[tokio::test]:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_password_verification() {
        let hash = hash_password_async("correct-horse-battery-staple".to_string())
            .await
            .unwrap();

        let valid = verify_password_async(
            "correct-horse-battery-staple".to_string(),
            hash.clone(),
        )
        .await
        .unwrap();

        assert!(valid);

        let invalid = verify_password_async(
            "wrong-password".to_string(),
            hash,
        )
        .await
        .unwrap();

        assert!(!invalid);
    }
}
}

#[tokio::test] spins up a Tokio runtime for the test. By default it uses the current-thread (single-threaded) runtime, which is deterministic and appropriate for unit tests. If you need the multi-threaded scheduler, use #[tokio::test(flavor = "multi_thread")].

Integration Tests with a Real Database

This is where it gets interesting. SQLx's compile-time query verification means your queries are checked against the schema, but it doesn't test that they return the right results. For that, you need a real database.

The standard approach: a test database that gets reset between test runs (or between tests).

Test Database Setup

# Create a test database
createdb myapp_test

# Run migrations
DATABASE_URL=postgres://localhost/myapp_test cargo sqlx migrate run

Add to your Cargo.toml:

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros", "uuid", "time"] }

Test Fixtures and Cleanup

#![allow(unused)]
fn main() {
// tests/common/mod.rs

use sqlx::PgPool;

pub async fn create_test_pool() -> PgPool {
    let database_url = std::env::var("TEST_DATABASE_URL")
        .unwrap_or_else(|_| "postgres://localhost/myapp_test".to_string());

    let pool = sqlx::postgres::PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to test database");

    // Run migrations to ensure schema is current
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Migrations failed");

    pool
}

pub async fn truncate_tables(pool: &PgPool) {
    sqlx::query!("TRUNCATE TABLE users, sessions RESTART IDENTITY CASCADE")
        .execute(pool)
        .await
        .expect("Failed to truncate tables");
}
}

Database Integration Tests

#![allow(unused)]
fn main() {
// tests/user_repository.rs

mod common;

#[tokio::test]
async fn test_create_and_fetch_user() {
    let pool = common::create_test_pool().await;
    common::truncate_tables(&pool).await;

    // Create a user
    let user = sqlx::query_as!(
        User,
        r#"
        INSERT INTO users (email, name, password_hash)
        VALUES ($1, $2, $3)
        RETURNING id, email, name, created_at, updated_at
        "#,
        "alice@example.com",
        "Alice",
        "hashed_password"
    )
    .fetch_one(&pool)
    .await
    .expect("Failed to create user");

    assert_eq!(user.email, "alice@example.com");
    assert_eq!(user.name, "Alice");

    // Fetch it back
    let fetched = sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1",
        user.id
    )
    .fetch_optional(&pool)
    .await
    .expect("Failed to fetch user");

    assert!(fetched.is_some());
    assert_eq!(fetched.unwrap().id, user.id);
}

#[tokio::test]
async fn test_unique_email_constraint() {
    let pool = common::create_test_pool().await;
    common::truncate_tables(&pool).await;

    let create = |email: &'static str| async {
        sqlx::query!(
            "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3)",
            email,
            "Test User",
            "hash"
        )
        .execute(&pool)
        .await
    };

    create("same@example.com").await.expect("First insert should succeed");

    let result = create("same@example.com").await;
    assert!(result.is_err());

    let err = result.unwrap_err();
    if let sqlx::Error::Database(db_err) = &err {
        assert!(db_err.constraint().is_some(), "Expected unique constraint violation");
    } else {
        panic!("Expected database error, got: {err}");
    }
}
}

The truncate_tables call between tests is important. Tests share a pool and a database, so they share state. TRUNCATE ... CASCADE removes all rows and cascades to dependent tables. RESTART IDENTITY resets sequences. Tests that don't clean up after themselves cause phantom failures in other tests — the most infuriating class of intermittent CI failure.

Integration Tests with the Full HTTP Stack

axum::test (via axum-test or the built-in test helpers) lets you test your entire router without binding to a port:

[dev-dependencies]
axum-test = "14"
serde_json = "1"
#![allow(unused)]
fn main() {
// tests/api_users.rs

use axum_test::TestServer;
use serde_json::json;

mod common;

async fn build_test_app(pool: sqlx::PgPool) -> TestServer {
    let state = AppState {
        pool,
        config: std::sync::Arc::new(AppConfig::test_defaults()),
    };
    let app = create_app(state);
    TestServer::new(app).unwrap()
}

#[tokio::test]
async fn test_create_user_returns_201() {
    let pool = common::create_test_pool().await;
    common::truncate_tables(&pool).await;
    let server = build_test_app(pool).await;

    let response = server
        .post("/api/v1/users")
        .json(&json!({
            "email": "bob@example.com",
            "name": "Bob",
            "password": "secure-password-123"
        }))
        .await;

    response.assert_status_success();
    let body: serde_json::Value = response.json();
    assert_eq!(body["email"], "bob@example.com");
    assert!(body["id"].is_string());
}

#[tokio::test]
async fn test_create_user_rejects_duplicate_email() {
    let pool = common::create_test_pool().await;
    common::truncate_tables(&pool).await;
    let server = build_test_app(pool).await;

    let payload = json!({
        "email": "duplicate@example.com",
        "name": "User",
        "password": "password123"
    });

    server.post("/api/v1/users").json(&payload).await.assert_status_success();

    let second_response = server.post("/api/v1/users").json(&payload).await;
    second_response.assert_status(axum::http::StatusCode::CONFLICT);
}

#[tokio::test]
async fn test_get_user_requires_authentication() {
    let pool = common::create_test_pool().await;
    let server = build_test_app(pool).await;

    let response = server.get("/api/v1/users/me").await;
    response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_authenticated_request() {
    let pool = common::create_test_pool().await;
    common::truncate_tables(&pool).await;
    let server = build_test_app(pool).await;

    // Create user
    let create_response = server
        .post("/api/v1/users")
        .json(&json!({"email": "carol@example.com", "name": "Carol", "password": "pass123"}))
        .await;
    create_response.assert_status_success();

    // Login
    let login_response = server
        .post("/api/v1/auth/login")
        .json(&json!({"email": "carol@example.com", "password": "pass123"}))
        .await;
    login_response.assert_status_success();
    let token: String = login_response.json::<serde_json::Value>()["access_token"]
        .as_str()
        .unwrap()
        .to_string();

    // Authenticated request
    let me_response = server
        .get("/api/v1/users/me")
        .add_header("Authorization", format!("Bearer {token}").parse().unwrap())
        .await;
    me_response.assert_status_success();
    let user: serde_json::Value = me_response.json();
    assert_eq!(user["email"], "carol@example.com");
}
}

These tests exercise the full stack: routing, middleware, extractors, handlers, and the database. They're slower than unit tests but catch integration failures that unit tests can't.

The Awkward Middle: Mocking

Sometimes you need to test code that calls external services without actually calling them. The standard Rust approach is trait-based mocking:

#![allow(unused)]
fn main() {
// Define a trait for the external dependency
#[async_trait::async_trait]
pub trait EmailService: Send + Sync {
    async fn send_welcome_email(&self, email: &str, name: &str) -> Result<(), EmailError>;
}

// Real implementation
pub struct SendgridEmailService {
    api_key: String,
}

#[async_trait::async_trait]
impl EmailService for SendgridEmailService {
    async fn send_welcome_email(&self, email: &str, name: &str) -> Result<(), EmailError> {
        // ... actual HTTP call to Sendgrid
        todo!()
    }
}

// Your handler takes the trait, not the concrete type
pub async fn register_user<E: EmailService>(
    email_service: &E,
    pool: &sqlx::PgPool,
    payload: CreateUser,
) -> Result<User, AppError> {
    let user = create_user(pool, payload).await?;
    email_service.send_welcome_email(&user.email, &user.name).await?;
    Ok(user)
}
}

Test with a mock:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;

    struct MockEmailService {
        called: Arc<AtomicBool>,
        should_fail: bool,
    }

    #[async_trait::async_trait]
    impl EmailService for MockEmailService {
        async fn send_welcome_email(&self, _email: &str, _name: &str) -> Result<(), EmailError> {
            self.called.store(true, Ordering::SeqCst);
            if self.should_fail {
                Err(EmailError::ServiceUnavailable)
            } else {
                Ok(())
            }
        }
    }

    #[tokio::test]
    async fn test_register_sends_welcome_email() {
        let pool = crate::common::create_test_pool().await;
        let called = Arc::new(AtomicBool::new(false));
        let email_service = MockEmailService { called: called.clone(), should_fail: false };

        let result = register_user(
            &email_service,
            &pool,
            CreateUser { email: "dave@example.com".into(), name: "Dave".into() },
        ).await;

        assert!(result.is_ok());
        assert!(called.load(Ordering::SeqCst), "Email should have been sent");
    }
}
}

For more complex mocking needs, mockall generates mock implementations automatically. But start with hand-rolled mocks — they're simpler, easier to debug, and don't require a proc-macro dependency.

Running Tests

# Run all tests
cargo test

# Run tests with database logging
TEST_DATABASE_URL=postgres://localhost/myapp_test cargo test

# Run a specific test
cargo test test_create_user_returns_201

# Run tests with output (don't capture stdout)
cargo test -- --nocapture

# Run tests in a single thread (useful when tests share database state)
cargo test -- --test-threads=1

The -- --test-threads=1 flag is important when your tests share a database. Parallel tests that truncate tables will stomp on each other. Either run single-threaded, or give each test its own database (using transactions that you roll back, or sqlx's TestPool if it fits your version).

What to Test and What to Skip

Test:

  • Business logic in pure functions — always
  • Database queries — at least the non-trivial ones
  • Happy path and error cases in HTTP handlers
  • Authentication and authorization boundaries
  • Constraint violations (unique, foreign key, not null)

Skip (or test separately via end-to-end):

  • Framework behavior you don't own (Axum's routing, SQLx's type mapping)
  • Pure configuration wiring
  • Code that's just boilerplate

The signal is: if this test is going to tell you something you didn't already know when you wrote the code, write it. If it's testing that Axum routes requests correctly — you can trust Axum to do that.