Cara Menulis Pengujian (Tests)
Pengujian (tests) adalah fungsi Rust yang memverifikasi kalau kode selain kode pengujian berjalan sesuai yang diharapkan. Body dari fungsi pengujian biasanya melakukan tiga aksi ini:
- Menyiapkan data atau state (keadaan) yang dibutuhkan.
- Menjalankan kode yang mau diuji.
- Menegaskan (assert) kalau hasilnya sesuai dengan yang diharapkan.
Mari kita lihat fitur-fitur yang disediakan Rust khusus buat menulis pengujian
yang mengambil aksi-aksi ini, termasuk atribut test, beberapa macro, dan
atribut should_panic.
Anatomi Fungsi Pengujian
Paling sederhananya, pengujian di Rust adalah sebuah fungsi yang dianotasi
dengan atribut test. Atribut adalah metadata tentang kode Rust; salah satu
contohnya adalah atribut derive yang kita pakai buat structs di Bab 5.
Untuk mengubah sebuah fungsi menjadi fungsi pengujian, tambahkan #[test] di
baris sebelum fn. Saat kita menjalankan pengujian menggunakan perintah
cargo test, Rust bakal mem-build sebuah test runner binary yang menjalankan
fungsi-fungsi yang dianotasi ini dan melaporkan apakah setiap fungsi pengujian
tersebut sukses atau gagal.
Kapan pun kita membuat project library baru dengan Cargo, sebuah modul pengujian (test module) dengan satu fungsi pengujian di dalamnya akan otomatis dibuatkan buat kita. Modul ini ngasih kita template buat nulis pengujian jadi kita tidak perlu repot-repot nyari struktur dan sintaks persisnya setiap kali mulai project baru. Kita bisa menambahkan sebanyak apa pun fungsi pengujian tambahan dan modul pengujian yang kita mau!
Kita bakal eksplorasi beberapa aspek soal gimana pengujian itu bekerja dengan ber-eksperimen menggunakan pengujian template ini sebelum kita benar-benar menguji kode apa pun. Lalu kita bakal nulis beberapa pengujian dunia nyata yang memanggil kode yang sudah kita tulis dan menegaskan kalau perilakunya sudah benar.
Mari kita buat project library baru bernama adder yang bakal menjumlahkan
dua angka:
$ cargo new adder --lib
Created library `adder` project
$ cd adder
Isi dari file src/lib.rs di library adder kita seharusnya kelihatan seperti
Listing 11-1.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
cargo newFile ini dimulai dengan sebuah contoh fungsi add, supaya kita punya sesuatu
buat diuji.
Buat sekarang, mari fokus pada fungsi it_works aja. Perhatikan anotasi
#[test]: atribut ini menunjukkan kalau ini adalah fungsi pengujian, jadi
test runner tahu buat memperlakukan fungsi ini sebagai pengujian. Kita juga
bisa punya fungsi biasa di dalam modul tests untuk membantu menyiapkan
skenario umum atau menjalankan operasi umum, jadi kita harus selalu menunjukkan
fungsi mana yang merupakan fungsi pengujian.
Contoh body fungsi ini memakai macro assert_eq! untuk menegaskan kalau
result, yang menampung hasil pemanggilan add dengan angka 2 dan 2, itu
sama dengan 4. Penegasan ini berfungsi sebagai contoh format buat pengujian yang
umum. Mari kita jalankan untuk melihat kalau pengujian ini sukses (passes).
Perintah cargo test menjalankan semua pengujian di dalam project kita, seperti
yang ditunjukkan di Listing 11-2.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo men-compile dan menjalankan pengujiannya. Kita melihat baris
running 1 test. Baris berikutnya menunjukkan nama fungsi pengujian yang
di-generate, bernama tests::it_works, dan hasil dari pengujian itu adalah ok.
Ringkasan keseluruhan test result: ok. berarti semua pengujian sukses, dan
bagian yang tertulis 1 passed; 0 failed menjumlahkan banyaknya pengujian yang
sukses atau gagal.
Kita bisa menandai sebuah pengujian untuk diabaikan (ignored) supaya tidak
dijalankan pada waktu tertentu; kita bakal membahas ini di bagian “Mengabaikan
Beberapa Pengujian Kecuali Diminta Secara Spesifik” nanti di bab ini.
Karena kita belum melakukannya di sini, ringkasannya menunjukkan 0 ignored. Kita
juga bisa mengirimkan argumen ke perintah cargo test untuk menjalankan hanya
pengujian yang namanya cocok dengan string tertentu; ini disebut filtering
(penyaringan) dan kita bakal membahasnya di bagian “Menjalankan Sebagian
Pengujian Berdasarkan Nama”. Di sini kita tidak menyaring pengujiannya,
jadi akhir dari ringkasan menunjukkan 0 filtered out.
Statistik 0 measured adalah untuk pengujian benchmark yang mengukur performa.
Pengujian benchmark, saat tulisan ini dibuat, baru tersedia di versi Rust
nightly. Cek dokumentasi soal pengujian benchmark buat info lebih
lanjut.
Bagian selanjutnya dari output pengujian yang dimulai dengan Doc-tests adder
adalah hasil dari documentation tests (pengujian dokumentasi). Kita belum
punya documentation tests apa pun, tapi Rust bisa men-compile contoh kode apa
pun yang ada di dokumentasi API kita. Fitur ini membantu menjaga supaya
dokumentasi dan kode kita tetap sinkron! Kita bakal membahas cara menulis
documentation tests di bagian “Komentar Dokumentasi Sebagai
Pengujian” di Bab 14. Buat sekarang, kita abaikan saja output
Doc-tests ini.
Mari kita mulai mengubah pengujian ini sesuai kebutuhan kita sendiri. Pertama,
ubah nama fungsi it_works jadi nama yang beda, misalnya exploration, kayak
gini:
Nama file: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
Kemudian jalankan cargo test lagi. Outputnya sekarang bakal menunjukkan
exploration bukannya it_works:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Sekarang kita bakal nambahin pengujian satu lagi, tapi kali ini kita bakal bikin
pengujian yang gagal! Pengujian bakal gagal kalau ada sesuatu di dalam fungsi
pengujian tersebut yang menyebabkan panic. Tiap pengujian dijalankan di dalam
thread baru, dan saat main thread melihat ada test thread yang mati,
pengujian itu bakal ditandai sebagai gagal. Di Bab 9, kita membahas gimana cara
paling sederhana untuk membuat panic adalah dengan memanggil macro panic!.
Masukkan pengujian baru ini sebagai fungsi bernama another, sehingga file
src/lib.rs kita kelihatan seperti Listing 11-3.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
panic!Jalankan pengujiannya lagi memakai cargo test. Output-nya seharusnya kelihatan
kayak Listing 11-4, yang menunjukkan kalau pengujian exploration kita sukses
tapi pengujian another kita gagal.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Bukannya ok, baris test tests::another menunjukkan FAILED. Ada dua bagian
baru yang muncul di antara hasil masing-masing pengujian dan ringkasan akhir:
bagian pertama menampilkan detail alasan setiap pengujian yang gagal. Di kasus
ini, kita mendapat detail bahwa tests::another gagal karena terjadi panic
dengan pesan Make this test fail di baris 17 pada file src/lib.rs. Bagian
berikutnya mendaftarkan cuma nama-nama dari semua pengujian yang gagal, yang
mana sangat berguna saat ada banyak pengujian dan sangat banyak output dari
pengujian yang gagal. Kita bisa memakai nama pengujian yang gagal itu untuk
menjalankan hanya pengujian tersebut agar lebih gampang men-debug-nya; kita
bakal membahas lebih lanjut soal cara-cara menjalankan pengujian di bagian
“Mengontrol Bagaimana Pengujian Dijalankan”.
Baris ringkasan ditampilkan di bagian paling akhir: secara keseluruhan, hasil
pengujian kita adalah FAILED. Kita punya satu pengujian yang sukses dan satu
yang gagal.
Sekarang karena kita sudah melihat seperti apa hasil pengujian di berbagai
skenario, mari kita lihat beberapa macro selain panic! yang berguna buat
pengujian.
Memeriksa Hasil dengan Macro assert!
Macro assert!, yang disediakan oleh standard library, berguna saat kita
mau memastikan bahwa suatu kondisi di pengujian dievaluasi menjadi true. Kita
memberikan sebuah argumen ke macro assert! yang bakal dievaluasi jadi sebuah
Boolean. Kalau nilainya true, tidak ada yang terjadi dan pengujiannya bakal
sukses. Kalau nilainya false, macro assert! memanggil panic! sehingga
pengujiannya gagal. Memakai macro assert! sangat membantu buat mengecek apakah
kode kita berfungsi seperti yang kita mau.
Di Bab 5, Listing 5-15, kita memakai struct Rectangle dan method can_hold,
yang mana diulangi lagi di sini di Listing 11-5. Mari kita masukkan kode ini
ke dalam file src/lib.rs, lalu kita tulis beberapa pengujian buat kode tersebut
menggunakan macro assert!.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
Rectangle dan method can_hold-nya dari Bab 5Method can_hold mengembalikan Boolean, yang artinya ini adalah use case
yang sangat pas buat macro assert!. Di Listing 11-6, kita nulis pengujian
yang mencoba method can_hold dengan bikin instance Rectangle yang punya
lebar 8 dan tinggi 7, lalu menegaskan kalau ia bisa menampung instance Rectangle
lain yang punya lebar 5 dan tinggi 1.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
can_hold yang mengecek apakah persegi panjang yang lebih besar benar-benar bisa menampung persegi panjang yang lebih kecilPerhatikan baris use super::*; di dalam modul tests. Modul tests adalah
modul biasa yang mengikuti aturan visibilitas standar yang sudah kita bahas
di Bab 7 di bagian “Paths untuk Merujuk ke sebuah Item di Pohon Modul”.
Karena modul tests adalah inner module (modul di dalam modul lain), kita
perlu membawa kode yang mau diuji di modul luar ke dalam scope modul
tests ini. Kita memakai glob (*) di sini, jadi semua hal yang kita
definisikan di modul luar bakal tersedia di modul tests ini.
Kita menamakan pengujian kita larger_can_hold_smaller, dan kita membuat dua
instance Rectangle yang kita butuhkan. Kemudian kita memanggil macro assert!
dan memasukkan hasil pemanggilan larger.can_hold(&smaller) kepadanya.
Ekspresi ini seharusnya mengembalikan true, jadi pengujian kita seharusnya
sukses. Mari kita buktikan!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Sukses kan! Mari tambahkan pengujian satu lagi, kali ini menegaskan bahwa persegi panjang yang lebih kecil tidak bisa menampung persegi panjang yang lebih besar:
Nama file: src/lib.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
// --snip--
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
Karena hasil yang benar dari fungsi can_hold di kasus ini adalah false,
kita perlu men-negasikan hasil tersebut sebelum memasukkannya ke macro assert!.
Sebagai hasilnya, pengujian kita bakal sukses kalau can_hold mengembalikan
false:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Dua pengujian yang sukses! Sekarang mari kita lihat apa yang terjadi pada hasil
pengujian kalau kita memunculkan bug di kode kita. Kita bakal mengubah
implementasi dari method can_hold dengan mengganti tanda lebih-dari menjadi
tanda kurang-dari saat dia membandingkan lebar (widths):
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --snip--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
Menjalankan pengujiannya sekarang bakal menghasilkan output ini:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Pengujian kita berhasil menangkap bug tersebut! Karena larger.width adalah
8 dan smaller.width adalah 5, perbandingan lebar-lebar ini di can_hold
sekarang mengembalikan false: 8 itu tidak kurang dari 5.
Menguji Kesamaan dengan Macro assert_eq! dan assert_ne!
Cara yang sangat umum buat memverifikasi fungsionalitas adalah dengan menguji
kesamaan antara hasil dari kode yang diuji dengan nilai yang kita harapkan bakal
dikembalikan oleh kode tersebut. Kita bisa saja melakukannya memakai macro
assert! dengan memasukkan ekspresi yang memakai operator ==. Tapi, karena
ini adalah bentuk pengujian yang sering sekali dipakai, standard library
menyediakan dua macro—assert_eq! dan assert_ne!—buat melakukan pengujian
ini dengan lebih praktis. Kedua macro ini membandingkan dua argumen buat melihat
apakah mereka sama (equality) atau tidak sama (inequality). Mereka juga bakal
mencetak kedua nilai tersebut kalau penegasannya gagal, yang mana bikin kita lebih
gampang melihat kenapa pengujian itu gagal; sebaliknya, macro assert! cuma
menunjukkan kalau dia dapat nilai false dari ekspresi ==, tanpa mencetak
nilai-nilai apa saja yang membuat hasil tersebut jadi false.
Di Listing 11-7, kita menulis sebuah fungsi bernama add_two yang menambahkan
2 ke parameternya, lalu kita menguji fungsi ini menggunakan macro assert_eq!.
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
add_two memakai macro assert_eq!Mari cek apakah pengujiannya sukses!
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Kita membuat sebuah variabel bernama result yang menampung hasil pemanggilan
add_two(2). Kemudian kita memasukkan result dan 4 sebagai argumen buat
macro assert_eq!. Baris output buat pengujian ini adalah test tests::it_adds_two ... ok, dan teks ok menunjukkan bahwa pengujian kita sukses!
Mari kita masukkan sebuah bug ke dalam kode kita buat melihat seperti apa
assert_eq! kalau dia gagal. Ubah implementasi fungsi add_two biar dia malah
menambahkan 3:
pub fn add_two(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
Jalankan pengujiannya lagi:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Pengujian kita berhasil menangkap bug tersebut! Pengujian tests::it_adds_two
gagal, dan pesannya memberi tahu kita kalau penegasan yang gagal adalah
left == right beserta apa nilai left dan right yang didapatkan. Pesan ini
ngebantu kita buat mulai melakukan debugging: argumen left, di mana kita
menaruh hasil pemanggilan add_two(2), itu ternyata 5, padahal argumen
right adalah 4. Kita bisa bayangin kalau informasi ini bakal kepake sekali
terutama kalau ada banyak pengujian yang lagi jalan.
Perhatikan bahwa di beberapa bahasa dan test frameworks (kerangka pengujian),
parameter untuk fungsi penegasan kesamaan biasanya dinamakan expected (harapan)
dan actual (asli), dan urutan kita memasukkan argumen-argumennya itu penting.
Tapi di Rust, mereka disebut left (kiri) dan right (kanan), dan urutan
kita meletakkan nilai ekspektasi kita sama nilai hasil dari kodenya itu sama
sekali tidak masalah. Kita bisa saja menulis penegasan di pengujian ini sebagai
assert_eq!(4, result), dan ini bakal menghasilkan pesan gagal yang sama yang
menampilkan assertion `left == right` failed.
Macro assert_ne! bakal sukses (pass) kalau dua nilai yang kita masukkan
tidak sama, dan bakal gagal kalau mereka sama. Macro ini paling berguna buat
kasus di mana kita tidak yakin nilai tersebut bakal jadi apa, tapi kita tahu
pasti nilai tersebut tidak boleh jadi apa. Misalnya, kalau kita menguji fungsi
yang dijamin mengubah inputnya dengan cara tertentu, tapi perubahan itu
tergantung sama hari dalam seminggu saat kita menjalankan pengujiannya, maka hal
terbaik yang bisa ditegaskan (assert) mungkin adalah bahwa output fungsinya
tidak sama dengan inputnya.
Di balik layar, macro assert_eq! dan assert_ne! masing-masing memakai operator
== dan !=. Saat penegasan gagal, macro ini mencetak argumen mereka memakai
debug formatting, yang berarti nilai-nilai yang dibandingkan harus
mengimplementasikan trait PartialEq dan Debug. Semua tipe primitif dan
sebagian besar tipe di standard library sudah mengimplementasikan traits
ini. Buat structs dan enums yang kita definisikan sendiri, kita harus
mengimplementasikan PartialEq buat menegaskan kesamaan tipe tersebut. Kita juga
harus mengimplementasikan Debug buat mencetak nilainya saat penegasan gagal.
Karena kedua traits ini adalah derivable traits, seperti yang disebutkan di
Listing 5-12 di Bab 5, hal ini biasanya semudah menambahkan anotasi
#[derive(PartialEq, Debug)] ke dalam definisi struct atau enum kita.
Lihat Lampiran C, “Derivable Traits,” buat detail lebih
lanjut soal ini dan derivable traits lainnya.
Menambahkan Pesan Kegagalan Kustom
Kita juga bisa menambahkan pesan kustom buat dicetak bersama pesan kegagalan
sebagai argumen opsional di macro assert!, assert_eq!, dan assert_ne!.
Argumen apa pun yang ditaruh setelah argumen wajib bakal diteruskan langsung ke
macro format! (dibahas di “Penggabungan dengan Operator + atau
Macro format!” di Bab 8),
jadi kita bisa memasukkan format string yang mengandung placeholder {}
serta nilai-nilai buat mengisi placeholder tersebut. Pesan kustom ini berguna
buat mendokumentasikan apa maksud dari sebuah penegasan; saat sebuah pengujian
gagal, kita jadi punya gambaran yang lebih baik soal masalah apa yang sedang
terjadi dengan kodenya.
Misalnya, katakanlah kita punya fungsi yang menyapa orang dengan namanya dan kita mau menguji apakah nama yang kita berikan ke fungsi tersebut muncul di dalam outputnya:
Nama file: src/lib.rs
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
Persyaratan buat program ini belum sepenuhnya disepakati, dan kita cukup yakin
kalau teks Hello di awal sapaan bakal berubah. Kita udah mutusin kalau kita
tidak mau terus-terusan meng-update pengujiannya tiap kali persyaratan ini
berubah, jadi alih-alih mengecek kesamaan yang sama persis dengan nilai kembalian
dari fungsi greeting, kita hanya menegaskan kalau outputnya mengandung teks
dari parameter inputnya.
Sekarang mari kita masukkan sebuah bug ke dalam kode ini dengan mengubah
greeting sehingga dia tidak menyertakan name dan kita bisa lihat seperti apa
kegagalan pengujian standarnya:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
Menjalankan pengujian ini bakal menghasilkan output berikut:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Hasil ini cuma ngasih tahu kalau penegasannya gagal dan di baris mana penegasan
itu berada. Pesan kegagalan yang lebih membantu seharusnya mencetak nilai dari
fungsi greeting. Mari tambahkan pesan kegagalan kustom yang disusun dari
format string dan placeholder yang diisi dengan nilai yang kita dapat
dari fungsi greeting:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
Sekarang ketika kita menjalankan pengujiannya, kita bakal dapat pesan error yang lebih informatif:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Kita bisa melihat nilai asli yang kita dapat di dalam output pengujiannya, yang mana sangat membantu kita buat debug apa yang sedang terjadi bukannya cuma apa yang kita harapkan terjadi.
Mengecek Panics dengan should_panic
Selain mengecek nilai kembalian, penting juga buat memastikan kalau kode kita
menangani kondisi error sesuai yang diharapkan. Contohnya, mari perhatikan
tipe Guess yang kita bikin di Bab 9, Listing 9-13. Kode lain yang memakai
Guess bergantung pada jaminan bahwa instance Guess hanya bakal berisi
angka antara 1 dan 100. Kita bisa nulis pengujian buat memastikan kalau
mencoba membuat instance Guess dengan nilai di luar batas itu bakal
mengakibatkan panic.
Kita melakukan ini dengan menambahkan atribut should_panic ke dalam fungsi
pengujian kita. Pengujian ini bakal sukses jika kode di dalam fungsi tersebut
mengalami panic; dan pengujian ini gagal jika kode di dalam fungsinya tidak
panic.
Listing 11-8 menunjukkan pengujian yang mengecek bahwa kondisi error dari
Guess::new memang terjadi pada waktu yang diharapkan.
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
panic!Kita menaruh atribut #[should_panic] setelah atribut #[test] dan sebelum
fungsi pengujian yang ia kenai. Mari lihat hasilnya ketika pengujian ini sukses:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Terlihat bagus! Sekarang mari masukkan sebuah bug di kode kita dengan menghapus
kondisi yang membuat fungsi new panic kalau nilainya lebih besar dari 100:
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
Saat kita menjalankan pengujian di Listing 11-8, dia bakal gagal:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Kita tidak dapat pesan yang cukup membantu di kasus ini, tapi pas kita melihat
fungsi pengujiannya, kita ingat kalau ia dianotasi dengan #[should_panic].
Kegagalan yang kita dapat berarti kode di dalam fungsi pengujian ini tidak
menyebabkan panic.
Pengujian yang memakai should_panic bisa kurang presisi. Sebuah pengujian
should_panic bakal tetap sukses meskipun terjadi panic dengan alasan yang
berbeda dari apa yang kita harapkan. Biar pengujian should_panic jadi lebih
presisi, kita bisa menambahkan parameter opsional expected ke dalam atribut
should_panic. Test harness bakal memastikan kalau pesan kegagalan tersebut
mengandung teks yang diberikan. Sebagai contoh, pertimbangkan kode yang
dimodifikasi untuk Guess di Listing 11-9 di mana fungsi new bakal panic
dengan pesan yang berbeda-beda tergantung apakah nilainya terlalu kecil atau
terlalu besar.
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
panic! di mana pesan panic-nya harus mengandung potongan teks tertentu (substring)Pengujian ini bakal sukses karena teks yang kita tulis di parameter expected
dari atribut should_panic adalah substring (bagian string) dari pesan panic
yang muncul di fungsi Guess::new. Kita bisa juga memberikan pesan panic
utuh yang kita harapkan, yang mana di kasus ini adalah Guess value must be less than or equal to 100, got 200. Apa yang kita pilih buat ditulis tergantung dari
seberapa unik atau seberapa dinamis pesan panic-nya dan seberapa presisi
kita mau pengujian ini. Di kasus ini, satu potongan teks saja dari pesan panic-nya
sudah cukup buat memastikan kalau fungsi pengujian ini menjalankan kasus blok
else if value > 100.
Untuk melihat apa yang terjadi saat sebuah pengujian should_panic dengan pesan
expected itu gagal, mari kembali masukkan bug ke dalam kode kita dengan menukar
body dari blok if value < 1 dan blok else if value > 100:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
Kali ini pas kita jalanin pengujian should_panic ini, dia bakal gagal:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Guess value must be greater than or equal to 1, got 200."
expected substring: "less than or equal to 100"
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Pesan kegagalannya menunjukkan kalau pengujian ini memang panic seperti yang
kita harapkan, tapi pesan panic-nya tidak mengandung string yang diharapkan
yaitu less than or equal to 100. Pesan panic yang justru kita dapatkan di
kasus ini adalah Guess value must be greater than or equal to 1, got 200.
Nah, dengan info ini, kita bisa mulai menelusuri di mana letak bug kita!
Menggunakan Result<T, E> di Pengujian
Semua pengujian yang kita bikin sejauh ini bakal panic saat gagal. Kita juga
bisa menulis pengujian yang menggunakan Result<T, E>! Berikut adalah
pengujian dari Listing 11-1, tapi ditulis ulang untuk memakai Result<T, E>
dan mengembalikan Err daripada panic:
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
Fungsi it_works sekarang punya tipe kembalian Result<(), String>. Di dalam
body fungsi, alih-alih memanggil macro assert_eq!, kita mengembalikan Ok(())
ketika pengujiannya sukses dan mengembalikan Err yang menampung String ketika
pengujiannya gagal.
Menulis pengujian biar mereka mengembalikan Result<T, E> memungkinkan kita
menggunakan question mark operator (?) di dalam body pengujiannya, yang bisa
jadi cara sangat nyaman untuk menulis pengujian yang seharusnya gagal jika ada
operasi di dalamnya yang mengembalikan varian Err.
Kita tidak bisa memakai anotasi #[should_panic] di pengujian yang memakai
Result<T, E>. Buat menegaskan kalau suatu operasi mengembalikan varian Err,
jangan pakai question mark operator pada nilai Result<T, E> itu. Sebaliknya,
pakai assert!(value.is_err()).
Sekarang setelah kita paham beberapa cara untuk menulis pengujian, mari kita
lihat apa yang terjadi di balik layar saat kita menjalankan pengujian dan mulai
mengeksplorasi berbagai opsi yang bisa kita pakai dengan cargo test.