Skip to content

Testing

Learn how to test your Ultimo applications effectively.

Test Client

:::info Coming Soon Built-in test client is coming soon. :::

The planned test client will make integration testing easy:

use ultimo::prelude::*;
use ultimo::test::TestClient;
 
#[tokio::test]
async fn test_get_users() {
    let mut app = Ultimo::new();
 
    app.get("/users", |ctx| async move {
        ctx.json(vec![
            User { id: 1, name: "Alice".to_string() }
        ]).await
    });
 
    let client = TestClient::new(app);
 
    let response = client.get("/users").send().await?;
 
    assert_eq!(response.status(), 200);
    assert_eq!(response.json::<Vec<User>>().await?.len(), 1);
}

Testing Patterns

Unit Testing Handlers

Test your handler logic separately:

#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
}
 
async fn get_users() -> Vec<User> {
    vec![
        User { id: 1, name: "Alice".to_string() },
        User { id: 2, name: "Bob".to_string() },
    ]
}
 
#[tokio::test]
async fn test_get_users() {
    let users = get_users().await;
    assert_eq!(users.len(), 2);
    assert_eq!(users[0].name, "Alice");
}

Integration Testing

Test the full HTTP stack:

use reqwest;
 
#[tokio::test]
async fn test_api_integration() {
    // Start your server in the background
    tokio::spawn(async {
        let mut app = Ultimo::new();
        app.get("/health", |ctx| async move {
            ctx.json(json!({"status": "ok"})).await
        });
        app.listen("127.0.0.1:3001").await
    });
 
    // Give server time to start
    tokio::time::sleep(Duration::from_millis(100)).await;
 
    // Test with real HTTP client
    let response = reqwest::get("http://127.0.0.1:3001/health")
        .await?;
 
    assert_eq!(response.status(), 200);
}

Testing with Database

#[tokio::test]
async fn test_user_crud() {
    // Setup test database
    let pool = SqlxPool::connect("postgres://localhost/test_db").await?;
 
    let mut app = Ultimo::new();
    app.with_sqlx(pool);
 
    // Add routes
    app.post("/users", create_user_handler);
    app.get("/users/:id", get_user_handler);
 
    let client = TestClient::new(app);
 
    // Test create
    let response = client
        .post("/users")
        .json(&json!({"name": "Alice", "email": "alice@example.com"}))
        .send()
        .await?;
 
    assert_eq!(response.status(), 201);
 
    let user: User = response.json().await?;
 
    // Test get
    let response = client
        .get(&format!("/users/{}", user.id))
        .send()
        .await?;
 
    assert_eq!(response.status(), 200);
}

Mocking Dependencies

// Mock RPC client for testing
struct MockRpcClient;
 
impl MockRpcClient {
    async fn get_user(&self, id: u32) -> User {
        User {
            id,
            name: "Test User".to_string(),
            email: "test@example.com".to_string(),
        }
    }
}
 
#[tokio::test]
async fn test_with_mock() {
    let mock_client = MockRpcClient;
    let user = mock_client.get_user(1).await;
    assert_eq!(user.name, "Test User");
}

Best Practices

✅ Use #[tokio::test] for Async Tests

#[tokio::test]
async fn test_async_handler() {
    let result = async_operation().await;
    assert!(result.is_ok());
}

✅ Clean Up Test Data

#[tokio::test]
async fn test_with_cleanup() {
    // Setup
    let db = setup_test_db().await;
 
    // Test
    let result = test_operation(&db).await;
 
    // Cleanup
    cleanup_test_db(&db).await;
 
    assert!(result.is_ok());
}

✅ Test Error Cases

#[tokio::test]
async fn test_invalid_input() {
    let result = create_user(CreateUserInput {
        name: "".to_string(), // Invalid
        email: "not-an-email".to_string(),
    }).await;
 
    assert!(result.is_err());
}

✅ Use Test Fixtures

struct TestFixture {
    app: Ultimo,
    db: SqlxPool,
}
 
impl TestFixture {
    async fn new() -> Self {
        let db = SqlxPool::connect("postgres://localhost/test_db").await.unwrap();
        let mut app = Ultimo::new();
        app.with_sqlx(db.clone());
        Self { app, db }
    }
}
 
#[tokio::test]
async fn test_with_fixture() {
    let fixture = TestFixture::new().await;
    // Use fixture.app and fixture.db
}

CI/CD Integration

# .github/workflows/test.yml
name: Tests
 
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v3
 
      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
 
      - name: Run tests
        run: cargo test
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost/test_db

Coverage

Generate test coverage reports:

# Install tarpaulin
cargo install cargo-tarpaulin
 
# Run tests with coverage
cargo tarpaulin --out Html --output-dir coverage