Unit Test

Anotasi #[test], asersi, dan pengujian panic.

Daftar isi

Topik ini membahas penulisan unit test dengan kerangka pengujian bawaan Rust, yaitu pengujian yang memeriksa fungsi atau modul tunggal dari dalam crate yang sama. Pembahasan mencakup anotasi #[test], tiga makro asersi beserta pesan kustomnya, pengujian yang sengaja mengharapkan panic, penandaan pengujian yang dilewati, serta cara memilih pengujian tertentu saat dijalankan. Contoh memakai proyek pustaka kecil bernama statistik-nilai yang dijalankan sungguhan dengan cargo test. Setelah membacanya, fungsi pengujian dapat ditempatkan pada modul yang tidak ikut dikompilasi pada build biasa, laporan kegagalan dapat dibaca sampai ke baris penyebabnya, dan pengujian mana pun dapat dijalankan terpisah.

Kerangka fungsi pengujian#

Perintah cargo test mengompilasi crate dalam mode khusus, lalu menjalankan setiap fungsi bertanda #[test] dan melaporkan hasilnya satu per satu. Fungsi biasa tidak dijalankan, sehingga penanda itulah satu-satunya pintu masuk kerangka pengujian. Konvensi yang dipakai luas menempatkan seluruh fungsi pengujian pada modul bernama tests di akhir berkas, dengan use super::*; agar fungsi pustaka yang berada di atas dapat dipanggil tanpa jalur panjang.

Modul tersebut dibungkus atribut #[cfg(test)] yang hanya bernilai benar saat kompilasi pengujian. Akibatnya cargo build menghasilkan crate pustaka tanpa kode pengujian sama sekali, sedangkan cargo test mengompilasi crate yang sama beserta modul pengujian dan pelari pengujian bawaan. Diagram berikut memetakan kedua jalur pembangunan itu.

src/lib.rsfungsi pustakamod tests#[cfg(test)]cargo buildcrate pustakamod tests tidak dikompilasicargo testcrate pengujianmod tests ikut dikompilasi
Modul tests hanya dikompilasi pada mode pengujian

Proyek contoh terdiri atas dua berkas. Cargo.toml menyatakan identitas paket, sedangkan src/lib.rs memuat tiga fungsi pustaka beserta enam fungsi pengujian yang mencakup asersi, pengujian panic, dan satu pengujian yang ditandai dilewati.

toml Cargo.toml
[package]
name = "statistik-nilai"
version = "0.1.0"
edition = "2021"
rust src/lib.rs
// tidak-dijalankan sendirian oleh rustc karena berkas pustaka tanpa fungsi main.

//! Statistik sederhana untuk daftar nilai ujian.

/// Mengembalikan nilai terbesar, bila daftar tidak kosong.
pub fn terbesar(nilai: &[i32]) -> Option<i32> {
    nilai.iter().copied().max()
}

/// Mengembalikan nilai rata-rata, bila daftar tidak kosong.
pub fn rata_rata(nilai: &[i32]) -> Option<f64> {
    if nilai.is_empty() {
        return None;
    }
    Some(nilai.iter().sum::<i32>() as f64 / nilai.len() as f64)
}

/// Mengembalikan predikat huruf untuk nilai 0 sampai 100.
///
/// Nilai di luar jangkauan dianggap kesalahan program dan memicu panic.
pub fn predikat(nilai: i32) -> &'static str {
    assert!((0..=100).contains(&nilai), "nilai harus 0 sampai 100");
    match nilai {
        0..=59 => "C",
        60..=79 => "B",
        _ => "A",
    }
}

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

    #[test]
    fn terbesar_dari_tiga_nilai() {
        assert_eq!(terbesar(&[70, 90, 80]), Some(90));
    }

    #[test]
    fn terbesar_daftar_kosong() {
        assert_eq!(terbesar(&[]), None, "daftar kosong harus menghasilkan None");
    }

    #[test]
    fn rata_rata_tiga_nilai() {
        assert_eq!(rata_rata(&[70, 80, 90]), Some(80.0));
    }

    #[test]
    fn predikat_ambang_batas() {
        assert_eq!(predikat(59), "C");
        assert_eq!(predikat(60), "B");
        assert_eq!(predikat(79), "B");
        assert_eq!(predikat(80), "A");
    }

    #[test]
    #[should_panic(expected = "nilai harus 0 sampai 100")]
    fn predikat_nilai_negatif_memicu_panic() {
        predikat(-5);
    }

    #[test]
    #[ignore = "contoh pengujian yang lebih lambat karena memeriksa seratus data"]
    fn rata_rata_seratus_data() {
        let data: Vec<i32> = (1..=100).collect();
        assert_eq!(rata_rata(&data), Some(50.5));
    }
}

