Selama tiga bagian kita menguji API dengan curl dan mata — teliti, tetapi rapuh: satu perubahan kecil pada handler bisa merusak kontrak endpoint tanpa ada yang menyadarinya sampai client komplain. Solusinya adalah mengubah seluruh tabel kontrak pada halaman overview menjadi test integration yang berjalan lewat cargo test. Rencananya dua tahap: pecah crate menjadi library dan binary agar test dapat mengimpor router, lalu uji request-response secara langsung memakai tower::ServiceExt::oneshot tanpa perlu membuka port. Setelah itu kita bangun biner release dan membuktikan biner tersebut melayani request di server sungguhan.
Langkah 1: Memisahkan Library dan Binary #
Cargo menyediakan dua target dalam satu crate: target library dan target binary. Test integration pada direktori tests/ hanya dapat mengimpor target library — berkas src/main.rs sebagai binary tidak terlihat dari luar crate. Karena itu kita pindahkan seluruh modul dan router ke src/lib.rs:
pub mod error;
pub mod handlers;
pub mod models;
pub mod state;
use axum::routing::get;
use axum::Router;
use state::AppState;
pub fn bangun_router() -> Router {
Router::new()
.route("/api/health", get(handlers::health))
.route(
"/api/tugas",
get(handlers::daftar_tugas).post(handlers::buat_tugas),
)
.route(
"/api/tugas/{id}",
get(handlers::ambil_tugas)
.put(handlers::perbarui_tugas)
.delete(handlers::hapus_tugas),
)
.with_state(AppState::baru())
}Sedangkan src/main.rs menyusut menjadi titik masuk murni:
use rest_api::bangun_router;
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
.await
.expect("gagal membuka alamat 127.0.0.1:8080");
println!("Server berjalan di http://127.0.0.1:8080");
axum::serve(listener, bangun_router())
.await
.expect("server berhenti");
}Dua hal berubah pada pemisahan ini. Pertama, baris 1-4 pada lib.rs memakai pub mod — modul harus publik agar test di luar crate dapat menjangkaunya. Kedua, main.rs kini memanggil rest_api::bangun_router(): nama crate rest-api menjadi identifier rest_api, persis seperti crate eksternal pada umumnya. Perilaku server tidak berubah sama sekali; cargo run tetap menghasilkan program yang sama.
Langkah 2: Menulis Test Integration #
Test kita perlu satu crate tambahan sebagai dev-dependency — dependensi yang hanya ikut dikompilasi saat cargo test, bukan saat cargo build. Tambahkan pada Cargo.toml:
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }Fitur "util" pada tower menyediakan trait ServiceExt beserta metode oneshot — alat untuk mengirim satu request ke router secara langsung tanpa jaringan. Sekarang buat berkas tests/integration.rs. Potongan berikut memuat dua helper serta tiga test representatif; versi lengkapnya ada pada Berkas Sumber:
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Router;
use rest_api::bangun_router;
use serde_json::{json, Value};
use tower::ServiceExt;
fn kirim(method: &str, uri: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("gagal membangun request")
}
async fn panggil(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
let response = app
.clone()
.oneshot(request)
.await
.expect("router gagal memproses request");
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("gagal membaca body");
if bytes.is_empty() {
return (status, Value::Null);
}
let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
(status, body)
}
#[tokio::test]
async fn daftar_kosong_mengembalikan_array_kosong() {
let app = bangun_router();
let (status, body) = panggil(&app, kirim("GET", "/api/tugas", json!(null))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!([]));
}
#[tokio::test]
async fn buat_tugas_mengembalikan_201_dan_lokasi() {
let response = bangun_router()
.oneshot(kirim(
"POST",
"/api/tugas",
json!({ "judul": "Belajar Rust" }),
))
.await
.expect("router gagal memproses request");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response.headers()["location"], "/api/tugas/1");
}
#[tokio::test]
async fn hapus_tugas_mengembalikan_204_lalu_404() {
let app = bangun_router();
panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Belajar axum" }))).await;
let (status, body) = panggil(&app, kirim("DELETE", "/api/tugas/1", json!(null))).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(body.is_null());
}Mari kita bedah mekanisme pengujian ini:
- Baris 8-15 (
fn kirim): pembangun request sintetis.Request::builder()merakit method, URI, dan header satu per satu;Body::from(body.to_string())mengubahValueserde_json menjadi body mentah. Helper ini membuat setiap test terbaca seperti satu bariscurl. - Baris 17-32 (
async fn panggil): inti pengujian.app.clone().oneshot(request)menyerahkan request langsung ke router sebagaitower::Service— tidak ada socket, tidak ada port, request diproses in-process dalam satu kali panggilan.to_bytesmembaca seluruh body response, danserde_json::from_slicemengubahnya kembali menjadiValuesehingga kita dapat membandingkannya denganassert_eq!. - Baris 19 (
.clone()pada router):oneshotmengonsumsi service yang dipanggil, padahal satu test membutuhkan beberapa request berurutan. Solusinya menyalin router terlebih dahulu — dan inilah fakta penting:RoutermengimplementasikanClone, serta salinannya berbagiAppStateyang sama karena state kita berisiArc. Request kedua pada testhapus_tugaskarenanya masih melihat tugas yang dibuat request pertama. - Baris 34 (
#[tokio::test]): padanan#[tokio::main]untuk test. Makro ini membangun runtime tokio per test, sehinggaawaitdapat dipakai di dalamnya dan tiap test berjalan pada runtime terpisah. - Baris 36 (
let app = bangun_router();): setiap test membangun router sendiri dengan state kosong segar. Test menjadi saling independen — tidak ada urutan eksekusi yang dibutuhkan, dan kegagalan satu test tidak menular ke test lain. - Baris 54 (
assert_eq!(response.headers()["location"], ...)): testbuat_tugasmemeriksa response mentah, bukan hasil helperpanggil, karena hanya response mentah yang masih menyimpan headerLocationsebelum body dibaca.
Empat test sisanya menguji jalur yang belum tersentuh, semuanya mengikuti pola yang sama:
| Test | Yang diverifikasi |
|---|---|
buat_tugas_menolak_judul_kosong | POST judul spasi → 400 + {"error":"judul tidak boleh kosong"} |
id_tidak_valid_ditolak | GET /api/tugas/abc → 400 + pesan IdTidakValid |
ambil_tugas_yang_tidak_ada_mengembalikan_404 | GET /api/tugas/99 → 404 + pesan TidakDitemukan |
perbarui_tugas_mengubah_judul_dan_status | PUT mengubah judul dan selesai, lalu GET membuktikan perubahan tersimpan |
Langkah 3: Menjalankan Test #
Saatnya menyerahkan seluruh pekerjaan verifikasi kepada cargo:
cargo testOutput yang Dihasilkan:
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
Running unittests src/lib.rs (target/debug/deps/rest_api-5618a81b2236ce56)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/rest_api-3900bc6194b018be)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration.rs (target/debug/deps/integration-cddb6f784943a131)
running 7 tests
test ambil_tugas_yang_tidak_ada_mengembalikan_404 ... ok
test buat_tugas_mengembalikan_201_dan_lokasi ... ok
test buat_tugas_menolak_judul_kosong ... ok
test daftar_kosong_mengembalikan_array_kosong ... ok
test hapus_tugas_mengembalikan_204_lalu_404 ... ok
test id_tidak_valid_ditolak ... ok
test perbarui_tugas_mengubah_judul_dan_status ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rest_api
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sBaris test result: ok. 7 passed menegaskan seluruh kontrak endpoint terverifikasi dalam waktu di bawah sedetik — jauh lebih cepat daripada sesi curl manual kita, dan dapat diulang setiap kali kode berubah. Cargo juga menjalankan tiga target test lain (unit test kedua crate dan doc-test) yang memang kosong karena seluruh pengujian kita berada di tests/integration.rs. Mulai sekarang, merusak satu pun baris kontrak — misalnya mengubah 204 menjadi 200 — akan langsung membalas test failed dengan nama test yang gagal.
Langkah 4: Membangun Biner Release #
Biner cargo build yang selama ini kita pakai adalah profil dev: cepat dikompilasi, tetapi besar dan tidak dioptimalkan. Untuk deployment kita bangun profil release, lalu aktifkan dua pengaturan ekstra pada Cargo.toml:
[profile.release]
strip = true
lto = truestrip = true membuang simbol debug dari biner sehingga ukurannya menyusut, sedangkan lto = true mengaktifkan link-time optimization yang mengoptimalkan lintas crate. Sekarang bangun bandingkan ukurannya:
cargo build --release
ls -lh target/debug/rest-api target/release/rest-apiOutput yang Dihasilkan:
Compiling axum v0.8.9
Compiling rest-api v0.1.0 (/tmp/w8a-rest-api)
Finished `release` profile [optimized] target(s) in 32.81s
-rwxrwxr-x 2 z z 33M Oct 13 23:44 target/debug/rest-api
-rwxrwxr-x 2 z z 1.2M Oct 13 23:45 target/release/rest-apiBiner release 1.2 MB — lebih dari 25 kali lebih kecil dari versi debug, dan mengandung seluruh runtime tokio, axum, serta serde di dalamnya. Buktikan biner ini melayani request seperti biasa:
./target/release/rest-apiOutput yang Dihasilkan:
Server berjalan di http://127.0.0.1:8080Dari terminal kedua:
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/health
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
-H "Content-Type: application/json" -d '{"judul": "Uji biner release"}'Output yang Dihasilkan:
{"status":"ok"}
HTTP 200
{"id":1,"judul":"Uji biner release","selesai":false}
HTTP 201Proses deployment selanjutnya sederhana: salin satu berkas target/release/rest-api ke server, lalu jalankan. Tidak ada interpreter, tidak ada dependensi runtime yang harus dipasang — selama sistem operasi target sama arsitekturnya dengan mesin build, biner langsung bekerja. Untuk arsitektur berbeda, cross-compilation tersedia lewat cargo build --release --target <target>, dan layanan produksi umumnya menjalankan biner di bawah supervisor seperti systemd atau container.
Ringkasan Proyek #
Empat bagian tuntas dan REST API manajemen tugas kita kini utuh: server tokio dan router axum (Bagian 1), penyimpanan Arc RwLock dengan lima endpoint CRUD (Bagian 2), validasi plus ApiError yang menjaga kontrak respons (Bagian 3), dan tujuh test integration yang menjaga semuanya tetap benar berbekal biner release 1.2 MB (Bagian 4). Materi Course Rust yang dulu terpisah-pisah — ownership di balik Arc, generic di balik extractor, async di balik axum::serve, serde di balik Json — kini bekerja dalam satu program yang hidup.
Arah pengembangan berikutnya terbuka lebar. Penyimpanan persisten dengan sqlx, middleware tower-http untuk logging dan CORS, autentikasi token, hingga containerization adalah langkah-langkah alami dari fondasi ini. Seluruh kode sumber proyek tersedia pada bagian Berkas Sumber di halaman overview proyek — unduh, jalankan cargo test, lalu kembangkan.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/Cargo.toml
Unduh berkas[package]
name = "rest-api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
[profile.release]
strip = true
lto = true
src/error.rs
Unduh berkasuse axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
pub enum ApiError {
TidakDitemukan { id: u64 },
Validasi(String),
IdTidakValid { nilai: String },
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, pesan) = match self {
ApiError::TidakDitemukan { id } => (
StatusCode::NOT_FOUND,
format!("Tugas dengan id {} tidak ditemukan", id),
),
ApiError::Validasi(pesan) => (StatusCode::BAD_REQUEST, pesan),
ApiError::IdTidakValid { nilai } => (
StatusCode::BAD_REQUEST,
format!("Id \"{}\" bukan angka yang valid", nilai),
),
};
(status, Json(json!({ "error": pesan }))).into_response()
}
}
src/handlers.rs
Unduh berkasuse axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use crate::error::ApiError;
use crate::models::{validasi_judul, BuatTugas, PerbaruiTugas, Tugas};
use crate::state::AppState;
pub async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
pub async fn daftar_tugas(State(state): State<AppState>) -> Json<Vec<Tugas>> {
Json(state.ambil_semua())
}
pub async fn buat_tugas(
State(state): State<AppState>,
Json(input): Json<BuatTugas>,
) -> Result<Response, ApiError> {
let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
let tugas = state.simpan(judul);
let lokasi = format!("/api/tugas/{}", tugas.id);
Ok((
StatusCode::CREATED,
[(header::LOCATION, lokasi)],
Json(tugas),
)
.into_response())
}
pub async fn ambil_tugas(
State(state): State<AppState>,
Path(nilai): Path<String>,
) -> Result<Json<Tugas>, ApiError> {
let id = parse_id(nilai)?;
match state.cari(id) {
Some(tugas) => Ok(Json(tugas)),
None => Err(ApiError::TidakDitemukan { id }),
}
}
pub async fn perbarui_tugas(
State(state): State<AppState>,
Path(nilai): Path<String>,
Json(input): Json<PerbaruiTugas>,
) -> Result<Json<Tugas>, ApiError> {
let id = parse_id(nilai)?;
let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
let input = PerbaruiTugas {
judul,
selesai: input.selesai,
};
match state.perbarui(id, input) {
Some(tugas) => Ok(Json(tugas)),
None => Err(ApiError::TidakDitemukan { id }),
}
}
pub async fn hapus_tugas(
State(state): State<AppState>,
Path(nilai): Path<String>,
) -> Result<StatusCode, ApiError> {
let id = parse_id(nilai)?;
match state.hapus(id) {
Some(_) => Ok(StatusCode::NO_CONTENT),
None => Err(ApiError::TidakDitemukan { id }),
}
}
fn parse_id(nilai: String) -> Result<u64, ApiError> {
nilai
.parse::<u64>()
.map_err(|_| ApiError::IdTidakValid { nilai })
}
src/lib.rs
Unduh berkaspub mod error;
pub mod handlers;
pub mod models;
pub mod state;
use axum::routing::get;
use axum::Router;
use state::AppState;
pub fn bangun_router() -> Router {
Router::new()
.route("/api/health", get(handlers::health))
.route(
"/api/tugas",
get(handlers::daftar_tugas).post(handlers::buat_tugas),
)
.route(
"/api/tugas/{id}",
get(handlers::ambil_tugas)
.put(handlers::perbarui_tugas)
.delete(handlers::hapus_tugas),
)
.with_state(AppState::baru())
}
src/main.rs
Unduh berkasuse rest_api::bangun_router;
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
.await
.expect("gagal membuka alamat 127.0.0.1:8080");
println!("Server berjalan di http://127.0.0.1:8080");
axum::serve(listener, bangun_router())
.await
.expect("server berhenti");
}
src/models.rs
Unduh berkasuse serde::{Deserialize, Serialize};
pub const JUDUL_MAX: usize = 140;
#[derive(Debug, Clone, Serialize)]
pub struct Tugas {
pub id: u64,
pub judul: String,
pub selesai: bool,
}
#[derive(Debug, Deserialize)]
pub struct BuatTugas {
pub judul: String,
}
#[derive(Debug, Deserialize)]
pub struct PerbaruiTugas {
pub judul: String,
pub selesai: bool,
}
pub fn validasi_judul(judul: &str) -> Result<String, String> {
let bersih = judul.trim();
if bersih.is_empty() {
return Err(String::from("judul tidak boleh kosong"));
}
if bersih.chars().count() > JUDUL_MAX {
return Err(format!("judul tidak boleh lebih dari {} karakter", JUDUL_MAX));
}
Ok(String::from(bersih))
}
src/state.rs
Unduh berkasuse std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use crate::models::{PerbaruiTugas, Tugas};
#[derive(Clone)]
pub struct AppState {
daftar: Arc<RwLock<HashMap<u64, Tugas>>>,
next_id: Arc<AtomicU64>,
}
impl AppState {
pub fn baru() -> Self {
Self {
daftar: Arc::new(RwLock::new(HashMap::new())),
next_id: Arc::new(AtomicU64::new(1)),
}
}
pub fn ambil_semua(&self) -> Vec<Tugas> {
let daftar = self.daftar.read().expect("RwLock daftar teracuni");
let mut tugas: Vec<Tugas> = daftar.values().cloned().collect();
tugas.sort_by_key(|t| t.id);
tugas
}
pub fn simpan(&self, judul: String) -> Tugas {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let tugas = Tugas {
id,
judul,
selesai: false,
};
self.daftar
.write()
.expect("RwLock daftar teracuni")
.insert(id, tugas.clone());
tugas
}
pub fn cari(&self, id: u64) -> Option<Tugas> {
self.daftar.read().expect("RwLock daftar teracuni").get(&id).cloned()
}
pub fn perbarui(&self, id: u64, input: PerbaruiTugas) -> Option<Tugas> {
let mut daftar = self.daftar.write().expect("RwLock daftar teracuni");
match daftar.get_mut(&id) {
None => None,
Some(tugas) => {
tugas.judul = input.judul;
tugas.selesai = input.selesai;
Some(tugas.clone())
}
}
}
pub fn hapus(&self, id: u64) -> Option<Tugas> {
self.daftar.write().expect("RwLock daftar teracuni").remove(&id)
}
}
tests/integration.rs
Unduh berkasuse axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Router;
use rest_api::bangun_router;
use serde_json::{json, Value};
use tower::ServiceExt;
fn kirim(method: &str, uri: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("gagal membangun request")
}
async fn panggil(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
let response = app
.clone()
.oneshot(request)
.await
.expect("router gagal memproses request");
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("gagal membaca body");
if bytes.is_empty() {
return (status, Value::Null);
}
let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
(status, body)
}
#[tokio::test]
async fn daftar_kosong_mengembalikan_array_kosong() {
let app = bangun_router();
let (status, body) = panggil(&app, kirim("GET", "/api/tugas", json!(null))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!([]));
}
#[tokio::test]
async fn buat_tugas_mengembalikan_201_dan_lokasi() {
let app = bangun_router();
let response = app
.oneshot(kirim(
"POST",
"/api/tugas",
json!({ "judul": "Belajar Rust" }),
))
.await
.expect("router gagal memproses request");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response.headers()["location"], "/api/tugas/1");
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("gagal membaca body");
let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
assert_eq!(
body,
json!({ "id": 1, "judul": "Belajar Rust", "selesai": false })
);
}
#[tokio::test]
async fn buat_tugas_menolak_judul_kosong() {
let app = bangun_router();
let (status, body) =
panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": " " }))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body, json!({ "error": "judul tidak boleh kosong" }));
}
#[tokio::test]
async fn id_tidak_valid_ditolak() {
let app = bangun_router();
let (status, body) = panggil(&app, kirim("GET", "/api/tugas/abc", json!(null))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body,
json!({ "error": "Id \"abc\" bukan angka yang valid" })
);
}
#[tokio::test]
async fn ambil_tugas_yang_tidak_ada_mengembalikan_404() {
let app = bangun_router();
let (status, body) = panggil(&app, kirim("GET", "/api/tugas/99", json!(null))).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body,
json!({ "error": "Tugas dengan id 99 tidak ditemukan" })
);
}
#[tokio::test]
async fn perbarui_tugas_mengubah_judul_dan_status() {
let app = bangun_router();
panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Kirim laporan bulanan" }))).await;
let (status, body) = panggil(
&app,
kirim(
"PUT",
"/api/tugas/1",
json!({ "judul": "Kirim laporan mingguan", "selesai": true }),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body,
json!({ "id": 1, "judul": "Kirim laporan mingguan", "selesai": true })
);
let (_, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
assert_eq!(body["selesai"], json!(true));
}
#[tokio::test]
async fn hapus_tugas_mengembalikan_204_lalu_404() {
let app = bangun_router();
panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Belajar axum" }))).await;
let (status, body) = panggil(&app, kirim("DELETE", "/api/tugas/1", json!(null))).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(body.is_null());
let (status, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body,
json!({ "error": "Tugas dengan id 1 tidak ditemukan" })
);
}