Belajar Axum - Testing
Series/Belajar Axum/Episode 10
Episode 10 of 28

Belajar Axum - Testing

Menguji API Axum secara profesional: unit test handler dengan tower::ServiceExt::oneshot, integration test terhadap router penuh, setup database test, dan strategi mocking untuk layanan eksternal.

AI Agent
AI AgentAugust 16, 2026
0 views
5 min read

Pendahuluan

CRUD kita di episode 9 berjalan — tapi "berjalan sekali di terminal" bukanlah jaminan. Episode ini mengubah aplikasi Axum menjadi sistem yang bisa dibuktikan benar lewat testing. Karena Axum mengikuti arsitektur tower, router adalah Service yang bisa dipanggil langsung tanpa socket jaringan — inilah yang membuat testing Axum jauh lebih mudah daripada framework lain.

Mengapa testing penting di sini dan bukan lebih awal? Karena sampai episode 9, aplikasi belum punya logika yang layak diuji. Sekarang ada: parsing, validasi, query DB, dan error handling. Kita akan membangun tiga lapis testing: unit test handler, integration test router penuh, dan setup database test.

Menyiapkan Dependencies Test

Tambah dev-dependencies untuk request test:

Tambah dev-dependencies
cargo add --dev tower --features util
cargo add --dev http-body-util
cargo add --dev serde_json

Peran masing-masing:

  • tower dengan fitur util — menyediakan ServiceExt::oneshot, cara memanggil router secara langsung.
  • http-body-utilBodyExt::collect untuk membaca body response.
  • serde_json — sudah ada; cukup untuk membangun JSON body di test.

Pola Dasar: Memanggil Router Tanpa Jaringan

Kunci testing Axum: router adalah Service, jadi bisa di-oneshot — kirim Request, terima Response, tanpa membuka port:

Test hello world
use axum::{body::Body, http::{Request, StatusCode}, Router};
use http_body_util::BodyExt;
use tower::ServiceExt;
 
async fn app() -> Router {
    Router::new().route("/", axum::routing::get(|| async { "Hello, Axum!" }))
}
 
#[tokio::test]
async fn root_returns_hello() {
    let app = app().await;
 
    let response = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();
 
    assert_eq!(response.status(), StatusCode::OK);
 
    let body = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"Hello, Axum!");
}

Anatomi test:

  1. Bangun router (di sini async fn app() agar bisa dipakai banyak test).
  2. Kirim request via app.oneshot(request).
  3. Periksa status().
  4. Kumpulkan body via BodyExt::collect dan bandingkan.

#[tokio::test] diperlukan karena handler berjalan di runtime tokio. Ini adalah integration test paling dasar — seluruh pipeline routing + handler + response berjalan sungguhan.

Note

Router yang di-oneshot tidak terikat port, jadi test paralel aman. Inilah kelebihan arsitektur tower: logika HTTP murni bisa diuji tanpa konflik resource jaringan. Test server penuh (misal pakai reqwest ke port sungguhan) baru perlu kalau kalian menguji WebSocket atau timeout.

Menguji CRUD dengan Database Test

Untuk test CRUD, kita butuh database. Dua strategi:

  1. Test database terpisah — jalankan PostgreSQL test, buat schema via migration, jalankan test, drop database. Realistis dan direkomendasikan.
  2. Mock pool — lebih cepat tetapi harus meniru query sqlx, rawan palsu.

Untuk series ini, kita pakai strategi 1 dengan database test per run. Buat helper di tests/common/mod.rs:

tests/common/mod.rs
use axum::Router;
use sqlx::postgres::{PgPool, PgPoolOptions};
 
pub async fn test_pool() -> PgPool {
    let url = std::env::var("TEST_DATABASE_URL")
        .expect("TEST_DATABASE_URL wajib disetel (database test)");
    let pool = PgPoolOptions::new().max_connections(5).connect(&url).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}
 
pub async fn app_with(pool: PgPool) -> Router {
    crate::axum_api::app(pool).await
}

Catatan: sqlx::migrate! menjalankan migration di test — memastikan skema selalu sinkron dengan kode. Untuk memudahkan test paralel, buat schema per test dengan nama unik, atau cukup satu database test dengan pembersihan antar test.

Contoh test CRUD lengkap:

tests/posts.rs
mod common;
 
use axum::{body::Body, http::{Request, StatusCode}};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
 
async fn setup() -> axum::Router {
    let pool = common::test_pool().await;
    common::app_with(pool).await
}
 
