Unit Test

Unit Test #

Testing di Rust adalah warga kelas satu — bukan pemikiran belakangan. Compiler Rust sendiri sudah mendorong kamu menulis test: cargo test bisa dijalankan di proyek mana pun tanpa konfigurasi tambahan, doc test berjalan otomatis dari komentar dokumentasi, dan sistem ownership membuat banyak kelas bug yang biasanya butuh test khusus (use-after-free, null dereference) menjadi mustahil di compile time. Artikel ini membahas semua mekanisme testing bawaan Rust — dari unit test dalam satu file hingga integration test di direktori terpisah, async test, dan benchmarking dengan criterion.

Anatomi Unit Test Rust #

Unit test di Rust hidup berdampingan dengan kode yang diuji dalam satu file, di dalam modul yang dibungkus #[cfg(test)]. Modul ini hanya dikompilasi saat cargo test dijalankan — tidak ikut masuk ke binary produksi:

// src/lib.rs atau src/modul.rs

pub fn tambah(a: i32, b: i32) -> i32 {
    a + b
}

pub fn bagi(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

pub fn kata_palindrom(kata: &str) -> bool {
    let bersih: String = kata
        .chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_lowercase().next().unwrap())
        .collect();
    bersih == bersih.chars().rev().collect::<String>()
}

// Modul test — hanya dikompilasi saat cargo test
#[cfg(test)]
mod tests {
    use super::*;  // akses semua fungsi publik dan privat dari modul induk

    #[test]
    fn test_tambah_positif() {
        assert_eq!(tambah(2, 3), 5);
    }

    #[test]
    fn test_tambah_negatif() {
        assert_eq!(tambah(-1, -1), -2);
    }

    #[test]
    fn test_tambah_nol() {
        assert_eq!(tambah(0, 0), 0);
    }

    #[test]
    fn test_palindrom() {
        assert!(kata_palindrom("kasur rusak"));
        assert!(kata_palindrom("Kasur Rusak"));  // case-insensitive
        assert!(!kata_palindrom("Rust"));
    }
}

use super::* penting: ia mengimpor semua item dari modul induk — termasuk fungsi dan tipe yang privat (pub atau tidak). Unit test memang sengaja diizinkan mengakses detail implementasi yang tidak terekspos ke luar.


Semua Macro Assertion #

assert!, assert_eq!, assert_ne! #

#[cfg(test)]
mod tests {
    #[test]
    fn demo_assertion() {
        // assert! — kondisi harus true
        assert!(2 + 2 == 4);
        assert!("halo".starts_with("ha"));

        // assert_eq! — dua nilai harus sama (butuh PartialEq + Debug)
        assert_eq!(2 + 2, 4);
        assert_eq!(vec![1, 2, 3], vec![1, 2, 3]);
        assert_eq!("hello".to_uppercase(), "HELLO");

        // assert_ne! — dua nilai harus berbeda
        assert_ne!(2 + 2, 5);
        assert_ne!("a", "b");

        // Semua assertion bisa diberi pesan kustom sebagai argumen terakhir
        let x = 42;
        assert_eq!(x, 42, "x seharusnya 42, tapi nilainya {}", x);
        assert!(x > 0, "x ({}) harus positif", x);
    }
}

Pesan Kustom — Debugging Test yang Gagal #

Pesan kustom sangat berguna saat test gagal dan kamu perlu tahu konteks lebih:

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

    fn hitung_diskon(harga: f64, persen: f64) -> f64 {
        harga * (1.0 - persen / 100.0)
    }

    #[test]
    fn test_diskon_berbagai_skenario() {
        let kasus = vec![
            (100.0, 10.0, 90.0, "diskon 10%"),
            (200.0, 50.0, 100.0, "diskon 50%"),
            (150.0, 0.0, 150.0, "tanpa diskon"),
        ];

        for (harga, persen, ekspektasi, deskripsi) in kasus {
            let hasil = hitung_diskon(harga, persen);
            assert!(
                (hasil - ekspektasi).abs() < 0.001,
                "GAGAL untuk {}: harga={}, diskon={}%, expected={}, got={}",
                deskripsi, harga, persen, ekspektasi, hasil
            );
        }
    }
}

