Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Filename: src/lib.rs
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);
    }
}
Listing 11-1: Kode yang di-generate secara otomatis oleh cargo new

File 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

Listing 11-2: Output dari menjalankan pengujian yang di-generate otomatis

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.

Filename: 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);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: Menambahkan pengujian kedua yang bakal gagal karena kita memanggil macro 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`
Listing 11-4: Hasil pengujian saat satu pengujian sukses dan yang lainnya gagal

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

Filename: 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
    }
}
Listing 11-5: Struct Rectangle dan method can_hold-nya dari Bab 5

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

Filename: 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() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: Pengujian buat can_hold yang mengecek apakah persegi panjang yang lebih besar benar-benar bisa menampung persegi panjang yang lebih kecil

Perhatikan 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!.

Filename: src/lib.rs
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);
    }
}
Listing 11-7: Menguji fungsi 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.

Filename: src/lib.rs
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);
    }
}
Listing 11-8: Menguji apakah sebuah kondisi bakal mengakibatkan 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.

Filename: src/lib.rs
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);
    }
}
Listing 11-9: Menguji sebuah 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.