Mocking

Mocking #

Mocking di Rust bekerja dengan cara yang berbeda dari Java atau Python — tidak ada reflection atau monkey-patching. Gantinya, Rust menggunakan dua mekanisme yang sudah ada di bahasa: trait sebagai abstraksi perilaku, dan generik (atau dyn Trait) sebagai cara menyuntikkan implementasi berbeda. Ini berarti testability harus dirancang dari awal — kode yang tightly-coupled ke implementasi konkret tidak bisa di-mock tanpa refaktor. Artikel ini membahas tiga level mocking: mock manual (tanpa dependensi), mockall (mock dengan ekspektasi penuh), dan mockito (mock server HTTP). Ditutup dengan panduan memilih pendekatan yang tepat untuk situasi yang berbeda.

Fondasi: Trait sebagai Seam untuk Testing #

Sebelum bisa melakukan mocking, kamu perlu seam — titik di mana implementasi nyata bisa diganti dengan implementasi test. Di Rust, seam ini adalah trait. Kode yang bergantung pada implementasi konkret tidak bisa di-mock:

// ANTI-PATTERN: tightly-coupled, tidak bisa di-mock
struct LaporanKeuangan;

impl LaporanKeuangan {
    fn buat_laporan(&self) -> String {
        // Langsung memanggil database nyata — tidak bisa diganti di test
        let data = Database::query("SELECT * FROM transaksi");
        format!("Total: {}", data.len())
    }
}

// BENAR: longgar-couple via trait — bisa diganti implementasinya
trait SumberData {
    fn ambil_transaksi(&self) -> Vec<String>;
    fn hitung_total(&self) -> f64;
}

struct LaporanKeuangan<T: SumberData> {
    sumber: T,
}

impl<T: SumberData> LaporanKeuangan<T> {
    fn buat_laporan(&self) -> String {
        let transaksi = self.sumber.ambil_transaksi();
        let total = self.sumber.hitung_total();
        format!("{} transaksi, total: Rp{:.0}", transaksi.len(), total)
    }
}

Dengan desain ini, kamu bisa mengganti T dengan implementasi nyata di produksi dan implementasi mock di test.


Mock Manual — Tanpa Dependensi Eksternal #

Mock manual adalah implementasi trait yang ditulis sendiri untuk keperluan test. Ini pendekatan paling sederhana dan tidak butuh crate tambahan:

use std::collections::HashMap;

trait RepositoriPengguna {
    fn cari_berdasarkan_id(&self, id: u64) -> Option<String>;
    fn simpan(&mut self, id: u64, nama: String) -> bool;
    fn hapus(&mut self, id: u64) -> bool;
}

// Implementasi nyata — mengakses database
struct DatabasePengguna {
    // koneksi database...
}

impl RepositoriPengguna for DatabasePengguna {
    fn cari_berdasarkan_id(&self, id: u64) -> Option<String> {
        // Query ke database nyata
        Some(format!("User-{}", id)) // disederhanakan
    }

    fn simpan(&mut self, _id: u64, _nama: String) -> bool {
        true // Insert ke database
    }

    fn hapus(&mut self, _id: u64) -> bool {
        true // Delete dari database
    }
}

// Service yang menggunakan repositori
struct LayananPengguna<R: RepositoriPengguna> {
    repositori: R,
}

impl<R: RepositoriPengguna> LayananPengguna<R> {
    fn profil(&self, id: u64) -> String {
        match self.repositori.cari_berdasarkan_id(id) {
            Some(nama) => format!("Profil: {}", nama),
            None => String::from("Pengguna tidak ditemukan"),
        }
    }

