Unit Tests #
Testing in Rust is a first-class citizen — not an afterthought. The Rust compiler itself already encourages you to write tests: cargo test can be run on any project without additional configuration, doc tests run automatically from documentation comments, and the ownership system makes many classes of bugs that usually require special tests (use-after-free, null dereference) impossible at compile time. This article covers all of Rust’s built-in testing mechanisms — from unit tests in a single file to integration tests in separate directories, async tests, and benchmarking with criteria.
Anatomy of a Rust Unit Test #
Unit tests in Rust coexist with the code being tested in a single file, inside a wrapped module #[cfg(test)]. This module is only compiled when cargo test is run — it doesn’t make it into the production binary:
// src/lib.rs or src/modul.rs
pub fn tambah(a: i32, b: i32) -> i32 {
a + b
}
pub fn bagi(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
pub fn kata_palindrom(kata: &str) -> bool {
let bersih: String = kata
.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_lowercase().next().unwrap())
.collect();
bersih == bersih.chars().rev().collect::<String>()
}
// Test module — only compiled during cargo test
#[cfg(test)]
mod tests {
use super::*; // access all public and private functions of the parent module
#[test]
fn test_tambah_positif() {
assert_eq!(tambah(2, 3), 5);
}
#[test]
fn test_tambah_negatif() {
assert_eq!(tambah(-1, -1), -2);
}
#[test]
fn test_tambah_nol() {
assert_eq!(tambah(0, 0), 0);
}
#[test]
fn test_palindrom() {
assert!(kata_palindrom("kasur rusak"));
assert!(kata_palindrom("Kasur Rusak")); // is case-insensitive
assert!(!kata_palindrom("Rust"));
}
}
use super::* is important: it imports all items from the parent module — including functions and types that are private (pub or not). Unit tests are deliberately allowed access to implementation details that are not exposed to the outside.
All Macro Assertions #
assert!, assert_eq!, assert_ne!
#
#[cfg(test)]
mod tests {
#[test]
fn demo_assertion() {
// assert! — condition must be true
assert!(2 + 2 == 4);
assert!("halo".starts_with("ha"));
// assert_eq! — two values must be equal (need PartialEq + Debug)
assert_eq!(2 + 2, 4);
assert_eq!(vec![1, 2, 3], vec![1, 2, 3]);
assert_eq!("hello".to_uppercase(), "HELLO");
// assert_ne! — the two values must be different
assert_ne!(2 + 2, 5);
assert_ne!("a", "b");
// All assertions can be given a custom message as the last argument
let x = 42;
assert_eq!(x, 42, "x seharusnya 42, tapi nilainya {}", x);
assert!(x > 0, "x ({}) harus positif", x);
}
}
Custom Message — Debugging Test Failed #
Custom messages are very useful when a test fails and you need to know more context:
#[cfg(test)]
mod tests {
use super::*;
fn hitung_diskon(harga: f64, persen: f64) -> f64 {
harga * (1.0 - persen / 100.0)
}
#[test]
fn test_diskon_berbagai_skenario() {
let kasus = vec![
(100.0, 10.0, 90.0, "diskon 10%"),
(200.0, 50.0, 100.0, "diskon 50%"),
(150.0, 0.0, 150.0, "tanpa diskon"),
];
for (harga, persen, ekspektasi, deskripsi) in kasus {
let hasil = hitung_diskon(harga, persen);
assert!(
(hasil - ekspektasi).abs() < 0.001,
"GAGAL untuk {}: harga={}, diskon={}%, expected={}, got={}",
deskripsi, harga, persen, ekspektasi, hasil
);
}
}
}
#[should_panic] — Test Expected Panic
#
pub fn bagi_integer(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Tidak bisa membagi dengan nol");
}
a / b
}
pub fn ambil_elemen(v: &[i32], i: usize) -> i32 {
if i >= v.len() {
panic!("Indeks {} melebihi panjang array {}", i, v.len());
}
v[i]
}
#[cfg(test)]
mod tests {
use super::*;
// This test passes if the panic function
#[test]
#[should_panic]
fn test_bagi_nol_panic() {
bagi_integer(10, 0);
}
// More stringent: verify specific panic messages
#[test]
#[should_panic(expected = "Tidak bisa membagi dengan nol")]
fn test_bagi_nol_pesan_benar() {
bagi_integer(10, 0);
}
// ANTI-PATTERN: should_panic too loose — does not verify message
// Test passed even though panic occurred for other reasons
#[test]
#[should_panic] // passed even though I panicked because the index was out of bounds
fn test_terlalu_longgar() {
let v = vec![1, 2, 3];
ambil_elemen(&v, 99); // panics because of the index, not because of the logic we tested
}
// TRUE: use expected for more precision
#[test]
#[should_panic(expected = "Indeks 99")]
fn test_indeks_di_luar_batas() {
let v = vec![1, 2, 3];
ambil_elemen(&v, 99);
}
}
Test that Returns Result
#
Instead of manual panic!, test can return Result<(), E> — the ? operator works inside:
use std::num::ParseIntError;
fn parse_dan_kali_dua(s: &str) -> Result<i32, ParseIntError> {
let n: i32 = s.trim().parse()?;
Ok(n * 2)
}
#[cfg(test)]
mod tests {
use super::*;
// Return Result — ? operator can be used
#[test]
fn test_parse_valid() -> Result<(), ParseIntError> {
let hasil = parse_dan_kali_dua("21")?;
assert_eq!(hasil, 42);
Ok(())
}
// Test Err case
#[test]
fn test_parse_invalid() {
let hasil = parse_dan_kali_dua("bukan angka");
assert!(hasil.is_err(), "Input tidak valid harus mengembalikan Err");
}
// Verify error type
#[test]
fn test_parse_overflow() {
// i32 max is 2147483647
let hasil = parse_dan_kali_dua("9999999999");
assert!(hasil.is_err());
}
}
Test Helper and Fixture #
Rust doesn’t have @BeforeEach like JUnit, but the equivalent pattern is easy to implement:
#[derive(Debug, PartialEq)]
struct Keranjang {
item: Vec<String>,
total: f64,
}
impl Keranjang {
fn baru() -> Self {
Keranjang { item: Vec::new(), total: 0.0 }
}
fn tambah(&mut self, nama: &str, harga: f64) {
self.item.push(nama.to_string());
self.total += harga;
}
fn hapus(&mut self, nama: &str) -> bool {
if let Some(pos) = self.item.iter().position(|i| i == nama) {
self.item.remove(pos);
true
} else {
false
}
}
fn kosong(&self) -> bool {
self.item.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
// Helper function that creates the fixture — called at the start of each test
fn keranjang_dengan_isi() -> Keranjang {
let mut k = Keranjang::baru();
k.tambah("Apel", 5_000.0);
k.tambah("Mangga", 10_000.0);
k.tambah("Jeruk", 8_000.0);
k
}
#[test]
fn test_keranjang_baru_kosong() {
let k = Keranjang::baru();
assert!(k.kosong());
assert_eq!(k.total, 0.0);
}
#[test]
fn test_tambah_item() {
let mut k = Keranjang::baru();
k.tambah("Apel", 5_000.0);
assert_eq!(k.item.len(), 1);
assert_eq!(k.total, 5_000.0);
}
#[test]
fn test_hapus_item_ada() {
let mut k = keranjang_dengan_isi(); // use fixture
assert!(k.hapus("Mangga"));
assert_eq!(k.item.len(), 2);
assert!(!k.item.contains(&"Mangga".to_string()));
}
#[test]
fn test_hapus_item_tidak_ada() {
let mut k = keranjang_dengan_isi();
assert!(!k.hapus("Durian")); // does not exist — return false
assert_eq!(k.item.len(), 3); // amount has not changed
}
#[test]
fn test_total_benar() {
let k = keranjang_dengan_isi();
assert_eq!(k.total, 23_000.0);
}
}
#[ignore] and Run Selective Test
#
#[cfg(test)]
mod tests {
#[test]
fn test_cepat() {
assert_eq!(2 + 2, 4);
}
// Test is slow — i.e. requires a database connection or takes a long time
#[test]
#[ignore = "butuh koneksi database produksi"]
fn test_integrasi_database() {
// expensive test that is not run by default
}
#[test]
#[ignore = "WIP — implementasi belum selesai"]
fn test_fitur_baru() {
todo!()
}
}
cargo test # jalankan semua kecuali yang #[ignore]
cargo test test_cepat # jalankan test yang namanya mengandung "test_cepat"
cargo test -- --ignored # jalankan hanya yang #[ignore]
cargo test -- --include-ignored # jalankan semua termasuk yang #[ignore]
cargo test -- --nocapture # tampilkan println! di dalam test
cargo test -- --test-threads=1 # jalankan serial (tidak paralel)
cargo test modul::tests:: # filter berdasarkan path modul
Integration Test #
Integration tests are in the tests/ directory in the project root — separate from the source code. They can only access crate’s public API, just like external users:
proyek/
├── src/
│ └── lib.rs
├── tests/
│ ├── integrasi_keranjang.rs
│ └── integrasi_api.rs
└── Cargo.toml
// tests/integration_cart.rs
// No need for #[cfg(test)] — this entire file is test
use nama_crate::{Keranjang}; // can only access pub items
#[test]
fn test_skenario_lengkap_pembelian() {
let mut k = Keranjang::baru();
k.tambah("Laptop", 15_000_000.0);
k.tambah("Mouse", 250_000.0);
k.tambah("Keyboard", 500_000.0);
assert_eq!(k.item.len(), 3);
assert_eq!(k.total, 15_750_000.0);
k.hapus("Mouse");
assert_eq!(k.total, 15_500_000.0);
}
// Shared helper module between integration test files
mod common; // refers to tests/common/mod.rs or tests/common.rs
// tests/common/mod.rs — common helper for all integration tests
pub fn setup_lingkungan_test() {
// Initialization required by all integration tests
// For example: setup in-memory database, create temp files, etc.
}
Async Test with Tokio #
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
// The async function you want to test
async fn ambil_data(url: &str) -> Result<String, String> {
// Simulation of HTTP requests
if url.starts_with("https://") {
Ok(format!("Data dari {}", url))
} else {
Err(format!("URL tidak valid: {}", url))
}
}
async fn proses_paralel(data: Vec<i32>) -> Vec<i32> {
let mut handles = vec![];
for n in data {
handles.push(tokio::spawn(async move { n * n }));
}
let mut hasil = vec![];
for h in handles {
hasil.push(h.await.unwrap());
}
hasil
}
#[cfg(test)]
mod tests {
use super::*;
// The tokio::test attribute replaces #[test] for async functions
#[tokio::test]
async fn test_ambil_data_valid() {
let hasil = ambil_data("https://api.example.com").await;
assert!(hasil.is_ok());
assert!(hasil.unwrap().contains("api.contoh.com"));
}
#[tokio::test]
async fn test_ambil_data_url_invalid() {
let hasil = ambil_data("http://no-https.com").await;
assert!(hasil.is_err());
}
#[tokio::test]
async fn test_proses_paralel() {
let input = vec![1, 2, 3, 4, 5];
let mut hasil = proses_paralel(input).await;
hasil.sort();
assert_eq!(hasil, vec![1, 4, 9, 16, 25]);
}
// Test with timeout — fails if not completed within time limit
#[tokio::test(flavor = "multi_thread")]
async fn test_dengan_timeout() {
let hasil = tokio::time::timeout(
std::time::Duration::from_secs(1),
ambil_data("https://fast.com"),
)
.await;
assert!(hasil.is_ok(), "Operasi melebihi timeout 1 detik");
}
}
Benchmarking with criterion
#
Nightly Rust’s ZZL19ZZ has criterion built in, but ZZI1ZZ in stable Rust is much more accurate and produces a complete statistics report:
has #[bench] built in, but criterion in stable Rust is much more accurate and produces a complete statistics report:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "benchmark_utama"
harness = false
// benches/benchmark_main.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn fibonacci_rekursif(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
n => fibonacci_rekursif(n - 1) + fibonacci_rekursif(n - 2),
}
}
fn fibonacci_iteratif(n: u64) -> u64 {
let mut a = 0u64;
let mut b = 1u64;
for _ in 0..n {
let temp = a;
a = b;
b = temp + b;
}
a
}
fn bench_fibonacci(c: &mut Criterion) {
// One-function benchmark
c.bench_function("fibonacci rekursif n=20", |b| {
b.iter(|| fibonacci_rekursif(black_box(20)))
});
// Comparison of two implementations with different inputs
let mut grup = c.benchmark_group("fibonacci perbandingan");
for n in [10u64, 15, 20, 25].iter() {
grup.bench_with_input(
BenchmarkId::new("rekursif", n),
n,
|b, &n| b.iter(|| fibonacci_rekursif(black_box(n))),
);
grup.bench_with_input(
BenchmarkId::new("iteratif", n),
n,
|b, &n| b.iter(|| fibonacci_iteratif(black_box(n))),
);
}
grup.finish();
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
cargo bench # jalankan semua benchmark
cargo bench fibonacci # filter benchmark berdasarkan nama
# Hasil HTML tersedia di target/criterion/
black_box() prevents the compiler from optimizing benchmark calculations to constants — without which benchmark results are inaccurate.
Summary #
#[cfg(test)]wraps all unit tests — this module is only compiled atcargo test, not in the production binary.use super::*imports private functions though.- Use custom message in assertion —
assert_eq!(a, b, "Konteks: a={}, b={}", a, b)is much more informative when a test fails than the default output.#[should_panic(expected = "...")]is better than#[should_panic]— specific panic message verification prevents tests from passing due to unexpected panic.- Test can return
Result— operator?can be used in tests that returnResult<(), E>, making tests for code that returnResultmore natural.- Helper function for fixture — Rust doesn’t have
@BeforeEach, but a regular function that creates an initial state and is called at the start of each test gives the same results.- Integration test in
tests/— can only access the crate public API, suitable for end-to-end testing from a library user perspective.#[tokio::test]for async test — replacement for#[test]for functionasync fn. Can be combined withtokio::time::timeoutfor timed tests.criterionfor benchmarking in stable Rust — more accurate than#[bench]nightly, produces statistical reports (median, standard deviation) and comparisons between runs.black_box()prevents benchmark optimization — without it the compiler could optimize the calculation to a constant, making the benchmark meaningless.