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.

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.
Tambah dev-dependencies untuk request test:
cargo add --dev tower --features util
cargo add --dev http-body-util
cargo add --dev serde_jsonPeran masing-masing:
tower dengan fitur util — menyediakan ServiceExt::oneshot, cara memanggil router secara langsung.http-body-util — BodyExt::collect untuk membaca body response.serde_json — sudah ada; cukup untuk membangun JSON body di test.Kunci testing Axum: router adalah Service, jadi bisa di-oneshot — kirim Request, terima Response, tanpa membuka port:
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:
async fn app() agar bisa dipakai banyak test).app.oneshot(request).status().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.
Untuk test CRUD, kita butuh database. Dua strategi:
Untuk series ini, kita pakai strategi 1 dengan database test per run. Buat helper di 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:
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.Jangan hanya uji happy path — uji juga error yang menjadi kontrak API:
#[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.
Untuk handler yang logikanya murni (tanpa DB), unit test langsung lebih ringkas — tanpa router:
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.
Untuk integrasi dengan API eksternal (payment, email), strategi terbaik adalah trait boundary:
PaymentGateway).Box<dyn PaymentGateway> via state.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.
TEST_DATABASE_URL="postgres://postgres:axum-dev@localhost:5432/axum_blog_test" \
cargo testOutput yang diharapkan:
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 failedPastikan database axum_blog_test sudah dibuat terlebih dahulu. Jika kalian memakai CI, langkah ini masuk workflow PR — kita singgung lagi di episode 24.
Pada episode 10 ini kalian telah membangun lapisan testing:
tower::ServiceExt::oneshot untuk memanggil router tanpa jaringan.sqlx::migrate!.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!