Organisasi Pengujian
Seperti yang disebutkan di awal bab, pengujian adalah disiplin yang kompleks, dan orang-orang yang berbeda memakai istilah dan organisasi yang beda-beda juga. Komunitas Rust memikirkan pengujian dalam dua kategori utama: unit tests (pengujian unit) dan integration tests (pengujian integrasi). Unit tests itu kecil dan lebih fokus, menguji satu modul secara terisolasi pada satu waktu, dan bisa menguji antarmuka (interface) private. Integration tests itu sepenuhnya eksternal terhadap library kita dan memakai kode kita dengan cara yang sama persis seperti kode eksternal lainnya, hanya memakai antarmuka public dan berpotensi melibatkan beberapa modul per pengujian.
Menulis kedua jenis pengujian ini penting buat memastikan kalau bagian-bagian dari library kita melakukan apa yang kita harapkan, baik secara terpisah maupun bersama-sama.
Unit Tests
Tujuan dari unit tests adalah buat menguji setiap unit kode secara terisolasi
dari sisa kode lainnya buat dengan cepat mencari tahu di mana kode kita berjalan
sesuai harapan atau tidak. Kita bakal menaruh unit tests di dalam direktori
src di setiap file bersama dengan kode yang lagi mereka uji. Konvensinya adalah
membuat modul bernama tests di setiap file buat menampung fungsi-fungsi
pengujian dan menganotasi modul itu dengan cfg(test).
Modul Tests dan #[cfg(test)]
Anotasi #[cfg(test)] pada modul tests memberi tahu Rust buat men-compile dan
menjalankan kode pengujian hanya ketika kita menjalankan cargo test, dan tidak
saat kita menjalankan cargo build. Ini menghemat waktu kompilasi (compile time)
saat kita cuma mau mem-build library-nya dan menghemat ruang di dalam artefak
hasil kompilasi (compiled artifact) karena pengujian-pengujiannya tidak
dimasukkan. Nanti kita bakal lihat kalau integration tests itu berada di
direktori yang berbeda, jadi mereka tidak butuh anotasi #[cfg(test)]. Tapi,
karena unit tests berada di file yang sama dengan kodenya, kita bakal pakai
#[cfg(test)] buat menentukan kalau mereka seharusnya tidak dimasukkan di
hasil kompilasinya.
Ingat kembali waktu kita men-generate project adder baru di bagian pertama
bab ini, Cargo men-generate kode ini buat kita:
Nama file: 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);
}
}
Pada modul tests yang di-generate otomatis, atribut cfg singkatan dari
configuration (konfigurasi) dan ngasih tahu Rust kalau item berikutnya hanya
boleh dimasukkan jika ada opsi konfigurasi tertentu. Di kasus ini, opsi
konfigurasinya adalah test, yang disediakan oleh Rust buat men-compile dan
menjalankan pengujian. Dengan memakai atribut cfg, Cargo men-compile kode
pengujian kita cuma kalau kita secara aktif menjalankan pengujiannya dengan
cargo test. Ini termasuk fungsi bantuan (helper functions) apa pun yang
mungkin ada di dalam modul ini, selain fungsi-fungsi yang dianotasi dengan
#[test].
Menguji Fungsi Private
Ada perdebatan di komunitas pengujian tentang apakah fungsi private (privat)
sebaiknya diuji secara langsung atau tidak, dan bahasa pemrograman lain membuat
pengujian fungsi private jadi hal yang susah atau mustahil. Terlepas dari
ideologi pengujian mana yang kita anut, aturan privasi Rust memang
memperbolehkan kita untuk menguji fungsi private. Coba perhatikan kode di
Listing 11-12 yang punya fungsi private internal_adder.
pub fn add_two(a: u64) -> u64 {
internal_adder(a, 2)
}
fn internal_adder(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
let result = internal_adder(2, 2);
assert_eq!(result, 4);
}
}
Perhatikan bahwa fungsi internal_adder tidak ditandai sebagai pub.
Pengujian itu pada dasarnya cuma kode Rust biasa, dan modul tests itu cuma
modul biasa juga. Seperti yang kita bahas di “Paths untuk Merujuk ke sebuah
Item di Pohon Modul”, items di dalam modul anak (child modules)
bisa memakai items di dalam modul leluhurnya (ancestor modules). Di pengujian
ini, kita membawa semua items dari induk modul tests ke dalam scope
dengan use super::*, lalu pengujian ini bisa memanggil internal_adder.
Kalau kita ngerasa fungsi private itu tidak perlu diuji, tidak ada hal di
Rust yang bakal memaksa kita buat ngelakuin itu.
Integration Tests
Di Rust, integration tests (pengujian integrasi) itu sepenuhnya eksternal terhadap library kita. Mereka memakai library kita dengan cara yang persis sama seperti kode eksternal lainnya, yang artinya mereka cuma bisa memanggil fungsi-fungsi yang merupakan bagian dari API public library kita. Tujuannya adalah buat menguji apakah banyak bagian dari library kita bekerja sama dengan benar. Unit-unit kode yang berjalan dengan benar saat sendirian bisa saja punya masalah pas digabungkan (integrated), jadi cakupan pengujian (test coverage) pada kode yang tergabung itu juga penting. Buat bikin integration tests, kita pertama-tama harus punya direktori tests.
Direktori tests
Kita membuat direktori tests di tingkat teratas (top level) direktori project kita, bersebelahan dengan src. Cargo tahu dia harus mencari file-file integration test di direktori ini. Kita kemudian bisa membuat sebanyak apa pun file pengujian yang kita mau, dan Cargo bakal men-compile tiap file tersebut sebagai sebuah crate individu.
Mari kita bikin sebuah integration test. Dengan kode di Listing 11-12 yang masih ada di file src/lib.rs, buat sebuah direktori tests, dan bikin file baru bernama tests/integration_test.rs. Struktur direktori kita seharusnya jadi seperti ini:
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
Masukkan kode di Listing 11-13 ke dalam file tests/integration_test.rs.
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
adderTiap file di dalam direktori tests itu adalah sebuah crate terpisah,
jadi kita perlu membawa library kita ke dalam scope masing-masing test crate.
Oleh karena itu kita menambahkan use adder::add_two; di paling atas kode
kita, yang mana tidak kita perlukan di unit tests.
Kita tidak perlu menganotasi kode apa pun di tests/integration_test.rs dengan
#[cfg(test)]. Cargo memperlakukan direktori tests secara spesial dan
men-compile file-file di direktori ini cuma kalau kita menjalankan cargo test.
Jalankan cargo test sekarang:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)
running 1 test
test 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
Tiga bagian dari output ini mencakup unit tests, integration test, dan doc tests. Perhatikan bahwa kalau ada pengujian di sebuah bagian yang gagal, bagian-bagian selanjutnya tidak bakal dijalankan. Misalnya, kalau ada unit test yang gagal, tidak bakal ada output buat integration tests atau doc tests karena pengujian-pengujian itu baru bakal jalan kalau semua unit tests sukses.
Bagian pertama untuk unit tests itu sama dengan yang selama ini kita lihat:
satu baris buat setiap unit test (satu bernama internal yang kita tambahkan
di Listing 11-12) dan kemudian satu baris ringkasan buat unit tests tersebut.
Bagian integration tests dimulai dengan baris Running tests/integration_test.rs. Setelah itu, ada satu baris buat setiap fungsi
pengujian di dalam integration test itu dan satu baris ringkasan buat
hasil dari integration test tepat sebelum bagian Doc-tests adder dimulai.
Tiap file integration test punya bagiannya masing-masing, jadi kalau kita nambahin lebih banyak file di direktori tests, bakal ada lebih banyak bagian integration test.
Kita masih bisa menjalankan fungsi integration test tertentu dengan
menentukan nama fungsi pengujian itu sebagai argumen di cargo test. Buat
menjalankan semua pengujian di dalam file integration test tertentu, kita
bisa memakai argumen --test di cargo test diikuti dengan nama filenya:
$ cargo test --test integration_test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Perintah ini hanya menjalankan pengujian-pengujian yang ada di file tests/integration_test.rs.
Submodul dalam Integration Tests
Saat kita menambahkan lebih banyak integration tests, kita mungkin mau membuat lebih banyak file di dalam direktori tests untuk membantu mengaturnya; misalnya, kita bisa mengelompokkan fungsi-fungsi pengujian berdasarkan fungsionalitas yang lagi mereka uji. Seperti yang disebutkan sebelumnya, setiap file di direktori tests di-compile sebagai crate-nya sendiri-sendiri secara terpisah, yang mana sangat berguna buat membuat scopes yang terpisah demi meniru dengan lebih dekat gimana para end users bakal memakai crate kita. Namun, ini artinya file-file di direktori tests tidak berbagi perilaku yang sama dengan file-file di direktori src, seperti yang kita pelajari di Bab 7 soal gimana memisahkan kode jadi berbagai modul dan file.
Perbedaan perilaku dari file-file di direktori tests ini paling terlihat saat
kita punya sekumpulan fungsi bantuan (helper functions) buat dipakai di berbagai
file integration test lalu kita mencoba mengikuti langkah-langkah di bagian
“Memisahkan Modul ke dalam Berbagai File”
di Bab 7 buat mengekstrak fungsi-fungsi itu ke modul bersama (common module).
Misalnya, kalau kita membuat tests/common.rs dan menaruh fungsi bernama
setup di dalamnya, kita bisa menambahkan beberapa kode ke setup yang mau
kita panggil dari beberapa fungsi pengujian yang ada di file-file pengujian
yang berbeda:
Nama file: tests/common.rs
pub fn setup() {
// setup code specific to your library's tests would go here
}
Saat kita menjalankan pengujiannya lagi, kita bakal melihat bagian baru di
output pengujiannya buat file common.rs, meskipun file ini sama sekali tidak
mengandung fungsi pengujian maupun kita memanggil fungsi setup dari mana
pun:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)
running 1 test
test 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
Munculnya common di hasil pengujian dengan pesan running 0 tests yang
ditampilkan buat modul itu bukanlah apa yang kita mau. Kita cuma mau membagikan
sedikit kode dengan file-file integration test yang lain. Buat mencegah
common muncul di output pengujian, alih-alih membuat tests/common.rs, kita
bakal membuat tests/common/mod.rs. Direktori project-nya sekarang bakal
kelihatan seperti ini:
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs
Ini adalah konvensi penamaan versi lama yang juga dipahami oleh Rust yang
sudah kita sebutkan di bagian “Alternate File Paths” di Bab 7.
Menamai file dengan cara ini memberi tahu Rust untuk tidak memperlakukan modul
common sebagai file integration test. Saat kita memindahkan kode fungsi
setup ke dalam tests/common/mod.rs dan menghapus file tests/common.rs,
bagian khusus buat modul ini di output pengujian tidak akan muncul lagi. File-file
yang ada di dalam subdirektori dari direktori tests tidak akan di-compile
sebagai crate yang terpisah maupun mendapatkan bagian khususnya sendiri di
output pengujian.
Setelah kita membuat tests/common/mod.rs, kita bisa memakainya dari file
integration test mana pun layaknya sebuah modul. Berikut ini contoh memanggil
fungsi setup dari pengujian it_adds_two yang ada di
tests/integration_test.rs:
Nama file: tests/integration_test.rs
use adder::add_two;
mod common;
#[test]
fn it_adds_two() {
common::setup();
let result = add_two(2);
assert_eq!(result, 4);
}
Perhatikan bahwa deklarasi mod common; itu sama dengan deklarasi modul yang
sudah kita demonstrasikan di Listing 7-21. Kemudian, di dalam fungsi
pengujiannya, kita bisa memanggil fungsi common::setup().
Integration Tests buat Binary Crates
Kalau project kita adalah sebuah binary crate yang hanya mengandung satu file
src/main.rs dan tidak punya file src/lib.rs, kita tidak bisa bikin
integration tests di direktori tests yang mencoba membawa fungsi-fungsi
yang didefinisikan di src/main.rs ke dalam scope dengan memakai statement
use. Cuma library crates yang mengekspos fungsi-fungsi buat bisa dipakai
oleh crates lain; binary crates itu dimaksudkan untuk berjalan sendiri.
Inilah salah satu alasan kenapa project-project Rust yang menyediakan sebuah
binary biasanya punya file src/main.rs yang lumayan simpel dan langsung
memanggil logika yang berada di dalam file src/lib.rs. Dengan struktur itu,
integration tests bisa menguji library crate dengan perintah use buat
membuat fungsionalitas utamanya jadi tersedia buat dites. Kalau fungsionalitas
utamanya bekerja dengan baik, sebagian kecil kode yang ada di file src/main.rs
itu bakal ikut bekerja dengan baik juga, dan bagian kecil kode itu tidak perlu
diuji lagi secara terpisah.
Ringkasan
Fitur pengujian Rust ngasih kita cara buat menentukan dengan spesifik gimana kode kita seharusnya berfungsi untuk memastikan kode itu terus berjalan sesuai yang kita harapkan, bahkan ketika kita membuat berbagai perubahan nanti. Unit tests mencoba bagian-bagian yang berbeda dari sebuah library secara terpisah dan bisa menguji detail implementasi private. Integration tests mengecek apakah banyak bagian dari library itu bekerja sama dengan benar, dan mereka memakai API public dari library itu buat menguji kodenya dengan cara yang persis sama dengan bagaimana kode eksternal bakal memakainya. Walaupun sistem tipe (type system) Rust dan aturan ownership ngebantu mencegah beberapa jenis bugs, pengujian tetaplah penting buat ngurangin logic bugs (kutu logika) yang berkaitan dengan bagaimana kode kita seharusnya berperilaku.
Mari kita gabungin ilmu yang udah kita pelajarin di bab ini dan di bab-bab sebelumnya buat ngerjain sebuah project bareng!