    fn daftarkan(&mut self, id: u64, nama: &str) -> Result<(), String> {
        if nama.trim().is_empty() {
            return Err(String::from("Nama tidak boleh kosong"));
        }
        if self.repositori.simpan(id, nama.to_string()) {
            Ok(())
        } else {
            Err(String::from("Gagal menyimpan ke database"))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Mock manual — dikontrol sepenuhnya oleh kita
    struct MockRepositoriPengguna {
        data: HashMap<u64, String>,
        gagal_simpan: bool,  // flag untuk simulasi kegagalan
    }

    impl MockRepositoriPengguna {
        fn baru() -> Self {
            MockRepositoriPengguna {
                data: HashMap::new(),
                gagal_simpan: false,
            }
        }

        fn dengan_data(mut self, id: u64, nama: &str) -> Self {
            self.data.insert(id, nama.to_string());
            self
        }

        fn simulasi_gagal_simpan(mut self) -> Self {
            self.gagal_simpan = true;
            self
        }
    }

    impl RepositoriPengguna for MockRepositoriPengguna {
        fn cari_berdasarkan_id(&self, id: u64) -> Option<String> {
            self.data.get(&id).cloned()
        }

        fn simpan(&mut self, id: u64, nama: String) -> bool {
            if self.gagal_simpan {
                return false;
            }
            self.data.insert(id, nama);
            true
        }

        fn hapus(&mut self, id: u64) -> bool {
            self.data.remove(&id).is_some()
        }
    }

    #[test]
    fn test_profil_ditemukan() {
        let mock = MockRepositoriPengguna::baru().dengan_data(1, "Budi");
        let layanan = LayananPengguna { repositori: mock };
        assert_eq!(layanan.profil(1), "Profil: Budi");
    }

    #[test]
    fn test_profil_tidak_ditemukan() {
        let mock = MockRepositoriPengguna::baru();
        let layanan = LayananPengguna { repositori: mock };
        assert_eq!(layanan.profil(99), "Pengguna tidak ditemukan");
    }

    #[test]
    fn test_daftarkan_nama_kosong() {
        let mock = MockRepositoriPengguna::baru();
        let mut layanan = LayananPengguna { repositori: mock };
        let hasil = layanan.daftarkan(1, "  ");
        assert!(hasil.is_err());
        assert_eq!(hasil.unwrap_err(), "Nama tidak boleh kosong");
    }

    #[test]
    fn test_daftarkan_gagal_database() {
        let mock = MockRepositoriPengguna::baru().simulasi_gagal_simpan();
        let mut layanan = LayananPengguna { repositori: mock };
        let hasil = layanan.daftarkan(1, "Sari");
        assert!(hasil.is_err());
    }
}

Dependency Injection dengan Box<dyn Trait> #

Generik menghasilkan kode yang paling efisien (monomorphization), tapi kadang kamu perlu menyimpan berbagai implementasi dalam satu koleksi atau mengganti implementasi saat runtime. Untuk itu gunakan Box<dyn Trait>:

trait Notifikasi: Send + Sync {
    fn kirim(&self, pesan: &str) -> Result<(), String>;
}

struct NotifikasiEmail { alamat: String }
struct NotifikasiSMS { nomor: String }

impl Notifikasi for NotifikasiEmail {
    fn kirim(&self, pesan: &str) -> Result<(), String> {
        println!("Email ke {}: {}", self.alamat, pesan);
        Ok(())
    }
}

impl Notifikasi for NotifikasiSMS {
    fn kirim(&self, pesan: &str) -> Result<(), String> {
        println!("SMS ke {}: {}", self.nomor, pesan);
        Ok(())
    }
}

struct SistemNotifikasi {
    // Vec bisa menyimpan berbagai implementasi berbeda
    pengirim: Vec<Box<dyn Notifikasi>>,
}

impl SistemNotifikasi {
    fn baru() -> Self {
        SistemNotifikasi { pengirim: Vec::new() }
    }

    fn tambah(mut self, n: Box<dyn Notifikasi>) -> Self {
        self.pengirim.push(n);
        self
    }

    fn broadcast(&self, pesan: &str) -> Vec<Result<(), String>> {
        self.pengirim.iter().map(|n| n.kirim(pesan)).collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    // Mock yang merekam semua pesan yang dikirim
    struct MockNotifikasi {
        pesan_terkirim: Arc<Mutex<Vec<String>>>,
        harus_gagal: bool,
    }

    impl MockNotifikasi {
        fn baru() -> (Self, Arc<Mutex<Vec<String>>>) {
            let rekaman = Arc::new(Mutex::new(Vec::new()));
            (MockNotifikasi {
                pesan_terkirim: Arc::clone(&rekaman),
                harus_gagal: false,
            }, rekaman)
        }
    }

    impl Notifikasi for MockNotifikasi {
        fn kirim(&self, pesan: &str) -> Result<(), String> {
            if self.harus_gagal {
                return Err(String::from("Gagal kirim"));
            }
            self.pesan_terkirim.lock().unwrap().push(pesan.to_string());
            Ok(())
        }
    }

    #[test]
    fn test_broadcast_ke_semua_pengirim() {
        let (mock1, rekaman1) = MockNotifikasi::baru();
        let (mock2, rekaman2) = MockNotifikasi::baru();

        let sistem = SistemNotifikasi::baru()
            .tambah(Box::new(mock1))
            .tambah(Box::new(mock2));

        sistem.broadcast("Sistem akan maintenance");

        assert_eq!(rekaman1.lock().unwrap().len(), 1);
        assert_eq!(rekaman2.lock().unwrap().len(), 1);
        assert!(rekaman1.lock().unwrap()[0].contains("maintenance"));
    }
}

mockall — Mock dengan Ekspektasi Otomatis #

mockall menghasilkan mock struct secara otomatis dari trait via proc-macro #[automock]. Mock ini bisa dikonfigurasi untuk memverifikasi berapa kali method dipanggil, dengan argumen apa, dan mengembalikan nilai apa:

[dev-dependencies]
mockall = "0.12"
use mockall::predicate::*;
use mockall::automock;

#[automock]
trait KalkulatorPajak {
    fn hitung_ppn(&self, harga: f64) -> f64;
    fn hitung_pph(&self, penghasilan: f64, tarif: f64) -> f64;
    fn validasi_npwp(&self, npwp: &str) -> bool;
}

struct ProsesPembayaran<K: KalkulatorPajak> {
    kalkulator: K,
}

impl<K: KalkulatorPajak> ProsesPembayaran<K> {
    fn hitung_total(&self, harga_barang: f64) -> f64 {
        let ppn = self.kalkulator.hitung_ppn(harga_barang);
        harga_barang + ppn
    }

    fn proses_dengan_npwp(&self, npwp: &str, harga: f64) -> Result<f64, String> {
        if !self.kalkulator.validasi_npwp(npwp) {
            return Err(format!("NPWP '{}' tidak valid", npwp));
        }
        Ok(self.hitung_total(harga))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hitung_total_dengan_ppn() {
        let mut mock = MockKalkulatorPajak::new();

        // Konfigurasi: ketika hitung_ppn dipanggil dengan 100000.0,
        // kembalikan 11000.0 (PPN 11%)
        mock.expect_hitung_ppn()
            .with(eq(100_000.0))
            .times(1)                          // harus dipanggil tepat 1 kali
            .returning(|harga| harga * 0.11);

        let proses = ProsesPembayaran { kalkulator: mock };
        let total = proses.hitung_total(100_000.0);

        assert_eq!(total, 111_000.0);
        // mockall otomatis memverifikasi ekspektasi saat mock di-drop
    }

    #[test]
    fn test_proses_npwp_valid() {
        let mut mock = MockKalkulatorPajak::new();

        mock.expect_validasi_npwp()
            .with(eq("12.345.678.9-012.345"))
            .times(1)
            .return_const(true);

        mock.expect_hitung_ppn()
            .times(1)
            .returning(|h| h * 0.11);

        let proses = ProsesPembayaran { kalkulator: mock };
        let hasil = proses.proses_dengan_npwp("12.345.678.9-012.345", 200_000.0);
        assert!(hasil.is_ok());
        assert_eq!(hasil.unwrap(), 222_000.0);
    }

    #[test]
    fn test_proses_npwp_tidak_valid() {
        let mut mock = MockKalkulatorPajak::new();

        mock.expect_validasi_npwp()
            .with(eq("npwp-salah"))
            .times(1)
            .return_const(false);

        // hitung_ppn TIDAK boleh dipanggil jika NPWP tidak valid
        mock.expect_hitung_ppn().times(0);

        let proses = ProsesPembayaran { kalkulator: mock };
        let hasil = proses.proses_dengan_npwp("npwp-salah", 100_000.0);
        assert!(hasil.is_err());
    }

    #[test]
    fn test_dengan_argumen_sembarang() {
        let mut mock = MockKalkulatorPajak::new();

        // any() — menerima argumen apapun
        mock.expect_hitung_ppn()
            .with(gt(0.0))         // argumen harus lebih dari 0
            .returning(|h| h * 0.11);

        let proses = ProsesPembayaran { kalkulator: mock };
        assert_eq!(proses.hitung_total(50_000.0), 55_500.0);
    }
}

Mockall untuk Async Trait #

[dev-dependencies]
mockall = "0.12"
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
use async_trait::async_trait;
use mockall::automock;

#[automock]
#[async_trait]
trait KlienHttp: Send + Sync {
    async fn get(&self, url: &str) -> Result<String, String>;
    async fn post(&self, url: &str, body: &str) -> Result<String, String>;
}

struct ApiService<H: KlienHttp> {
    klien: H,
    base_url: String,
}

impl<H: KlienHttp> ApiService<H> {
    async fn ambil_pengguna(&self, id: u64) -> Result<String, String> {
        let url = format!("{}/pengguna/{}", self.base_url, id);
        self.klien.get(&url).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_ambil_pengguna_berhasil() {
        let mut mock = MockKlienHttp::new();
        mock.expect_get()
            .with(mockall::predicate::eq("https://api.contoh.com/pengguna/42"))
            .times(1)
            .returning(|_| Ok(r#"{"id": 42, "nama": "Budi"}"#.to_string()));

        let service = ApiService {
            klien: mock,
            base_url: "https://api.contoh.com".to_string(),
        };

        let hasil = service.ambil_pengguna(42).await;
        assert!(hasil.is_ok());
        assert!(hasil.unwrap().contains("Budi"));
    }

    #[tokio::test]
    async fn test_ambil_pengguna_gagal() {
        let mut mock = MockKlienHttp::new();
        mock.expect_get()
            .times(1)
            .returning(|_| Err("Koneksi timeout".to_string()));

        let service = ApiService {
            klien: mock,
            base_url: "https://api.contoh.com".to_string(),
        };

        let hasil = service.ambil_pengguna(1).await;
        assert!(hasil.is_err());
        assert_eq!(hasil.unwrap_err(), "Koneksi timeout");
    }
}

mockito — Mock Server HTTP #

Untuk test kode yang melakukan HTTP request ke API eksternal, mockito menyediakan server HTTP lokal yang bisa dikonfigurasi:

[dev-dependencies]
mockito = "1"
reqwest = { version = "0.11", features = ["blocking"] }
fn ambil_nilai_tukar(base_url: &str, dari: &str, ke: &str) -> Result<f64, String> {
    let url = format!("{}/rates?from={}&to={}", base_url, dari, ke);
    let resp = reqwest::blocking::get(&url)
        .map_err(|e| e.to_string())?
        .text()
        .map_err(|e| e.to_string())?;

    resp.parse::<f64>().map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use mockito::Server;

    #[test]
    fn test_ambil_nilai_tukar_berhasil() {
        let mut server = Server::new();

        let _mock = server.mock("GET", "/rates?from=USD&to=IDR")
            .with_status(200)
            .with_header("content-type", "text/plain")
            .with_body("15800.50")
            .create();

        let hasil = ambil_nilai_tukar(&server.url(), "USD", "IDR");
        assert!(hasil.is_ok());
        assert!((hasil.unwrap() - 15800.50).abs() < 0.01);
    }

    #[test]
    fn test_server_error() {
        let mut server = Server::new();

        let _mock = server.mock("GET", "/rates?from=USD&to=IDR")
            .with_status(503)
            .with_body("Service Unavailable")
            .create();

        let hasil = ambil_nilai_tukar(&server.url(), "USD", "IDR");
        assert!(hasil.is_err()); // "Service Unavailable" tidak bisa di-parse sebagai f64
    }

    #[test]
    fn test_verifikasi_request_dikirim() {
        let mut server = Server::new();

        let mock = server.mock("GET", "/rates?from=EUR&to=JPY")
            .with_status(200)
            .with_body("162.45")
            .expect(1)  // harus dipanggil tepat 1 kali
            .create();

        let _ = ambil_nilai_tukar(&server.url(), "EUR", "JPY");

        mock.assert();  // verifikasi bahwa request benar-benar dikirim
    }
}

Spy Pattern — Catat Interaksi Tanpa Ubah Perilaku #

Spy adalah variasi mock yang mendelegasikan ke implementasi nyata tapi juga merekam interaksinya:

use std::sync::{Arc, Mutex};

trait Logger {
    fn log(&self, level: &str, pesan: &str);
}

struct LoggerKonsol;
impl Logger for LoggerKonsol {
    fn log(&self, level: &str, pesan: &str) {
        println!("[{}] {}", level, pesan);
    }
}

// Spy: rekam log tapi tetap delegasikan ke logger nyata
struct SpyLogger {
    delegate: Box<dyn Logger>,
    catatan: Arc<Mutex<Vec<(String, String)>>>,
}

impl Logger for SpyLogger {
    fn log(&self, level: &str, pesan: &str) {
        self.catatan.lock().unwrap().push((level.to_string(), pesan.to_string()));
        self.delegate.log(level, pesan); // tetap output ke konsol
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_log_dicatat() {
        let catatan = Arc::new(Mutex::new(Vec::new()));
        let spy = SpyLogger {
            delegate: Box::new(LoggerKonsol),
            catatan: Arc::clone(&catatan),
        };

        spy.log("INFO", "Aplikasi dimulai");
        spy.log("WARN", "Memori hampir penuh");
        spy.log("ERROR", "Koneksi gagal");

        let logs = catatan.lock().unwrap();
        assert_eq!(logs.len(), 3);
        assert_eq!(logs[0], ("INFO".to_string(), "Aplikasi dimulai".to_string()));
        assert!(logs.iter().any(|(level, _)| level == "ERROR"));
    }
}

Kapan Memilih Pendekatan Mana #

Mock Manual:
  ✓ Kontrol penuh atas perilaku mock
  ✓ Tidak butuh dependensi tambahan
  ✓ Bisa simulasikan kondisi error yang kompleks
  ✓ Cocok untuk trait dengan sedikit method
  ✗ Lebih verbose untuk trait dengan banyak method

mockall:
  ✓ Otomatis generate mock dari trait
  ✓ Verifikasi ekspektasi (berapa kali dipanggil, argumen apa)
  ✓ Mendukung async trait
  ✓ Predicate kaya (eq, gt, lt, any, always, dll.)
  ✗ Menambah dependensi dan waktu kompilasi

mockito:
  ✓ Mock HTTP server nyata — tidak perlu ubah kode produksi
  ✓ Test kode yang tidak bisa di-inject (hardcoded URL)
  ✓ Verifikasi request yang diterima server
  ✗ Hanya untuk HTTP — tidak berguna untuk dependensi non-HTTP

Ringkasan #

  • Testability harus dirancang sejak awal — kode yang langsung menggunakan implementasi konkret (bukan trait) tidak bisa di-mock tanpa refaktor. Injeksi via trait adalah fondasinya.
  • Dua cara injeksi dependensi — generik <T: Trait> menghasilkan kode paling efisien (zero-cost), Box<dyn Trait> lebih fleksibel untuk koleksi heterogen dan injeksi runtime.
  • Mock manual cukup untuk banyak kasus — struct yang mengimplementasikan trait dengan data yang dikontrol sepenuhnya, tidak butuh dependensi tambahan.
  • Arc<Mutex<Vec<...>>> untuk merekam interaksi — inject bersama mock, akses dari test setelah operasi selesai untuk verifikasi apa yang dipanggil.
  • mockall untuk verifikasi ekspektasi otomatis.times(n) memverifikasi jumlah pemanggilan, .with(predicate) memverifikasi argumen. Verifikasi terjadi saat mock di-drop.
  • mockall mendukung async trait dengan kombinasi #[automock] + #[async_trait]. Return value async menggunakan .returning(|_| Box::pin(async { ... })).
  • mockito untuk mock server HTTP — cocok untuk test kode yang melakukan HTTP request, terutama yang URL-nya tidak mudah di-inject. Gunakan .expect(n) dan .assert() untuk verifikasi.
  • Spy pattern merekam tanpa mengubah perilaku — mendelegasikan ke implementasi nyata sambil mencatat semua interaksi. Berguna untuk test logging dan audit trail.

← Sebelumnya: Unit Test   Berikutnya: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact