Lewati ke konten

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.

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 benchmark

Rust 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 yang pub.
  • 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 Result membuatmu bisa memakai ?. Kalau test mengembalikan Err, test gagal dan error-nya dicetak, misalnya Error: "missing '.' in \"12,50\"".
  • #[ignore] melewati test yang lambat atau khusus secara default. Teks setelah = adalah alasannya, dan cargo test ikut mencetaknya.

Integration test memakai library lewat API publiknya. Helper bersamanya ada di tests/common/mod.rs:

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 },
]
}
tests/checkout.rs
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 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test 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 test
test 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 test
test 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.00s

Output dari Rust ini selalu dalam bahasa Inggris: passed artinya lolos, failed gagal, ignored dilewati, dan filtered out disaring keluar.

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.

Terminal window
cargo test discount # hanya test yang namanya mengandung "discount"
cargo test --test checkout # hanya file tests/checkout.rs
cargo test --lib # hanya unit test di src/
cargo test --doc # hanya doc test
cargo test -- --ignored # hanya test yang diberi #[ignore]
cargo test -- --include-ignored # semuanya, termasuk yang di-ignore
cargo test -- --nocapture # tampilkan output println! dari test yang lolos
cargo test -- --test-threads=1 # jalankan test satu per satu

Filter 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 test
test 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 test
test discounted_breakfast_total ... ok

Test yang lambat hanya jalan kalau kamu memintanya. Sepuluh juta kali bolak-balik makan waktu sekitar 3 detik di sini:

running 1 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

Secara default, Rust menyembunyikan output println! dari test yang lolos, supaya laporannya tetap rapi. --nocapture menampilkannya:

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

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 {
mailer.send("[email protected]", &format!("Pesanan besar #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
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();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "Pesanan besar #8".to_string()));
}
}

Output:

email ke [email protected]: Pesanan #7 dikonfirmasi
email ke [email protected]: Pesanan #8 dikonfirmasi
email ke [email protected]: Pesanan besar #8
▶ Coba sendiri

Contoh ini tidak memakai crate eksternal, jadi di Playground kamu juga bisa memilih Test dari menu di sebelah Run:

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

confirm_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.

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):

Terminal window
cargo add --dev mockall

Pasang #[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.

src/orders.rs
#[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()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "Pesanan besar #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
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 1

Pesan 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”.

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:

tests/properties.rs
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 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Perhatikan 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 = 100000

Lihat 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.

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.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

Nanti, 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.

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 = false

Benchmark ini bertanya: untuk mengecek apakah sebuah kode produk ada di stok, mana yang lebih cepat untuk 1.000 kode, Vec atau HashSet?

benches/stock.rs
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 severe

Cara 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 nightly
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

Di Rust stable, pakai criterion.

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.

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

Coverage (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:

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # tabel ringkasan di terminal
cargo llvm-cov --html # laporan yang bisa dijelajahi di target/llvm-cov/html

cargo-tarpaulin adalah alternatif lama yang populer. Paling cocok di Linux dan sering dipilih untuk CI:

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

Anggap 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:

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

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 tests

Solusinya: 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.

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 pagi
12:00 -> Selamat siang
21:00 -> Selamat malam
▶ Coba sendiri

greeting 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"
▶ Coba sendiri

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 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

String 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.

  • Unit test ditaruh di #[cfg(test)] mod tests di samping kodenya, integration test di tests/ (helper bersama di tests/common/mod.rs), dan doc test di komentar ///.
  • Pakai #[ignore] untuk test yang lambat, jalankan dengan cargo test -- --ignored, saring berdasarkan nama dengan cargo test some_name, dan lihat output println! 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 dengan std::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

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