Updated Aug 27, 2026

11. Testing

📋 Jump to Takeaways

🎁 Axum handlers are async functions. You can call them directly in tests, or spin up the full router with a test HTTP client. Both patterns have their place.

Unit Testing Handlers

For handlers that don't touch the database, test the function directly:

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

    #[tokio::test]
    async fn health_returns_ok() {
        let response = health().await;
        assert_eq!(response, "ok");
    }
}

#[tokio::test] is the async equivalent of #[test] — it starts a tokio runtime for the test.

Integration Testing with tower

For testing the full router, use tower::ServiceExt to send requests without a real TCP connection:

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
axum = "0.8"

The tests reference two helpers you need to wire up in your project. Add them to src/lib.rs (or wherever your router is assembled):

// src/lib.rs
use axum::Router;
use crate::state::AppState;

pub fn app(state: AppState) -> Router {
    Router::new()
        .merge(crate::health::router(state.clone()))
        .merge(crate::links::router(state.clone()))
}

#[cfg(test)]
pub async fn test_state() -> AppState {
    let pool = test_pool().await;
    AppState {
        pool,
        config: crate::config::Config {
            database_url: std::env::var("TEST_DATABASE_URL")
                .unwrap_or_else(|_| "postgres://postgres:password@localhost/urlshortener_test".to_string()),
            port: 3000,
            log_level: "error".to_string(),
            jwt_secret: "test-secret".to_string(),
        },
    }
}
use axum::{body::Body, http::{Request, StatusCode}};
use tower::ServiceExt; // for .oneshot()
use crate::{app, test_state};

#[tokio::test]
async fn get_link_returns_404_when_not_found() {
    let app = app(test_state().await);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/links/nonexistent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

.oneshot() sends one request through the router and returns the response. No server, no network, fast.

Test Database

Use a separate database for tests. Set DATABASE_URL in a .env.test file or use environment variables in CI.

A better approach: create a new database per test run and migrate it:

async fn test_pool() -> sqlx::PgPool {
    let url = std::env::var("TEST_DATABASE_URL")
        .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/urlshortener_test".to_string());

    let pool = sqlx::PgPool::connect(&url).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

For full isolation, use sqlx::test which creates a fresh database per test:

[dev-dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "testing"] }
#[sqlx::test]
async fn create_link_stores_in_database(pool: sqlx::PgPool) {
    let link = create(&pool, "https://example.com").await.unwrap();
    assert_eq!(link.url, "https://example.com");
    assert_eq!(link.clicks, 0);
    assert!(!link.code.is_empty());
}

#[sqlx::test] creates an isolated database, runs migrations, injects the pool, and cleans up afterward.

Testing JSON Responses

Read and parse the response body:

use axum::body::to_bytes;
use serde_json::Value;

#[tokio::test]
async fn create_link_returns_json() {
    let app = app(test_state().await);

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/links")
                .header("Content-Type", "application/json")
                .body(Body::from(r#"{"url":"https://example.com"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::CREATED);

    let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
    let json: Value = serde_json::from_slice(&body).unwrap();

    assert_eq!(json["url"], "https://example.com");
    assert!(json["code"].is_string());
}

Organizing Tests

Put unit tests in the same file as the code under test:

// src/links.rs
#[cfg(test)]
mod tests {
    use super::*;
    // ...
}

Put integration tests in tests/:

tests/
├── links_api.rs    ← tests for the full /links endpoints
└── health_api.rs   ← tests for /health

Key Takeaways

  • #[tokio::test] runs async test functions with a tokio runtime
  • .oneshot() from tower::ServiceExt sends a request through the router without a real server
  • #[sqlx::test] creates an isolated database per test — ideal for database integration tests
  • Test the router, not the handler functions directly — it exercises middleware and extractors too
  • Unit tests live next to source files; integration tests live in tests/

🎁 Your API is open to anyone. Next up: adding JWT authentication so only authorized clients can create and delete links.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy