Akar Masalah: Flaky Test pada Integrasi HTTP Eksternal

Pengujian integrasi yang memanggil HTTP service eksternal pihak ketiga secara riil sering memicu flaky test. Latensi jaringan, limitasi rate limit, atau ketidakstabilan environment sandbox di luar kendali developer merusak determinisme pipeline CI/CD. Solusi konvensional seperti mocking di level Rust trait sering kali melewatkan bug pada layer serialisasi, header parsing, dan penanganan HTTP status code aktual.

Crate wiremock menyediakan HTTP mock server lokal berbasis Tokio. Server ini berjalan pada loopback interface dengan port dinamis, memungkinkan pengujian transport layer HTTP secara penuh tanpa ketergantungan pada jaringan eksternal.

Konfigurasi Dependensi Cargo.toml

Tambahkan wiremock ke dalam [dev-dependencies] dan pastikan Actix Web beserta HTTP client (misalnya reqwest) sudah terpasang.

[dependencies]
actix-web = "4"
reqwest = { version = "0.11", default-features = false, features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

[dev-dependencies]
wiremock = "0.5"

Desain Arsitektur: Dynamic Base URL Injection

Agar service dapat diarahkan ke MockServer saat pengujian, URL eksternal tidak boleh di-hardcode. Masukkan base URL ke dalam configuration state yang dibagikan melalui web::Data.

use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
pub struct AppConfig {
    pub external_api_url: String,
}

#[derive(Serialize, Deserialize)]
pub struct PaymentRequest {
    pub order_id: String,
    pub amount: u64,
}

#[derive(Serialize, Deserialize)]
pub struct PaymentResponse {
    pub status: String,
}

pub async fn process_payment(
    config: web::Data<AppConfig>,
    payload: web::Json<PaymentRequest>,
) -> impl Responder {
    let client = reqwest::Client::new();
    let url = format!("{}/v1/charge", config.external_api_url);

    let res = client
        .post(&url)
        .header("Authorization", "Bearer token-prod-xyz")
        .json(&payload.into_inner())
        .send()
        .await;

    match res {
        Ok(response) if response.status().is_success() => {
            let data: PaymentResponse = response.json().await.unwrap_or(PaymentResponse {
                status: "failed_parse".into(),
            });
            HttpResponse::Ok().json(data)
        }
        Ok(response) => HttpResponse::build(response.status()).finish(),
        Err(_) => HttpResponse::GatewayTimeout().finish(),
    }
}

Setup MockServer dan Request Matchers

Dalam fungsi pengujian, inisialisasi MockServer::start().await. Crate wiremock menyediakan berbagai matcher bawaan seperti method, path, header, dan body_json untuk memverifikasi kontrak request sebelum mengembalikan stub response.

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{test, App};
    use serde_json::json;
    use wiremock::matchers::{body_json, header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[actix_web::test]
    async fn test_process_payment_success() {
        let mock_server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/charge"))
            .and(header("Authorization", "Bearer token-prod-xyz"))
            .and(body_json(json!({
                "order_id": "ORD-101",
                "amount": 50000
            })))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "status": "success"
            })))
            .expect(1)
            .mount(&mock_server)
            .await;

        let app_config = AppConfig {
            external_api_url: mock_server.uri(),
        };

        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(app_config))
                .route("/checkout", web::post().to(process_payment)),
        )
        .await;

        let req = test::TestRequest::post()
            .uri("/checkout")
            .set_json(&PaymentRequest {
                order_id: "ORD-101".into(),
                amount: 50000,
            })
            .to_request();

        let resp = test::call_service(&app, req).await;
        assert!(resp.status().is_success());
    }
}

Simulasi Downstream Error dan Timeout

Keandalan microservice bergantung pada cara menangani skenario downstream failure. Gunakan ResponseTemplate::new(500) untuk error server pihak ketiga, atau set_delay untuk menyimulasikan koneksi lambat yang memicu timeout.

    use std::time::Duration;

    #[actix_web::test]
    async fn test_downstream_timeout_handling() {
        let mock_server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/charge"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_delay(Duration::from_secs(3))
            )
            .expect(1)
            .mount(&mock_server)
            .await;

        // Jalankan request Actix Web dengan client timeout rendah
        // Pastikan handler merespons GatewayTimeout (504)
    }

Isolasi Eksekusi Paralel via mount_as_scoped

Secara default, Rust menjalankan tes unit/integrasi secara paralel (multi-threaded). Jika tes berbagi satu instance MockServer, terjadi race condition di mana matcher dari satu tes mencocokkan request dari tes lain.

Metode mount_as_scoped menyelesaikan masalah ini dengan memanfaatkan pola RAII (Resource Acquisition Is Initialization). Guard yang dihasilkan akan otomatis mencabut (unmount) mock dari server saat variabel guard keluar dari scope eksekusi.

    #[actix_web::test]
    async fn test_isolated_scope() {
        let mock_server = MockServer::start().await;

        {
            let _guard = Mock::given(method("POST"))
                .and(path("/v1/charge"))
                .respond_with(ResponseTemplate::new(500))
                .expect(1)
                .mount_as_scoped(&mock_server)
                .await;

            // Request dieksekusi di sini menghasilkan status 500
        }

        // Mock otomatis dideregister setelah guard drop
        // Request selanjutnya ke mock_server tidak akan match ekspektasi di atas
    }
Catatan: Praktik terbaik untuk isolasi absolut adalah membuat instance MockServer::start().await baru di dalam setiap fungsi tes. Overhead pembuatan MockServer lokal pada port acak sangat kecil dan sepenuhnya mencegah tumpang tindih state antar worker thread.