#[should_panic] — Test yang Diharapkan Panic #

pub fn bagi_integer(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Tidak bisa membagi dengan nol");
    }
    a / b
}

pub fn ambil_elemen(v: &[i32], i: usize) -> i32 {
    if i >= v.len() {
        panic!("Indeks {} melebihi panjang array {}", i, v.len());
    }
    v[i]
}

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

    // Test ini lulus jika fungsi panic
    #[test]
    #[should_panic]
    fn test_bagi_nol_panic() {
        bagi_integer(10, 0);
    }

    // Lebih ketat: verifikasi pesan panic yang spesifik
    #[test]
    #[should_panic(expected = "Tidak bisa membagi dengan nol")]
    fn test_bagi_nol_pesan_benar() {
        bagi_integer(10, 0);
    }

    // ANTI-PATTERN: should_panic terlalu longgar — tidak verifikasi pesan
    // Test lulus meski panic terjadi karena alasan lain
    #[test]
    #[should_panic]  // ini lulus meski panic karena index out of bounds
    fn test_terlalu_longgar() {
        let v = vec![1, 2, 3];
        ambil_elemen(&v, 99);  // panic karena index, bukan karena logika yang kita test
    }

    // BENAR: gunakan expected untuk lebih presisi
    #[test]
    #[should_panic(expected = "Indeks 99")]
    fn test_indeks_di_luar_batas() {
        let v = vec![1, 2, 3];
        ambil_elemen(&v, 99);
    }
}

Test yang Mengembalikan Result #

Daripada panic! manual, test bisa mengembalikan Result<(), E> — operator ? bekerja di dalamnya:

use std::num::ParseIntError;

fn parse_dan_kali_dua(s: &str) -> Result<i32, ParseIntError> {
    let n: i32 = s.trim().parse()?;
    Ok(n * 2)
}

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

    // Return Result — ? operator bisa digunakan
    #[test]
    fn test_parse_valid() -> Result<(), ParseIntError> {
        let hasil = parse_dan_kali_dua("21")?;
        assert_eq!(hasil, 42);
        Ok(())
    }

    // Test Err case
    #[test]
    fn test_parse_invalid() {
        let hasil = parse_dan_kali_dua("bukan angka");
        assert!(hasil.is_err(), "Input tidak valid harus mengembalikan Err");
    }

    // Verifikasi jenis error
    #[test]
    fn test_parse_overflow() {
        // i32 max adalah 2147483647
        let hasil = parse_dan_kali_dua("9999999999");
        assert!(hasil.is_err());
    }
}

Test Helper dan Fixture #

Rust tidak punya @BeforeEach seperti JUnit, tapi pola yang setara mudah diimplementasikan:

#[derive(Debug, PartialEq)]
struct Keranjang {
    item: Vec<String>,
    total: f64,
}

impl Keranjang {
    fn baru() -> Self {
        Keranjang { item: Vec::new(), total: 0.0 }
    }

    fn tambah(&mut self, nama: &str, harga: f64) {
        self.item.push(nama.to_string());
        self.total += harga;
    }

    fn hapus(&mut self, nama: &str) -> bool {
        if let Some(pos) = self.item.iter().position(|i| i == nama) {
            self.item.remove(pos);
            true
        } else {
            false
        }
    }

    fn kosong(&self) -> bool {
        self.item.is_empty()
    }
}

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

    // Fungsi helper yang membuat fixture — dipanggil di awal setiap test
    fn keranjang_dengan_isi() -> Keranjang {
        let mut k = Keranjang::baru();
        k.tambah("Apel", 5_000.0);
        k.tambah("Mangga", 10_000.0);
        k.tambah("Jeruk", 8_000.0);
        k
    }

    #[test]
    fn test_keranjang_baru_kosong() {
        let k = Keranjang::baru();
        assert!(k.kosong());
        assert_eq!(k.total, 0.0);
    }

    #[test]
    fn test_tambah_item() {
        let mut k = Keranjang::baru();
        k.tambah("Apel", 5_000.0);
        assert_eq!(k.item.len(), 1);
        assert_eq!(k.total, 5_000.0);
    }

    #[test]
    fn test_hapus_item_ada() {
        let mut k = keranjang_dengan_isi();  // gunakan fixture
        assert!(k.hapus("Mangga"));
        assert_eq!(k.item.len(), 2);
        assert!(!k.item.contains(&"Mangga".to_string()));
    }

    #[test]
    fn test_hapus_item_tidak_ada() {
        let mut k = keranjang_dengan_isi();
        assert!(!k.hapus("Durian"));  // tidak ada — return false
        assert_eq!(k.item.len(), 3);  // jumlah tidak berubah
    }

    #[test]
    fn test_total_benar() {
        let k = keranjang_dengan_isi();
        assert_eq!(k.total, 23_000.0);
    }
}

