Mocking #

Mocking in Rust works differently than Java or Python — no reflection or monkey-patching. Instead, Rust uses two existing mechanisms in the language: traits as behavioral abstractions, and generics (or dyn Trait) as a way of injecting different implementations. This means testability must be designed in from the start — code that is tightly-coupled to a concrete implementation cannot be mocked without refactoring. This article discusses three levels of mocking: manual mock (no dependencies), mockall (mock with full expectations), and mockito (HTTP server mock). It closes with guidance on choosing the right approach for different situations.

Foundation: Trait as Seam for Testing #

Before you can mock, you need to seam — the point where the real implementation can be replaced by the test implementation. In Rust, this seam is trait. Code that relies on concrete implementations cannot be mocked:

// ANTI-PATTERN: tightly-coupled, cannot be mocked
struct LaporanKeuangan;

impl LaporanKeuangan {
    fn buat_laporan(&self) -> String {
        // Directly calls the real database — cannot be replaced in the test
        let data = Database::query("SELECT * FROM transaksi");
        format!("Total: {}", data.len())
    }
}

// CORRECT: loose-couple via trait — can be changed implementation
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)
    }
}

With this design, you can replace T with a real implementation in production and a mock implementation in test.


Manual Mock — No External Dependencies #

Mock manual is a trait implementation written by yourself for testing purposes. This is the simplest approach and doesn’t require any additional crates:

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;
}

// Real implementation — accessing the database
struct DatabasePengguna {
    // database connection...
}

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

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

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

// Service that uses the repository
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 — completely controlled by us
    struct MockRepositoriPengguna {
        data: HashMap<u64, String>,
        gagal_simpan: bool,  // flag for failure simulation
    }

    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 with Box<dyn Trait> #

Generic ZZL22ZZ produces the most efficient code (monomorphization), but sometimes you need to store multiple implementations in one collection or replace implementations at runtime. For that use Box<dyn Trait>:

produces the most efficient code (monomorphization), but sometimes you need to store multiple implementations in one collection or replace implementations at runtime. For that use 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 can store a variety of different implementations
    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 that records all sent messages
    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 with Auto Expectation #

mockall generates mock struct automatically from traits via proc-macro #[automock]. This mock can be configured to verify how many times a method is called, with what arguments, and what value it returns:

[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();

        // Configuration: when calculate_ppn is called with 100000.0,
        // refund 11000.0 (VAT 11%)
        mock.expect_hitung_ppn()
            .with(eq(100_000.0))
            .times(1)                          // must be called exactly 1 time
            .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 automatically verifies expectations when a mock is dropped
    }

    #[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);

        // calculate_ppn should NOT be called if the NPWP is invalid
        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() — accepts any argument
        mock.expect_hitung_ppn()
            .with(gt(0.0))         // argument must be more than 0
            .returning(|h| h * 0.11);

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

Mockall for 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.example.com/user/42"))
            .times(1)
            .returning(|_| Ok(r#"{"id": 42, "nama": "Budi"}"#.to_string()));

        let service = ApiService {
            klien: mock,
            base_url: "https://api.example.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.example.com".to_string(),
        };

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

mockito — Mock HTTP Server #

For test code that makes HTTP requests to external APIs, mockito provides a local HTTP server that can be configured:

[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" cannot be parsed as 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)  // must be called exactly 1 time
            .create();

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

        mock.assert();  // verifies that the request was actually sent
    }
}

Spy Pattern — Log Interactions Without Changing Behavior #

Spy is a mock variation that delegates to the real implementation but also records the interactions:

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: record logs but still delegate to real logger
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); // fixed output to console
    }
}

#[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"));
    }
}

When to Choose Which Approach #

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

Summary #

  • Testability must be designed in from the start — code that directly uses concrete implementations (not traits) cannot be mocked without refactoring. Injection via traits is the foundation.
  • Two ways of dependency injection — generic <T: Trait> produces the most efficient (zero-cost) code, Box<dyn Trait> is more flexible for heterogeneous collections and runtime injection.
  • Manual mocks are sufficient for most cases — structs that implement traits with fully controlled data, no additional dependencies needed.
  • Arc<Mutex<Vec<...>>> to record interaction — inject along with mock, access from test after operation completes to verify what was called.
  • mockall for automatic expectation verification.times(n) verifies call count, .with(predicate) verifies arguments. Verification occurs when the mock is dropped.
  • mockall supports async traits with the combination #[automock] + #[async_trait]. Return value async using .returning(|_| Box::pin(async { ... })).
  • mockito for mock HTTP server — suitable for testing code that performs HTTP requests, especially those whose URLs are not easy to inject. Use .expect(n) and .assert() for verification.
  • Spy pattern records without changing behavior — delegates to real implementation while logging all interactions. Useful for test logging and audit trails.


← Previous: Unit Test   Next: JSON →

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