toni
Testing

Integration Testing

Test complete Toni applications end-to-end using the application context and HTTP test clients.

Integration tests exercise the full application — modules, DI, lifecycle hooks, and HTTP routing — without mocking internal boundaries.

Testing with the application context

ToniFactory::create_application_context creates a full DI-initialized context without starting an HTTP server. Use it to test service interactions end-to-end:

use toni::ToniFactory;

#[tokio::test]
async fn test_user_creation_flow() {
    let mut ctx = ToniFactory::create_application_context(AppModule).await;

    let users_service = ctx.get::<UsersService>().await.unwrap();
    let orders_service = ctx.get::<OrdersService>().await.unwrap();

    // Create a user
    let user = users_service.create("Alice", "alice@example.com").await.unwrap();
    assert_eq!(user.name, "Alice");

    // Create an order for that user
    let order = orders_service.create_order(user.id, 49.99).await.unwrap();
    assert!(order.id > 0);
    assert_eq!(order.user_id, user.id);

    ctx.close().await.unwrap();
}

HTTP integration tests

For HTTP-level integration tests, start a real server and send requests with a test HTTP client:

[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
use toni::ToniFactory;
use toni_axum::AxumAdapter;

async fn start_test_server() -> String {
    let port = find_free_port();
    let mut app = ToniFactory::create(TestModule, AxumAdapter::new()).await;

    tokio::spawn(async move {
        app.listen(port, "127.0.0.1").await;
    });

    // Give the server a moment to start
    tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

    format!("http://127.0.0.1:{port}")
}

fn find_free_port() -> u16 {
    use std::net::TcpListener;
    TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

#[tokio::test]
async fn test_get_users() {
    let base_url = start_test_server().await;
    let client = reqwest::Client::new();

    let response = client.get(format!("{base_url}/users"))
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 200);
    let users: Vec<serde_json::Value> = response.json().await.unwrap();
    assert!(!users.is_empty());
}

#[tokio::test]
async fn test_create_user() {
    let base_url = start_test_server().await;
    let client = reqwest::Client::new();

    let response = client.post(format!("{base_url}/users"))
        .json(&serde_json::json!({
            "name": "Alice",
            "email": "alice@test.com"
        }))
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 201);
    let user: serde_json::Value = response.json().await.unwrap();
    assert_eq!(user["name"], "Alice");
}

Testing a specific module in isolation

Create a test-only module that provides only what you need:

#[module(
    controllers: [UsersController],
    providers: [
        UsersService,
        // Use a fake database service instead of the real one
        provider_value!("DB", FakeDatabase::new()),
    ],
)]
pub struct UsersTestModule;

#[tokio::test]
async fn test_users_controller() {
    let base_url = start_test_server_with_module(UsersTestModule).await;
    // ... run HTTP tests against UsersController with fake dependencies
}

Lifecycle hooks in tests

Lifecycle hooks run normally in integration tests. If on_module_init makes real network calls (database, cache), consider using test-specific provider implementations that avoid external systems.

Tip: test isolation

Run integration tests with cargo test --test-threads 1 if tests share state (ports, databases) that can conflict. Or use unique ports per test via find_free_port().

On this page