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.mockallfor automatic expectation verification —.times(n)verifies call count,.with(predicate)verifies arguments. Verification occurs when the mock is dropped.mockallsupports async traits with the combination#[automock]+#[async_trait]. Return value async using.returning(|_| Box::pin(async { ... })).mockitofor 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.