Baris komentar pada awal src/lib.rs menyatakan bahwa berkas itu tidak dapat dikompilasi sendirian oleh rustc karena berkas pustaka tidak memuat fungsi main. Pembangunan lengkap dilakukan Cargo, sehingga verifikasi eksekusi memakai proyek di luar pohon konten situs, dengan seluruh berkas disalin dari bundle topik lebih dahulu.

bash
# siapkan proyek percobaan yang bersih di luar pohon konten
rm -rf /tmp/statistik-uji
cargo new /tmp/statistik-uji --lib -q --vcs none
# salin berkas proyek dari bundle ke posisinya masing-masing
cp Cargo.toml /tmp/statistik-uji/Cargo.toml
cp src/lib.rs /tmp/statistik-uji/src/lib.rs

Makro asersi#

Pengujian memeriksa perilaku lewat makro asersi yang menghentikan fungsi pengujian dengan panic apabila kondisinya tidak terpenuhi. Panic pada fungsi bertanda #[test] tidak menghentikan seluruh program, melainkan ditandai sebagai kegagalan satu pengujian, kemudian pengujian berikutnya tetap dijalankan.

MakroPemeriksaanGagal ketika
assert!(kondisi)nilai bool adalah truekondisi bernilai false
assert_eq!(a, b)kedua nilai samanilai kiri dan kanan berbeda
assert_ne!(a, b)kedua nilai berbedanilai kiri dan kanan sama

Ketiganya menerima pesan kustom sebagai argumen tambahan, seperti pada pengujian terbesar_daftar_kosong yang memakai teks "daftar kosong harus menghasilkan None". Pesan itu ditampilkan pada laporan kegagalan sehingga niat pengujian dapat dibaca tanpa membuka kodenya. Nilai pada assert_eq! dan assert_ne! harus bertipe sama dan mengimplementasikan PartialEq serta Debug, karena kedua sifat itulah yang dipakai untuk membandingkan dan mencetak nilai yang tidak cocok. Fungsi terbesar mengembalikan Option<i32> yang memenuhi kedua syarat tersebut, sehingga pembandingan langsung terhadap Some(90) atau None sah dilakukan.

Menjalankan dan memilih pengujian#

Perintah cargo test menjalankan seluruh fungsi bertanda #[test] berurutan menurut nama, kemudian menjalankan bagian doc test. Bagian doc test pada proyek ini masih kosong, sehingga tercetak running 0 tests. Argumen yang ditulis setelah tanda -- diteruskan ke program pengujian, bukan ke Cargo. Opsi --test-threads=1 membuat pengujian dijalankan satu per satu sehingga urutan barisnya stabil, sedangkan 2>/dev/null membuang pesan status Cargo yang memuat lama kompilasi. Keduanya dipakai agar keluaran contoh dapat direproduksi persis.

bash
# jalankan seluruh pengujian secara berurutan agar urutan baris stabil
cargo test --manifest-path /tmp/statistik-uji/Cargo.toml -- --test-threads=1 2>/dev/null
text Keluaran
running 6 tests
test tests::predikat_ambang_batas ... ok
test tests::predikat_nilai_negatif_memicu_panic - should panic ... ok
test tests::rata_rata_seratus_data ... ignored, contoh pengujian yang lebih lambat karena memeriksa seratus data
test tests::rata_rata_tiga_nilai ... ok
test tests::terbesar_daftar_kosong ... ok
test tests::terbesar_dari_tiga_nilai ... ok

test result: ok. 5 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Baris ignored, contoh pengujian yang lebih lambat karena memeriksa seratus data memperlihatkan efek atribut #[ignore = "..."], yakni pengujian dilewati saat cargo test biasa beserta alasannya. Penanda ini dipakai untuk pengujian yang memakan waktu, lalu dijalankan berkala atau sebelum rilis dengan opsi khusus. Nama pengujian juga dapat dipakai sebagai saringan dengan menuliskannya sebagai argumen sebelum tanda --, dan hanya pengujian yang namanya memuat saringan itu yang dijalankan.

bash
# jalankan hanya pengujian yang namanya memuat kata predikat
cargo test --manifest-path /tmp/statistik-uji/Cargo.toml predikat -- --test-threads=1 2>/dev/null
text Keluaran
running 2 tests
test tests::predikat_ambang_batas ... ok
test tests::predikat_nilai_negatif_memicu_panic - should panic ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s
bash
# jalankan hanya pengujian yang ditandai ignore
cargo test --manifest-path /tmp/statistik-uji/Cargo.toml -- --ignored 2>/dev/null
text Keluaran
running 1 test
test tests::rata_rata_seratus_data ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Setiap angka pada baris ringkasan memiliki makna yang tetap: filtered out menghitung pengujian yang tidak ikut karena saringan nama atau penanda ignore, sedangkan measured terisi hanya ketika pengukuran kinerja diaktifkan. Saringan nama menjadi alur kerja yang ringkas ketika satu kegagalan diperbaiki, karena satu fungsi bermasalah dapat diuji berulang tanpa menunggu seluruh rangkaian.

