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.mockalluntuk verifikasi ekspektasi otomatis —.times(n)memverifikasi jumlah pemanggilan,.with(predicate)memverifikasi argumen. Verifikasi terjadi saat mock di-drop.mockallmendukung async trait dengan kombinasi#[automock]+#[async_trait]. Return value async menggunakan.returning(|_| Box::pin(async { ... })).mockitountuk 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.