Pengujian Lanjutan di Rust: Mock, Property Test, dan Benchmark
Kamu sudah tahu cara menulis test dengan #[test] dan assert_eq! (kalau belum, mulai dulu dari Testing di Rust). Untuk program kecil, itu sudah cukup. Tapi di proyek sungguhan, pertanyaan baru cepat muncul. Di mana test sebaiknya disimpan? Bagaimana menguji kode yang mengirim email atau membaca jam? Bagaimana menemukan satu input yang tidak pernah terpikir olehmu? Dan apakah versi “lebih cepat” buatanmu memang benar-benar lebih cepat?
Bayangkan sebuah bengkel mobil. Test dasar itu seperti memeriksa apakah setiap komponen berfungsi. Halaman ini melengkapi isi bengkelnya: meja uji untuk komponen (fake dan mock), mesin yang mengguncang mobil dengan ribuan guncangan acak (property test), foto kondisi mobil terakhir kali (snapshot), dan stopwatch yang bisa dipercaya (benchmark).
Satu catatan jujur sebelum mulai: tombol Run di Rust Playground menjalankan main, bukan test, dan Playground tidak punya crate testing yang dipakai di bawah. Jadi sebagian besar contoh di sini menampilkan output asli cargo test dan cargo bench dari proyek kecil di komputer biasa. Kalau programnya punya main, tombol Try it tetap ada seperti biasa.
Mengatur letak test
Section titled “Mengatur letak test”Ini struktur library kecil shop yang dipakai di halaman ini:
shop/├── Cargo.toml├── src/│ ├── lib.rs <- kode + unit test di `mod tests`│ └── orders.rs├── tests/│ ├── checkout.rs <- sebuah integration test│ └── common/│ └── mod.rs <- helper bersama untuk integration test└── benches/ └── stock.rs <- sebuah benchmarkRust punya tiga jenis test, dan cargo test menjalankan semuanya:
- Unit test tinggal di samping kodenya, di dalam blok
#[cfg(test)] mod tests. Unit test juga bisa melihat fungsi yang private. - Integration test (test integrasi) tinggal di folder
tests/. Setiap file dikompilasi sebagai crate terpisah yang memakai library-mu dari luar, persis seperti pengguna memakainya. Jadi integration test hanya bisa memanggil item yangpub. - Doc test adalah contoh kode di dalam komentar
///. Doc test membuktikan dokumentasimu masih benar. Halaman Dokumentasi Rust membahasnya lebih detail.
Berikut sebagian dari src/lib.rs. Perhatikan doc test pada apply_discount, serta fungsi helper dan fixture di dalam mod tests:
/// Satu baris di keranjang belanja. Harga dalam sen.#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// Memotong `percent` persen dari sebuah harga.////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// Mengubah "12.50" menjadi 1250 sen.pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Mengubah 1250 sen menjadi "12.50".pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// Helper: singkat dipanggil, dan semua test membuat item dengan cara yang sama. fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// Fixture: satu keranjang realistis yang dipakai bersama oleh beberapa test. fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// Test bisa mengembalikan Result, jadi kamu bisa memakai `?` di dalamnya. #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}Beberapa hal yang patut diperhatikan:
- Helper dan fixture hanyalah fungsi biasa. Rust tidak punya sistem fixture khusus, dan kamu jarang merasa butuh. Karena letaknya di dalam
#[cfg(test)], fungsi-fungsi ini tidak pernah ikut masuk ke program aslimu. #[should_panic(expected = "...")]hanya lolos kalau pesan panic-nya mengandung teks itu, jadi panic karena bug lain tidak dihitung. Testing di Rust menjelaskannya dengan contoh lain.- Test yang mengembalikan
Resultmembuatmu bisa memakai?. Kalau test mengembalikanErr, test gagal dan error-nya dicetak, misalnyaError: "missing '.' in \"12,50\"". #[ignore]melewati test yang lambat atau khusus secara default. Teks setelah=adalah alasannya, dancargo testikut mencetaknya.
Integration test memakai library lewat API publiknya. Helper bersamanya ada di tests/common/mod.rs:
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}Menjalankan cargo test menampilkan satu bagian untuk tiap jenis test:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sOutput dari Rust ini selalu dalam bahasa Inggris: passed artinya lolos, failed gagal, ignored dilewati, dan filtered out disaring keluar.
Memilih test mana yang dijalankan
Section titled “Memilih test mana yang dijalankan”Sehari-hari, kamu tidak selalu ingin menjalankan semua test. Perintah-perintah ini membantu. Semua yang ditulis setelah -- yang berdiri sendiri diteruskan ke program test, bukan ke Cargo.
cargo test discount # hanya test yang namanya mengandung "discount"cargo test --test checkout # hanya file tests/checkout.rscargo test --lib # hanya unit test di src/cargo test --doc # hanya doc testcargo test -- --ignored # hanya test yang diberi #[ignore]cargo test -- --include-ignored # semuanya, termasuk yang di-ignorecargo test -- --nocapture # tampilkan output println! dari test yang loloscargo test -- --test-threads=1 # jalankan test satu per satuFilter mencocokkan sebagian nama lengkap test, di semua file test. Di sini discount menemukan satu unit test dan satu integration test:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... okTest yang lambat hanya jalan kalau kamu memintanya. Sepuluh juta kali bolak-balik makan waktu sekitar 3 detik di sini:
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sSecara default, Rust menyembunyikan output println! dari test yang lolos, supaya laporannya tetap rapi. --nocapture menampilkannya:
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... okTest double: memalsukan dengan trait
Section titled “Test double: memalsukan dengan trait”Sebagian kode berhubungan dengan dunia luar: mengirim email, memanggil API, atau membaca jam. Kamu tentu tidak mau test-mu mengirim email sungguhan. Kamu juga tidak bisa menguji “apa yang terjadi jam 9 malam” kalau kodenya selalu bertanya ke jam asli.
Jawaban yang idiomatis di Rust sama sekali tidak butuh library. Gambarkan dependensinya sebagai sebuah trait, buat fungsimu menerima apa saja yang mengimplementasikannya, lalu tulis fake (tiruan) kecil untuk test. Program asli memberikan versi aslinya. Test memberikan versi tiruannya.
Di sini konfirmasi pesanan mengirim email ke pelanggan, dan pesanan besar juga memberi tahu tim sales:
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Mailer asli. Di aplikasi sungguhan, ini akan memanggil layanan email.struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("email ke {to}: {body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("Pesanan #{} dikonfirmasi", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// Fake: tidak mengirim apa pun, hanya mencatat setiap panggilan. #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}Output:
email ke [email protected]: Pesanan #7 dikonfirmasiemail ke [email protected]: Pesanan #8 dikonfirmasiemail ke [email protected]: Pesanan besar #8Contoh ini tidak memakai crate eksternal, jadi di Playground kamu juga bisa memilih Test dari menu di sebelah Run:
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_order tidak tahu dan tidak peduli mailer mana yang ia terima. Itulah seluruh triknya. Ide yang sama berlaku untuk trait Clock (test memakai jam yang macet di jam 9 malam), PaymentGateway, atau trait Storage yang di dalam test disimpan di sebuah HashMap. Kamu akan mencoba versi jam di bagian latihan di bawah.
Mock dengan mockall
Section titled “Mock dengan mockall”Menulis fake dengan tangan tidak masalah untuk satu atau dua trait. Kalau jumlahnya banyak, crate mockall (bahasa Inggris) bisa membuatkannya untukmu. Tambahkan sebagai dev-dependency (dependensi khusus pengembangan), supaya hanya dipakai untuk test dan contoh (Cargo dan crate menjelaskan bagian-bagian Cargo.toml):
cargo add --dev mockallPasang #[automock] di atas trait, dan mockall akan membuat tipe MockMailer. Bagian cfg_attr(test, ...) artinya “lakukan ini hanya saat testing”, jadi mock-nya tidak pernah masuk ke build aslimu. Fitur Cargo dan #[cfg] menjelaskan cfg lebih detail.
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order dan confirm_order sama seperti di atas.
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}Setiap expect_send() adalah sebuah expectation (ekspektasi): “aku berharap send dipanggil dengan argumen yang cocok dengan withf, tepat satu kali”. return_const(()) menentukan apa yang dikembalikan mock (di sini, tidak ada). Saat mock di-drop di akhir test, mockall memeriksa apakah semua ekspektasi terpenuhi.
Untuk melihatnya menangkap bug, ubah aturan di confirm_order menjadi order.total_cents > 12_000. Sekarang email untuk sales tidak pernah terkirim:
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1Pesan ini artinya: ekspektasi dipanggil 0 kali, padahal diharapkan 1 kali.
Fake atau mock? Mulailah dengan fake buatan sendiri. Isinya Rust biasa, mudah dibaca, dan tidak menambah dependensi. Pakai mockall kalau trait yang harus dipalsukan banyak, atau kalau kamu benar-benar peduli pada “dipanggil tepat N kali dengan argumen ini”.
Property-based testing dengan proptest
Section titled “Property-based testing dengan proptest”Test biasa memeriksa satu contoh yang kamu pilih: “12.50 di-parse menjadi 1250”. Property test (test berbasis sifat) menyatakan sebuah aturan yang harus benar untuk setiap input, lalu sebuah library mencoba ratusan input acak untuk mematahkannya. Rasanya seperti menyewa orang yang sangat sabar untuk mencoba setiap nilai aneh yang tidak terpikir olehmu.
Property yang bagus sering terlihat seperti ini:
- Melakukan sesuatu dua kali membatalkannya: membalik list dua kali menghasilkan list semula.
- Bolak-balik mengembalikan nilai awal:
parse(format(x)) == x. - Suatu fakta selalu berlaku: list yang sudah diurutkan panjangnya sama dengan input.
Tambahkan proptest (bahasa Inggris) dengan cargo add --dev proptest, lalu tulis test di dalam makro proptest!. Setiap baris nama in strategi menyatakan jenis nilai acak yang akan dibuat:
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>() artinya “i32 apa saja”, 0u32..10_000_000 artinya angka dalam rentang itu, dan vec(..., 0..100) membuat list berisi paling banyak 99 item. Secara default setiap property dijalankan dengan 256 kasus acak. Di dalam proptest!, pakai prop_assert_eq! sebagai ganti assert_eq!, supaya proptest bisa menangani kegagalan dengan baik. Keduanya lolos:
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sPerhatikan bahwa ini pemeriksaan yang sama dengan test #[ignore] yang lambat tadi. Test itu mencoba kesepuluh juta harga dalam 3 detik. Property test mencoba 256 harga acak dalam seperseratus detik, jadi bisa dijalankan setiap saat.
Sekarang mari sengaja membuat kesalahan. Seseorang “memperbaiki” format_price agar mencetak pemisah ribuan, seperti 1,000.00. Tampilannya lebih enak dilihat, tapi dia lupa pada parse_price:
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}Ini output asli dari cargo test --test properties, sedikit dipangkas:
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000Lihat minimal failing input: cents = 100000 (“input terkecil yang gagal”). Nilai acak pertama yang gagal mungkin sesuatu yang berantakan seperti 7,384,912. Proptest lalu mengecilkannya (shrinking): ia terus mencoba nilai yang lebih kecil yang masih gagal, lalu melaporkan yang paling sederhana. 100000 sen tepat sama dengan 1,000.00, harga pertama yang mendapat koma. Itu langsung menunjuk ke bug-nya.
Proptest juga menyimpan kasus yang gagal di file .proptest-regressions di samping test, dan mencobanya lebih dulu di setiap run berikutnya. Commit file itu, supaya bug-nya tidak bisa diam-diam kembali. Kalau kamu lebih suka library yang lebih kecil, quickcheck (bahasa Inggris) melakukan tugas yang sama dengan fitur yang lebih sedikit.
Snapshot test dengan insta
Section titled “Snapshot test dengan insta”Sebagian output itu panjang: struk belanja, laporan error, dokumen JSON yang besar. Menulis assert_eq! dengan seluruh teks yang diharapkan itu melelahkan. Snapshot test menyimpan output ke sebuah file pada run pertama, lalu di run berikutnya membandingkan output baru dengan “foto” yang tersimpan itu.
Dengan insta (bahasa Inggris; cargo add --dev insta), test-nya cukup satu baris:
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}Run pertama sengaja gagal dan menulis file .snap.new untuk kamu periksa. Setelah kamu menerimanya (dengan cargo insta review dari tool cargo-insta, atau dengan mengganti nama file menjadi .snap), file itu disimpan di src/snapshots/:
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00Nanti, kalau seseorang mengubah "TOTAL" menjadi "Total", test gagal dengan diff yang jelas:
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`Kalau perubahannya memang disengaja, kamu tinggal menerima snapshot yang baru. Kalau tidak, kamu baru saja menemukan bug. Snapshot sangat cocok untuk pemeriksaan “ini tidak boleh berubah tanpa sengaja”, tapi snapshot tidak memberi tahu apakah output-nya benar. Baca snapshot pertama dengan teliti sebelum menerimanya.
Benchmark dengan criterion
Section titled “Benchmark dengan criterion”Kalau ingin tahu apakah kode itu cepat, ukurlah. Tebakan ternyata cukup sering salah. Mengukur waktu di dalam #[test] juga kurang tepat: test dibangun tanpa optimasi, dan satu kali run terlalu banyak gangguannya.
criterion (bahasa Inggris) adalah alat yang umum dipakai di Rust stable. Criterion menjalankan kodemu berkali-kali, mengurus statistiknya untukmu, dan mengingat hasil terakhir sehingga bisa memberi tahu apakah kodemu jadi lebih cepat atau lebih lambat. Tambahkan dengan cargo add --dev criterion, lalu daftarkan file benchmark-nya ke Cargo, dan matikan harness bawaan:
[[bench]]name = "stock"harness = falseBenchmark ini bertanya: untuk mengecek apakah sebuah kode produk ada di stok, mana yang lebih cepat untuk 1.000 kode, Vec atau HashSet?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // 1.000 kode produk, seperti "SKU-0", "SKU-1", ... let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // kasus terburuk untuk Vec: yang terakhir
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) adalah kode yang diukur. std::hint::black_box menyembunyikan sebuah nilai dari optimizer. Tanpa itu, kompiler bisa menyadari bahwa jawabannya tidak pernah berubah, menghitungnya sekali saja, dan kamu jadi mengukur waktu untuk tidak melakukan apa-apa.
Jalankan dengan cargo bench, yang selalu membangun dalam mode teroptimasi (release). Angka di komputermu pasti berbeda:
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severeCara membacanya: tiga angka waktu itu adalah sebuah rentang. Yang di tengah adalah perkiraan terbaik, dan dua angka di luarnya berarti “nilai sebenarnya sangat mungkin ada di antara keduanya”. Di sini HashSet butuh sekitar 19 nanodetik dan Vec sekitar 2,4 mikrodetik, jadi set lebih dari 100 kali lebih cepat untuk kasus ini. “Outliers” (pencilan) adalah beberapa run yang luar biasa lambat atau cepat, sering kali karena ada program lain yang berjalan di komputermu. Kalau hanya sedikit, itu normal.
Jalankan lagi setelah ada perubahan, dan criterion akan membandingkannya dengan run terakhir:
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.Perubahannya di bawah 1% dan bisa jadi hanya gangguan acak, jadi criterion bilang “No change” (tidak ada perubahan). Criterion juga menulis laporan HTML di target/criterion/. Untuk tahu kapan (dan kapan tidak) perlu memikirkan kecepatan, lihat Performa Rust.
Di kode lama kamu mungkin juga melihat fungsi #[bench]. Fungsi ini memakai test::Bencher bawaan, yang hanya jalan di kompiler nightly:
#![feature(test)] // hanya di nightlyextern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}Di Rust stable, pakai criterion.
Coverage dan CI
Section titled “Coverage dan CI”Setelah punya test, ada dua tool lagi yang membantu. Kamu memasangnya dengan cargo install, seperti tool lain di Tools dan setup Rust.
cargo-nextest adalah test runner yang lebih cepat. Ia menjalankan setiap test di proses tersendiri, menampilkan output yang lebih jelas, dan bisa mengulang test yang kadang gagal (flaky). Satu hal yang perlu diingat: nextest tidak menjalankan doc test, jadi jalankan juga cargo test --doc.
cargo install cargo-nextest --lockedcargo nextest runCoverage (cakupan test) memberi tahu baris mana yang benar-benar dijalankan oleh test-mu. cargo-llvm-cov memakai dukungan coverage yang sudah ada di kompiler dan bisa dipakai di Linux, macOS, dan Windows:
cargo install cargo-llvm-covcargo llvm-cov # tabel ringkasan di terminalcargo llvm-cov --html # laporan yang bisa dijelajahi di target/llvm-cov/htmlcargo-tarpaulin adalah alternatif lama yang populer. Paling cocok di Linux dan sering dipilih untuk CI:
cargo install cargo-tarpaulincargo tarpaulin --out HtmlAnggap coverage sebagai peta, bukan nilai rapor. Coverage menunjukkan kode yang tidak pernah disentuh test mana pun. Coverage tidak bisa memberi tahu apakah assert-mu memeriksa hal yang benar. Coverage 80% dengan assert yang bagus lebih baik daripada 100% dengan assert yang lemah.
Di CI (GitHub Actions, GitLab, dan sebagainya), titik awal yang baik adalah tiga perintah yang sama dengan yang kamu jalankan di komputermu, supaya pemeriksaan yang gagal punya arti yang sama di mana pun:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testKesalahan umum
Section titled “Kesalahan umum”Integration test untuk crate yang hanya berupa binary. Kalau proyekmu hanya punya src/main.rs, file di tests/ tidak bisa meng-import apa pun dari situ:
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`Solusinya: pindahkan logikanya ke src/lib.rs dan biarkan main.rs tetap kecil. main.rs lalu memanggil library itu, begitu juga test-mu. Cara ini juga membuat kodenya lebih mudah dipakai ulang.
Menaruh helper bersama di tests/common.rs. Setiap file .rs yang langsung berada di dalam tests/ dianggap sebagai file test tersendiri, jadi muncul bagian tambahan yang tidak berguna di output:
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 testsSolusinya: pakai tests/common/mod.rs, dan tulis mod common; di setiap file test yang membutuhkannya.
Crate testing di [dependencies]. proptest, mockall, insta, dan criterion seharusnya ada di [dev-dependencies] (pakai cargo add --dev). Kalau tidak, setiap pengguna crate-mu ikut mengunduh dan mengompilasinya.
Test yang berbagi state. Test berjalan secara paralel. Kalau dua test menulis ke file yang sama atau mengubah environment variable yang sama, keduanya akan gagal secara acak. Beri setiap test folder sementaranya sendiri (crate tempfile membantu), atau sebagai jalan terakhir jalankan dengan --test-threads=1.
Benchmark tanpa black_box, atau di dalam test. Optimizer bisa menghapus pekerjaan yang hasilnya tidak dipakai, dan pengukuran waktu di #[test] mode debug tidak banyak artinya. Pakai criterion dengan black_box.
Latihan
Section titled “Latihan”1. Sapaan yang bisa diuji
Section titled “1. Sapaan yang bisa diuji”Fungsi ini tidak bisa diuji dengan andal, karena selalu bertanya ke jam asli:
fn greeting() -> &'static str { let hour = current_hour(); // membaca waktu sistem match hour { 5..=11 => "Selamat pagi", 12..=17 => "Selamat siang", _ => "Selamat malam", }}Tulis ulang agar menerima trait Clock dengan satu method, fn hour(&self) -> u64. Buat FixedClock yang selalu mengembalikan jam yang sama, lalu uji batas-batasnya: 11 dan 12, 17 dan 18. Petunjuk: struct FixedClock(u64); sudah cukup. Aplikasi aslinya akan memberikan SystemClock yang membaca waktu.
Lihat solusi
trait Clock { fn hour(&self) -> u64; // 0 sampai 23}
// Jam yang selalu menunjukkan jam yang sama. Sempurna untuk test.struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "Selamat pagi", 12..=17 => "Selamat siang", _ => "Selamat malam", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "Selamat pagi"); assert_eq!(greeting(&FixedClock(12)), "Selamat siang"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "Selamat siang"); assert_eq!(greeting(&FixedClock(18)), "Selamat malam"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "Selamat malam"); }}Output:
8:00 -> Selamat pagi12:00 -> Selamat siang21:00 -> Selamat malamgreeting tidak lagi menentukan dari mana waktunya berasal, pemanggilnya yang menentukan. Satu perubahan itu membuat setiap jam bisa diuji dalam hitungan milidetik. Pilih Test di menu Playground dan ketiga test-nya lolos.
2. Temukan aturannya, lalu biarkan proptest memeriksanya
Section titled “2. Temukan aturannya, lalu biarkan proptest memeriksanya”Berikut fungsi yang mengubah tag blog seperti " Web Dev " menjadi "web-dev":
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}Tulis dua property test dengan proptest: menormalkan tag yang sudah normal tidak mengubah apa pun, dan hasilnya tidak pernah mengandung whitespace (spasi, tab, baris baru). Petunjuk: strategi ".*" membuat string acak (itu sebuah regular expression). Untuk “tanpa whitespace”, prop_assert!(!s.contains(char::is_whitespace)) bisa dipakai.
Lihat solusi
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}Output:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"Run bisa dipakai di Playground, tapi Test tidak, karena Playground tidak punya proptest. Di proyekmu sendiri, tambahkan dengan cargo add --dev proptest lalu jalankan cargo test:
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sString acaknya mengandung tab, baris baru, dan huruf non-Latin, hal-hal yang mungkin tidak akan pernah kamu ketik di test buatan tangan. “Melakukannya dua kali sama saja dengan melakukannya sekali” adalah property yang layak diperiksa untuk setiap fungsi pembersih data.
Ringkasan
Section titled “Ringkasan”- Unit test ditaruh di
#[cfg(test)] mod testsdi samping kodenya, integration test ditests/(helper bersama ditests/common/mod.rs), dan doc test di komentar///. - Pakai
#[ignore]untuk test yang lambat, jalankan dengancargo test -- --ignored, saring berdasarkan nama dengancargo test some_name, dan lihat outputprintln!dengan-- --nocapture. - Untuk menguji kode yang berhubungan dengan dunia luar, sembunyikan dependensi itu di balik sebuah trait dan berikan fake saat test. Pakai mockall kalau menulis fake dengan tangan mulai merepotkan.
- Property test (proptest) memeriksa sebuah aturan dengan ratusan input acak dan mengecilkan kegagalan menjadi contoh paling sederhana. Snapshot test (insta) menangkap perubahan tak sengaja pada output yang besar.
- Ukur kecepatan dengan criterion dan
cargo bench, dan bungkus input denganstd::hint::black_box.#[bench]butuh nightly. - cargo-nextest menjalankan test lebih cepat; cargo-llvm-cov dan cargo-tarpaulin menunjukkan baris mana yang tercakup oleh test-mu.
Kuis singkat
Section titled “Kuis singkat”Kuis singkat
1. Beberapa file di tests/ butuh fungsi helper yang sama. Di mana sebaiknya fungsi itu ditaruh?
2. Apa yang dilakukan cargo test -- --ignored?
3. Sebuah property di proptest gagal. Apa yang dilaporkan proptest sebagai minimal failing input?
4. Kenapa benchmark criterion membungkus nilai dengan std::hint::black_box?
Skor: 0 / 4
Bacaan lebih lanjut
Section titled “Bacaan lebih lanjut”- Writing Automated Tests — The Rust Book (bahasa Inggris): bab resmi tentang cara menulis, menjalankan, dan mengatur unit test serta integration test.
- Testing — Rust by Example (bahasa Inggris): contoh singkat unit test, doc test, integration test, dan dev-dependencies.
- The proptest book (bahasa Inggris): strategi, shrinking, konfigurasi, dan perbandingan proptest dengan quickcheck.
- Dokumentasi mockall (bahasa Inggris):
#[automock], ekspektasi, pencocokan argumen, nilai kembalian, dan mock untuk struct. - Criterion.rs user guide (bahasa Inggris): cara memulai, membaca output, membandingkan fungsi, dan laporan HTML.