Menguji panic dan membaca laporan kegagalan#

Atribut #[should_panic] membalik makna keberhasilan: pengujian dianggap lulus justru ketika fungsinya memicu panic. Bentuk lengkapnya memuat expected = "..." yang mencocokkan potongan pesan panic, sehingga panic dengan sebab lain tetap dinyatakan gagal. Pada contoh, predikat(-5) harus berhenti dengan pesan nilai harus 0 sampai 100 karena nilai negatif melanggar kontrak fungsi, dan kecocokan potongan pesan itulah yang menjaga pengujian tetap spesifik.

Bentuk laporan kegagalan dapat diperiksa dengan sengaja merusak satu asersi. Blok berikut mengubah nilai harapan pada pengujian terbesar_dari_tiga_nilai menjadi Some(80), menjalankan pengujian, lalu memulihkan berkas aslinya dari bundle. Nomor thread pada pesan panic berbeda pada setiap jalankan sehingga disaring dari keluaran.

bash
# ubah sementara satu asersi agar laporan kegagalan dapat diperiksa
sed -i 's/Some(90)/Some(80)/' /tmp/statistik-uji/src/lib.rs
# jalankan pengujian lalu saring nomor thread yang selalu berubah
cargo test --manifest-path /tmp/statistik-uji/Cargo.toml -- --test-threads=1 2>/dev/null | sed -E 's/ \([0-9]+\)//'
# pulihkan berkas asli dari bundle
cp src/lib.rs /tmp/statistik-uji/src/lib.rs
text Keluaran
running 6 tests
test tests::predikat_ambang_batas ... ok
test tests::predikat_nilai_negatif_memicu_panic - should panic ... ok
test tests::rata_rata_seratus_data ... ignored, contoh pengujian yang lebih lambat karena memeriksa seratus data
test tests::rata_rata_tiga_nilai ... ok
test tests::terbesar_daftar_kosong ... ok
test tests::terbesar_dari_tiga_nilai ... FAILED

failures:

---- tests::terbesar_dari_tiga_nilai stdout ----

thread 'tests::terbesar_dari_tiga_nilai' panicked at src/lib.rs:36:9:
assertion `left == right` failed
  left: Some(90)
 right: Some(80)


failures:
    tests::terbesar_dari_tiga_nilai

test result: FAILED. 4 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

Laporan itu memuat tiga lapis informasi. Baris FAILED pada daftar pengujian menunjukkan fungsi mana yang gagal, bagian stdout memuat pesan panic lengkap dengan lokasi src/lib.rs:36:9 serta nilai kiri dan kanan yang tidak cocok, dan baris ringkasan menghitung hasil akhirnya. Lokasi panic menunjuk langsung ke baris asersi, sedangkan nilai left dan right membebaskan pembaca dari menebak arah pembandingan. Perbaikan yang benar biasanya terlihat begitu kedua nilai itu dibaca bersama lokasinya.

Kesalahan umum#

Menguji fungsi privat dari berkas lain. Unit test paling mudah ditulis pada modul tests di dalam berkas yang sama karena use super::* membuka seluruh item di atasnya. Pengujian yang menembus batas berkas atau crate sebaiknya dipindahkan ke integration test pada direktori tests/, bukan dipaksa lewat jalur modul yang panjang.

Mengabaikan pesan kustom. Asersi tanpa pesan hanya melaporkan nilai yang tidak cocok, sehingga pengujian nilai batas seperti predikat(59) dan predikat(60) sulit dibedakan maksudnya saat gagal. Tambahkan pesan singkat yang menyatakan invariannya, misalnya ambang mana yang sedang diuji.

Menandai #[should_panic] pada panic yang seharusnya Result. Fungsi yang gagal karena masukan sah dari dunia luar seharusnya mengembalikan Result, bukan panic. #[should_panic] hanya tepat untuk invarian internal yang penegasannya memang ditulis dengan assert!.

Ringkasan#

  • Fungsi bertanda #[test] pada modul #[cfg(test)] hanya dikompilasi saat cargo test, sehingga build biasa tidak memuat kode pengujian.
  • Asersi assert!, assert_eq!, dan assert_ne! menghentikan satu pengujian dengan panic tanpa menghentikan rangkaian lainnya.
  • #[should_panic(expected = "...")] menguji panic yang diharapkan sambil mencocokkan potongan pesannya, dan #[ignore = "..."] menunda pengujian lambat agar hanya dijalankan lewat --ignored.
  • Saringan nama serta opsi --ignored menjalankan sebagian pengujian, dan laporan kegagalan memuat lokasi baris beserta nilai yang tidak cocok.

Referensi#