Unit Test Anda Hanya Mencakup Kasus yang Anda Ingat
Anda menulis fungsi reverse. Anda mengujinya dengan [1, 2, 3] dan [4, 5, 6, 7]. Lolos. Anda release.
Seorang pengguna memberikan slice dengan satu elemen. Fungsi Anda membuangnya begitu saja. Mereka membuka issue. Anda menatap file test dan bertanya-tanya bagaimana Anda bisa melewatkan sesuatu yang begitu jelas.
Anda melewatkannya karena pengujian berbasis contoh hanya menangkap bug yang Anda antisipasi. Setiap assert_eq! di test suite Anda adalah tebakan tentang di mana kegagalan mungkin bersembunyi. Property-based testing mengganti tebakan-tebakan itu dengan data acak dan invariant matematis. Ia akan menemukan bug satu elemen itu. Ia juga akan menemukan bug vector kosong, kasus edge integer overflow, dan kasus sudut Unicode yang tidak Anda ketahui keberadaannya.
Input Acak dan Invariant, Bukan Contoh dan Ekspektasi
Property-based testing membalikkan skrip. Alih-alih memilih input dan menegaskan output yang tepat, Anda mendefinisikan properti yang harus berlaku untuk semua input, dan test framework menghasilkan ratusan input acak untuk mencoba merusaknya.
Contoh klasik adalah membalikkan sebuah daftar. Propertinya bukan “reverse dari [1, 2, 3] adalah [3, 2, 1].” Propertinya adalah “membalikkan daftar dua kali mengembalikan daftar asli.” Invariant itu berlaku untuk setiap daftar yang mungkin, jadi framework melemparkan daftar-daftar acak kepadanya sampai sesuatu rusak atau batas iterasi tercapai.
Rust memiliki dua pustaka matang untuk ini. quickcheck adalah pilihan populer pertama, di-port dari Haskell. proptest adalah pendekatan yang lebih baru dengan fitur menentukan: shrinking otomatis. Ketika proptest menemukan input yang gagal, ia tidak sekadar mencetak vector seratus elemen dan membiarkan Anda debug. Ia menyederhanakan input sampai menemukan contoh kontra minimal yang mungkin. Itulah perbedaan antara membaca output tes yang gagal dan benar-benar memahami bug-nya.
Bagaimana Shrinking Menemukan Bug Sebenarnya
Shrinking adalah rahasia utamanya. Misal proptest menghasilkan vector acak berisi 47 integer dan properti Anda gagal. Bug-nya bukan elemen ke-47. Bug-nya mungkin sesuatu tentang vector kosong, atau perubahan tanda, atau nilai duplikat.
proptest mengetahui hal ini. Ia mencoba menghapus elemen. Ia mencoba meng-nol-kan nilai. Ia mencoba membuat vector semakin kecil. Jika tes masih gagal dengan input yang lebih kecil, ia terus menyusutkan. Ia berhenti ketika setiap variasi yang lebih sederhana lolos. Hasilnya adalah contoh kontra minimal.
Ini bukan sekadar fitur tambahan yang bagus. Ini adalah alasan property-based testing dapat digunakan dalam praktik. Tanpa shrinking, Anda kembali ke debugging tumpukan jerami. Dengannya, Anda mendapatkan jarum.
Bug Nyata, Tes Nyata
Berikut adalah fungsi reverse dengan bug yang halus. Lihat apakah Anda menemukannya sebelum tes melakukannya.
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
Rentangnya dimulai dari 1 bukan 0. Untuk daftar non-kosong apa pun, elemen pertama dihilangkan secara diam-diam. Sebuah unit test dengan [1, 2, 3] mungkin lolos jika penulis hanya memeriksa bahwa output terlihat terbalik, bukan bahwa panjangnya benar. Tetapi properti itu menangkapnya secara instan.
Tambahkan proptest ke Cargo.toml Anda:
[dev-dependencies]
proptest = "1.6"
Kemudian tulis propertinya:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
Jalankan cargo test dan outputnya menceritakan kisahnya:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
proptest menyusutkan vector acak yang gagal menjadi satu elemen, [0]. Panggilan pertama mengembalikan []. Panggilan kedua mengembalikan []. Properti menuntut [] == [0], yang gagal. Bug-nya menjadi jelas setelah input minimal.
Anda juga dapat menguji properti struktural secara langsung:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
Ini gagal dengan input minimal yang sama tetapi memberikan error yang lebih jelas: panjang output adalah 0, bukan 1.
Mengapa Anda Masih Membutuhkan Unit Test Biasa
Property-based testing bukan pengganti untuk tes berbasis contoh. Ia adalah pelengkap.
Unit test mendokumentasikan maksud. Ketika developer baru membaca bahwa reverse(&[1, 2, 3]) harus sama dengan [3, 2, 1], mereka belajar apa yang seharusnya fungsi itu lakukan. Tes properti berkata “invariant ini berlaku,” tetapi tidak memberi tahu Anda seperti apa outputnya untuk kasus normal.
Properti juga lebih sulit ditulis daripada contoh. State machine, side effect, dan I/O tidak dipetakan dengan bersih ke pure function. proptest memiliki strategi untuk tipe kompleks, tetapi menyambungkannya membutuhkan lebih banyak pemikiran daripada beberapa panggilan assert_eq!.
Ada juga biaya waktu. Sebuah tes properti mungkin menjalankan seribu iterasi. Dalam codebase besar, itu terakumulasi. Anda menjalankan tes properti di CI, bukan pada setiap penekanan tombol selama development.
Tes Acak Masih Dapat Direproduksi
Keacakan membuat orang gugup. Jika tes hanya gagal kadang-kadang, apakah itu kode atau tes-nya?
proptest menangani ini dengan menyimpan seed yang gagal. Ketika sebuah properti gagal, ia menyimpan seed dan input yang disusutkan ke file di proptest-regressions/. Run berikutnya memutar ulang kasus persis itu sebelum menghasilkan data acak baru. Tes properti yang flaky hampir selalu adalah properti yang buggy, bukan framework yang flaky.
Jika Anda membutuhkan determinisme ketat, Anda dapat mematok seed di konfigurasi tes Anda. Kebanyakan tim tidak repot-repot. File regresi sudah cukup.
Di Mana Tes Properti Memberikan Hasil
Property-based testing sebanding dengan overhead-nya ketika kode Anda memiliki invariant matematis yang jelas dan ruang input yang besar.
Sorting adalah contoh teksbuku. Propertinya sederhana: outputnya tersortir, merupakan permutasi dari input, dan memiliki panjang yang sama. Sebuah sort yang buggy mungkin lolos contoh yang ditulis tangan tetapi gagal pada data acak dengan elemen duplikat.
Parser dan serializer adalah titik manis lainnya. Properti round-trip seperti parse(serialize(x)) == x menangkap bug encoding yang hanya muncul dengan urutan byte tertentu.
Apa pun yang melibatkan aritmatika, koleksi, atau transisi state mendapat manfaat. Apa pun yang melibatkan API eksternal, timing ketat, atau penilaian manusia tidak.
Mulai dengan Satu Properti
Anda tidak perlu menulis ulang test suite Anda. Pilih satu pure function dengan invariant yang jelas. Tambahkan proptest ke dev-dependencies. Tulis satu properti. Jalankan, lihat lolos, kemudian perkenalkan bug yang halus dan lihat ia menyusutkan kegagalan menjadi sesuatu yang dapat Anda debug dalam hitungan detik.
Setelah Anda melihat tes acak menemukan bug yang tidak akan pernah Anda tulis contohnya, Anda akan mulai lebih sering mengambil properti. Bukan untuk setiap tes. Hanya untuk yang penting.