#[ignore] dan Menjalankan Test Selektif #

#[cfg(test)]
mod tests {
    #[test]
    fn test_cepat() {
        assert_eq!(2 + 2, 4);
    }

    // Test lambat — misalnya butuh koneksi database atau waktu lama
    #[test]
    #[ignore = "butuh koneksi database produksi"]
    fn test_integrasi_database() {
        // test mahal yang tidak dijalankan secara default
    }

    #[test]
    #[ignore = "WIP — implementasi belum selesai"]
    fn test_fitur_baru() {
        todo!()
    }
}
cargo test                          # jalankan semua kecuali yang #[ignore]
cargo test test_cepat               # jalankan test yang namanya mengandung "test_cepat"
cargo test -- --ignored             # jalankan hanya yang #[ignore]
cargo test -- --include-ignored     # jalankan semua termasuk yang #[ignore]
cargo test -- --nocapture           # tampilkan println! di dalam test
cargo test -- --test-threads=1      # jalankan serial (tidak paralel)
cargo test modul::tests::           # filter berdasarkan path modul

Integration Test #

Integration test berada di direktori tests/ di root proyek — terpisah dari kode sumber. Mereka hanya bisa mengakses API publik crate, sama seperti pengguna eksternal:

proyek/
├── src/
│   └── lib.rs
├── tests/
│   ├── integrasi_keranjang.rs
│   └── integrasi_api.rs
└── Cargo.toml
// tests/integrasi_keranjang.rs
// Tidak perlu #[cfg(test)] — seluruh file ini adalah test

use nama_crate::{Keranjang};  // hanya bisa akses item pub

#[test]
fn test_skenario_lengkap_pembelian() {
    let mut k = Keranjang::baru();
    k.tambah("Laptop", 15_000_000.0);
    k.tambah("Mouse", 250_000.0);
    k.tambah("Keyboard", 500_000.0);

    assert_eq!(k.item.len(), 3);
    assert_eq!(k.total, 15_750_000.0);

    k.hapus("Mouse");
    assert_eq!(k.total, 15_500_000.0);
}

// Modul helper bersama antar file integration test
mod common;  // merujuk ke tests/common/mod.rs atau tests/common.rs
// tests/common/mod.rs — helper bersama untuk semua integration test
pub fn setup_lingkungan_test() {
    // Inisialisasi yang dibutuhkan semua integration test
    // Misalnya: setup database in-memory, buat file temp, dll.
}

Async Test dengan Tokio #

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
// Fungsi async yang ingin ditest
async fn ambil_data(url: &str) -> Result<String, String> {
    // Simulasi HTTP request
    if url.starts_with("https://") {
        Ok(format!("Data dari {}", url))
    } else {
        Err(format!("URL tidak valid: {}", url))
    }
}