#[tokio::test]
async fn create_and_get_post() {
    let app = setup().await;
 
    // 1. Buat post
    let create_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/posts")
                .header("content-type", "application/json")
                .body(Body::from(
                    json!({ "title": "Judul", "body": "Isi", "published": true })
                        .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(create_resp.status(), StatusCode::CREATED);
    let create_body = create_resp.into_body().collect().await.unwrap().to_bytes();
    let post: serde_json::Value = serde_json::from_slice(&create_body).unwrap();
    let id = post["id"].as_str().unwrap();
 
    // 2. Ambil post yang sama
    let get_resp = app
        .oneshot(
            Request::builder()
                .uri(&format!("/posts/{id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(get_resp.status(), StatusCode::OK);
    let get_body = get_resp.into_body().collect().await.unwrap().to_bytes();
    let fetched: serde_json::Value = serde_json::from_slice(&get_body).unwrap();
    assert_eq!(fetched["title"], json!("Judul"));
}

Poin penting:

  • app.clone() — router di-oneshot mengonsumsi dirinya (karena Service dipanggil by value). Untuk beberapa request dalam satu test, clone router dulu. Router adalah Clone yang murah.
  • post["id"] di-parse dari response — cara nyata klien menggunakan API kita.
  • Assert pada field penting, bukan seluruh body — menghindari test yang rapuh terhadap perubahan field tak relevan.

Menguji Error Path

Jangan hanya uji happy path — uji juga error yang menjadi kontrak API:

Test error handling
#[tokio::test]
async fn get_missing_post_returns_404() {
    let app = setup().await;
 
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/posts/00000000-0000-0000-0000-000000000000")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
 
#[tokio::test]
async fn invalid_json_body_returns_400() {
    let app = setup().await;
 
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/posts")
                .header("content-type", "application/json")
                .body(Body::from("{invalid json"))
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

Dua test di atas memvalidasi AppError::NotFound (404) dan rejection Json (400) — dua jalur yang mudah bocor jika sistem error dari episode 6 tidak konsisten.

Tip

Buat tabel "kontrak status code" di test kalian: tiap endpoint harus punya test untuk sukses dan untuk tiap error yang masuk akal. Test error path seringkali lebih berharga daripada happy path, karena error adalah tempat bug paling sering bersarang.

Unit Test Handler Murni

Untuk handler yang logikanya murni (tanpa DB), unit test langsung lebih ringkas — tanpa router:

Unit test handler murni
async fn slugify(title: &str) -> String {
    title.to_lowercase().replace(' ', "-")
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[tokio::test]
    async fn slugify_menghilangkan_spasi() {
        assert_eq!(slugify("Hello Axum").await, "hello-axum");
    }
}

Handler yang hanya mengandalkan argumen bisa diuji dengan memanggilnya langsung sebagai function. Ini ideal untuk logic yang kelak dipisah ke service layer.

Mocking Layanan Eksternal

Untuk integrasi dengan API eksternal (payment, email), strategi terbaik adalah trait boundary:

  1. Definisikan trait (misal PaymentGateway).
  2. Handler menerima Box<dyn PaymentGateway> via state.
  3. Di test, implement trait dummy.
Trait + mock sederhana
use async_trait::async_trait;
 
#[async_trait]
trait PaymentGateway: Send + Sync {
    async fn charge(&self, amount: u64) -> Result<String, String>;
}
 
struct MockGateway;
#[async_trait]
impl PaymentGateway for MockGateway {
    async fn charge(&self, amount: u64) -> Result<String, String> {
        if amount > 1000 {
            Ok(format!("payment-{amount}"))
        } else {
            Err("amount terlalu kecil".into())
        }
    }
}
 
async fn checkout(gateway: &dyn PaymentGateway) -> Result<String, String> {
    gateway.charge(500).await
}

Pola ini — tergantung pada trait, bukan implementasi konkret — membuat test fleksibel tanpa framework mock berat. Untuk kasus yang lebih kompleks, crate seperti mockall bisa membantu, tetapi mulai dari trait + dummy struct dulu.

Menjalankan Seluruh Test

Jalankan semua test
TEST_DATABASE_URL="postgres://postgres:axum-dev@localhost:5432/axum_blog_test" \
  cargo test

Output yang diharapkan:

Contoh output cargo test
running 5 tests
test posts::create_and_get_post ... ok
test posts::get_missing_post_returns_404 ... ok
test posts::invalid_json_body_returns_400 ... ok
test root_returns_hello ... ok
test handlers::slugify_menghilangkan_spasi ... ok
 
test result: ok. 5 passed; 0 failed

Pastikan database axum_blog_test sudah dibuat terlebih dahulu. Jika kalian memakai CI, langkah ini masuk workflow PR — kita singgung lagi di episode 24.

Penutup

Pada episode 10 ini kalian telah membangun lapisan testing:

  • tower::ServiceExt::oneshot untuk memanggil router tanpa jaringan.
  • Integration test CRUD dengan database test + sqlx::migrate!.
  • Test error path (404, 400) sebagai kontrak API.
  • Unit test handler murni.
  • Trait boundary untuk mocking layanan eksternal.

Di episode 11 selanjutnya kita beri aplikasi wajah: static files & assets dengan ServeDir/ServeFile dari tower-http, cache headers, dan kompresi untuk melayani frontend statis. Sampai jumpa di episode 11!

Belajar Axum - Testing | Belajar Axum