async fn proses_paralel(data: Vec<i32>) -> Vec<i32> {
    let mut handles = vec![];
    for n in data {
        handles.push(tokio::spawn(async move { n * n }));
    }
    let mut hasil = vec![];
    for h in handles {
        hasil.push(h.await.unwrap());
    }
    hasil
}

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

    // Atribut tokio::test menggantikan #[test] untuk fungsi async
    #[tokio::test]
    async fn test_ambil_data_valid() {
        let hasil = ambil_data("https://api.contoh.com").await;
        assert!(hasil.is_ok());
        assert!(hasil.unwrap().contains("api.contoh.com"));
    }

    #[tokio::test]
    async fn test_ambil_data_url_invalid() {
        let hasil = ambil_data("http://tidak-https.com").await;
        assert!(hasil.is_err());
    }

    #[tokio::test]
    async fn test_proses_paralel() {
        let input = vec![1, 2, 3, 4, 5];
        let mut hasil = proses_paralel(input).await;
        hasil.sort();
        assert_eq!(hasil, vec![1, 4, 9, 16, 25]);
    }

    // Test dengan timeout — gagal jika tidak selesai dalam batas waktu
    #[tokio::test(flavor = "multi_thread")]
    async fn test_dengan_timeout() {
        let hasil = tokio::time::timeout(
            std::time::Duration::from_secs(1),
            ambil_data("https://cepat.com"),
        )
        .await;
        assert!(hasil.is_ok(), "Operasi melebihi timeout 1 detik");
    }
}

Benchmarking dengan criterion #

Nightly Rust punya #[bench] bawaan, tapi criterion di stable Rust jauh lebih akurat dan menghasilkan laporan statistik yang lengkap:

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "benchmark_utama"
harness = false
// benches/benchmark_utama.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};

fn fibonacci_rekursif(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        n => fibonacci_rekursif(n - 1) + fibonacci_rekursif(n - 2),
    }
}

fn fibonacci_iteratif(n: u64) -> u64 {
    let mut a = 0u64;
    let mut b = 1u64;
    for _ in 0..n {
        let temp = a;
        a = b;
        b = temp + b;
    }
    a
}

fn bench_fibonacci(c: &mut Criterion) {
    // Benchmark satu fungsi
    c.bench_function("fibonacci rekursif n=20", |b| {
        b.iter(|| fibonacci_rekursif(black_box(20)))
    });

    // Perbandingan dua implementasi dengan input berbeda
    let mut grup = c.benchmark_group("fibonacci perbandingan");
    for n in [10u64, 15, 20, 25].iter() {
        grup.bench_with_input(
            BenchmarkId::new("rekursif", n),
            n,
            |b, &n| b.iter(|| fibonacci_rekursif(black_box(n))),
        );
        grup.bench_with_input(
            BenchmarkId::new("iteratif", n),
            n,
            |b, &n| b.iter(|| fibonacci_iteratif(black_box(n))),
        );
    }
    grup.finish();
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
cargo bench                         # jalankan semua benchmark
cargo bench fibonacci               # filter benchmark berdasarkan nama
# Hasil HTML tersedia di target/criterion/

black_box() mencegah compiler mengoptimasi kalkulasi benchmark menjadi konstanta — tanpanya hasil benchmark tidak akurat.


Ringkasan #

  • #[cfg(test)] membungkus semua unit test — modul ini hanya dikompilasi saat cargo test, tidak masuk binary produksi. use super::* mengimpor fungsi privat sekalipun.
  • Gunakan pesan kustom di assertionassert_eq!(a, b, "Konteks: a={}, b={}", a, b) jauh lebih informatif saat test gagal daripada output default.
  • #[should_panic(expected = "...")] lebih baik dari #[should_panic] — verifikasi pesan panic spesifik mencegah test lulus karena panic yang tidak terduga.
  • Test bisa return Result — operator ? bisa digunakan di dalam test yang return Result<(), E>, membuat test untuk kode yang return Result lebih natural.
  • Helper function untuk fixture — Rust tidak punya @BeforeEach, tapi fungsi biasa yang membuat state awal dan dipanggil di awal setiap test memberikan hasil yang sama.
  • Integration test di tests/ — hanya bisa akses API publik crate, cocok untuk test end-to-end dari perspektif pengguna library.
  • #[tokio::test] untuk async test — pengganti #[test] untuk fungsi async fn. Bisa dikombinasikan dengan tokio::time::timeout untuk test dengan batas waktu.
  • criterion untuk benchmarking di stable Rust — lebih akurat dari #[bench] nightly, menghasilkan laporan statistik (median, standar deviasi) dan perbandingan antar run.
  • black_box() mencegah optimasi benchmark — tanpanya compiler bisa mengoptimasi kalkulasi menjadi konstanta, membuat benchmark tidak bermakna.

← Sebelumnya: Web Server   Berikutnya: Mocking →

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