Bỏ qua để đến nội dung

Kiểm thử nâng cao trong Rust: mock, property test và benchmark

Bạn đã biết cách viết test với #[test] và assert_eq! (nếu chưa, hãy bắt đầu với Kiểm thử). Chừng đó là đủ cho các chương trình nhỏ. Nhưng dự án thật sẽ sớm đặt ra những câu hỏi mới. Test nên nằm ở đâu? Làm sao kiểm thử code gửi email hoặc đọc đồng hồ hệ thống? Làm sao tìm ra đúng cái đầu vào mà bạn chưa nghĩ tới? Và phiên bản “nhanh hơn” của bạn có thật sự nhanh hơn không?

Hãy hình dung một xưởng sửa xe. Test cơ bản là kiểm tra từng bộ phận có chạy không. Trang này bổ sung phần còn lại của xưởng: một bàn thử cho bộ phận (fake và mock), một cỗ máy rung chiếc xe với hàng nghìn cú xóc ngẫu nhiên (property test), một tấm ảnh chụp trạng thái lần trước (snapshot), và một chiếc đồng hồ bấm giờ đáng tin cậy (benchmark).

Một lưu ý thật lòng trước khi bắt đầu: nút Run của Rust Playground chạy main, không chạy test, và Playground không có các crate kiểm thử dùng bên dưới. Vì vậy phần lớn ví dụ ở đây cho thấy kết quả thật của cargo test và cargo bench từ một dự án nhỏ trên một máy tính bình thường. Kết quả của các công cụ này luôn bằng tiếng Anh. Chương trình nào có main thì vẫn có nút Try it như thường lệ.

Đây là cấu trúc của thư viện nhỏ shop dùng trong trang này:

shop/
├── Cargo.toml
├── src/
│ ├── lib.rs <- code + unit tests in `mod tests`
│ └── orders.rs
├── tests/
│ ├── checkout.rs <- an integration test
│ └── common/
│ └── mod.rs <- helpers shared by integration tests
└── benches/
└── stock.rs <- a benchmark

Ở đây, lib.rs chứa code cùng các unit test trong mod tests, tests/checkout.rs là một integration test, tests/common/mod.rs chứa các hàm trợ giúp dùng chung, và benches/stock.rs là một benchmark. Rust có ba loại test, và cargo test chạy tất cả:

  • Unit test nằm cạnh code, trong một khối #[cfg(test)] mod tests. Chúng nhìn thấy được cả các hàm private.
  • Integration test nằm trong thư mục tests/. Mỗi file được biên dịch thành một crate riêng, dùng thư viện của bạn từ bên ngoài, y như một người dùng thật. Vì vậy chúng chỉ gọi được các thành phần pub.
  • Doc test là các ví dụ code bên trong chú thích ///. Chúng chứng minh tài liệu của bạn vẫn đúng. Trang Tài liệu nói về chúng chi tiết.

Đây là một phần của src/lib.rs. Hãy để ý doc test trên apply_discount, và các hàm trợ giúp (helper) và fixture bên trong mod tests:

/// Một dòng trong giỏ hàng. Giá tính bằng cent.
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub name: String,
pub price_cents: u32,
pub qty: u32,
}
pub fn cart_total(items: &[Item]) -> u32 {
items.iter().map(|i| i.price_cents * i.qty).sum()
}
/// Giảm `percent` phần trăm trên một mức giá.
///
/// ```
/// use shop::apply_discount;
///
/// assert_eq!(apply_discount(1000, 25), 750);
/// ```
pub fn apply_discount(price_cents: u32, percent: u32) -> u32 {
assert!(percent <= 100, "discount must be at most 100%, got {percent}%");
price_cents - price_cents * percent / 100
}
/// Biến "12.50" thành 1250 cent.
pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Biến 1250 cent thành "12.50".
pub fn format_price(cents: u32) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
#[cfg(test)]
mod tests {
use super::*;
// Một helper: gọi ngắn gọn, và test nào cũng tạo item theo cùng một cách.
fn item(name: &str, price_cents: u32, qty: u32) -> Item {
Item { name: name.to_string(), price_cents, qty }
}
// Một fixture: một giỏ hàng thực tế mà nhiều test dùng chung.
fn sample_cart() -> Vec<Item> {
vec![item("coffee", 450, 2), item("bagel", 300, 1)]
}
#[test]
fn total_of_sample_cart() {
assert_eq!(cart_total(&sample_cart()), 1200);
}
#[test]
fn empty_cart_costs_nothing() {
assert_eq!(cart_total(&[]), 0);
}
#[test]
#[should_panic(expected = "at most 100%")]
fn discount_over_100_panics() {
apply_discount(1000, 150);
}
// Test có thể trả về Result, nên bạn dùng được `?` bên trong.
#[test]
fn parses_a_price() -> Result<(), String> {
let cents = parse_price("12.50")?;
assert_eq!(cents, 1250);
Ok(())
}
#[test]
#[ignore = "slow: checks ten million prices"]
fn every_price_round_trips() {
for cents in 0..10_000_000 {
assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}
}

Vài điều đáng chú ý:

  • Helper và fixture chỉ là các hàm bình thường. Rust không có hệ thống fixture đặc biệt nào, và bạn hiếm khi thấy thiếu. Vì chúng nằm trong #[cfg(test)], chúng không bao giờ lọt vào chương trình thật của bạn.
  • #[should_panic(expected = "...")] chỉ qua khi thông báo panic chứa đoạn văn bản đó, nên một panic do bug khác gây ra sẽ không được tính. Kiểm thử giải thích nó với nhiều ví dụ hơn.
  • Test trả về Result cho phép bạn dùng ?. Nếu test trả về Err, nó thất bại và in lỗi ra, ví dụ Error: "missing '.' in \"12,50\"".
  • #[ignore] mặc định bỏ qua một test chậm hoặc đặc biệt. Phần chữ sau dấu = là lý do, và cargo test sẽ in nó ra (vì vậy chúng ta giữ nó bằng tiếng Anh, khớp với kết quả bên dưới).

Integration test dùng thư viện qua API public của nó. Helper dùng chung của nó nằm trong tests/common/mod.rs:

tests/common/mod.rs
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> {
vec![
Item { name: "coffee".into(), price_cents: 450, qty: 2 },
Item { name: "bagel".into(), price_cents: 300, qty: 1 },
]
}
tests/checkout.rs
mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]
fn discounted_breakfast_total() {
let total = cart_total(&common::breakfast_cart());
let to_pay = apply_discount(total, 10);
println!("to pay: {}", format_price(to_pay));
assert_eq!(format_price(to_pay), "10.80");
}

Chạy cargo test sẽ cho mỗi loại test một phần riêng:

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 test
test discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 test
test src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Hằng ngày, không phải lúc nào bạn cũng muốn chạy mọi test. Các lệnh sau sẽ giúp bạn. Mọi thứ sau một dấu -- đứng riêng sẽ được chuyển cho chính chương trình test, không phải cho Cargo.

Terminal window
cargo test discount # chỉ các test có tên chứa "discount"
cargo test --test checkout # chỉ file tests/checkout.rs
cargo test --lib # chỉ các unit test trong src/
cargo test --doc # chỉ các doc test
cargo test -- --ignored # chỉ các test có #[ignore]
cargo test -- --include-ignored # tất cả, kể cả các test bị bỏ qua
cargo test -- --nocapture # hiện kết quả println! của các test qua
cargo test -- --test-threads=1 # chạy từng test một

Bộ lọc khớp với một phần của tên đầy đủ của test, trên mọi file test. Ở đây discount tìm thấy một unit test và một integration test:

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 test
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 test
test discounted_breakfast_total ... ok

Test chậm chỉ chạy khi bạn yêu cầu. Mười triệu vòng chuyển đổi qua lại mất khoảng 3 giây ở đây:

running 1 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

Mặc định, Rust ẩn kết quả println! của các test qua, để báo cáo gọn gàng. --nocapture sẽ hiện nó ra:

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

Một số đoạn code giao tiếp với thế giới bên ngoài: gửi email, gọi API, hoặc đọc đồng hồ. Bạn không muốn test của mình gửi email thật. Bạn cũng không thể kiểm thử “chuyện gì xảy ra lúc 9 giờ tối” nếu code luôn hỏi đồng hồ thật.

Câu trả lời đúng chất Rust không cần thư viện nào cả. Hãy mô tả phần phụ thuộc đó bằng một trait, cho hàm của bạn nhận bất cứ thứ gì triển khai trait đó, và viết một fake (bản giả) nhỏ cho test. Chương trình thật truyền vào thứ thật. Test truyền vào bản giả.

Ở đây, việc xác nhận đơn hàng gửi một email cho khách, và đơn hàng lớn còn báo cho đội bán hàng:

trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Mailer thật. Trong ứng dụng thật, nó sẽ gọi một dịch vụ email.
struct ConsoleMailer;
impl Mailer for ConsoleMailer {
fn send(&mut self, to: &str, body: &str) {
println!("email tới {to}: {body}");
}
}
struct Order {
id: u32,
email: String,
total_cents: u32,
}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) {
let body = format!("Đơn hàng #{} đã được xác nhận", order.id);
mailer.send(&order.email, &body);
if order.total_cents >= 10_000 {
mailer.send("[email protected]", &format!("Đơn hàng lớn #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&small, &mut mailer);
confirm_order(&big, &mut mailer);
}
#[cfg(test)]
mod tests {
use super::*;
// Một fake: nó không gửi gì cả, chỉ ghi lại mọi lời gọi.
#[derive(Default)]
struct FakeMailer {
sent: Vec<(String, String)>,
}
impl Mailer for FakeMailer {
fn send(&mut self, to: &str, body: &str) {
self.sent.push((to.to_string(), body.to_string()));
}
}
#[test]
fn small_order_sends_one_email() {
let mut fake = FakeMailer::default();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "Đơn hàng lớn #8".to_string()));
}
}

Kết quả:

email tới [email protected]: Đơn hàng #7 đã được xác nhận
email tới [email protected]: Đơn hàng #8 đã được xác nhận
email tới [email protected]: Đơn hàng lớn #8
▶ Tự chạy thử

Ví dụ này không dùng crate bên ngoài nào, nên trong Playground bạn cũng có thể chọn Test từ menu cạnh nút Run:

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

confirm_order không biết và cũng chẳng quan tâm nó nhận mailer nào. Toàn bộ bí quyết chỉ có vậy. Cùng ý tưởng đó dùng được cho một trait Clock (test dùng một chiếc đồng hồ đứng yên ở 9 giờ tối), một PaymentGateway, hay một trait Storage mà trong test được hiện thực bằng một HashMap. Bạn sẽ luyện phiên bản đồng hồ ở bên dưới.

Tự viết fake bằng tay vẫn ổn với một hai trait. Khi có nhiều trait, crate mockall (tiếng Anh) có thể sinh ra chúng giúp bạn. Hãy thêm nó dưới dạng dev-dependency, để nó chỉ được dùng cho test và ví dụ (Cargo và crate giải thích các phần của Cargo.toml):

Terminal window
cargo add --dev mockall

Đặt #[automock] lên trait, và mockall tạo ra một kiểu MockMailer. Phần cfg_attr(test, ...) nghĩa là “chỉ làm việc này khi chạy test”, nên mock không bao giờ lọt vào bản build thật của bạn. Cargo feature và #[cfg] giải thích cfg chi tiết hơn.

src/orders.rs
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Order và confirm_order giống hệt ở trên.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_order_also_tells_sales() {
let mut mailer = MockMailer::new();
mailer
.expect_send()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "Đơn hàng lớn #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut mailer);
}
}

Mỗi expect_send() là một kỳ vọng (expectation): “tôi mong send được gọi với các đối số khớp với withf, đúng một lần”. return_const(()) cho biết mock trả về gì (ở đây là không có gì). Khi mock bị hủy ở cuối test, mockall kiểm tra xem mọi kỳ vọng đã được đáp ứng chưa.

Để thấy nó bắt bug, hãy đổi quy tắc trong confirm_order thành order.total_cents > 12_000. Giờ email cho đội bán hàng không bao giờ được gửi:

thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:
MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1

Thông báo nói rằng kỳ vọng được gọi 0 lần, ít hơn con số 1 đã mong đợi. Fake hay mock? Hãy bắt đầu với một fake tự viết. Nó là Rust thuần, dễ đọc, và không cần thêm phụ thuộc nào. Hãy dùng mockall khi bạn có nhiều trait cần làm giả, hoặc khi bạn thật sự quan tâm tới chuyện “được gọi đúng N lần với các đối số này”.

Kiểm thử dựa trên thuộc tính với proptest

Phần tiêu đề “Kiểm thử dựa trên thuộc tính với proptest”

Một test bình thường kiểm tra một ví dụ do bạn chọn: “12.50 được phân tích thành 1250”. Một property test (kiểm thử dựa trên thuộc tính) phát biểu một quy tắc phải đúng với mọi đầu vào, rồi một thư viện thử hàng trăm đầu vào ngẫu nhiên để phá nó. Giống như thuê một người cực kỳ kiên nhẫn thử mọi giá trị kỳ quặc mà bạn chưa nghĩ tới.

Những thuộc tính tốt thường trông như sau:

  • Làm một việc hai lần thì hoàn tác nó: đảo ngược một danh sách hai lần sẽ cho lại danh sách ban đầu.
  • Một vòng đi rồi về trả lại đúng thứ bạn bắt đầu: parse(format(x)) == x.
  • Một sự thật nào đó luôn đúng: danh sách đã sắp xếp có cùng độ dài với đầu vào.

Thêm proptest (tiếng Anh) bằng cargo add --dev proptest, và viết test bên trong macro proptest!. Mỗi dòng name in strategy cho biết loại giá trị ngẫu nhiên cần sinh ra:

tests/properties.rs
use proptest::prelude::*;
use shop::{format_price, parse_price};
proptest! {
#[test]
fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) {
let mut copy = list.clone();
copy.reverse();
copy.reverse();
prop_assert_eq!(copy, list);
}
#[test]
fn price_survives_format_then_parse(cents in 0u32..10_000_000) {
prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}

any::<i32>() nghĩa là “một i32 bất kỳ”, 0u32..10_000_000 nghĩa là một số trong khoảng đó, và vec(..., 0..100) tạo các danh sách có tối đa 99 phần tử. Mặc định mỗi thuộc tính chạy 256 trường hợp ngẫu nhiên. Hãy dùng prop_assert_eq! thay cho assert_eq! bên trong proptest!, để proptest xử lý thất bại một cách gọn gàng. Cả hai đều qua:

running 2 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Hãy để ý rằng đây chính là phép kiểm tra giống test chậm có #[ignore] ở phần trước. Test đó thử cả mười triệu mức giá trong 3 giây. Property test thử 256 mức giá ngẫu nhiên trong một phần trăm giây, nên có thể chạy mỗi lần.

Giờ hãy cố tình phạm một lỗi. Ai đó “cải tiến” format_price để in dấu phân cách hàng nghìn, như 1,000.00. Trông đẹp hơn, nhưng họ quên mất parse_price:

pub fn format_price(cents: u32) -> String {
let euros = cents / 100;
let euros = if euros >= 1000 {
format!("{},{:03}", euros / 1000, euros % 1000)
} else {
euros.to_string()
};
format!("{euros}.{:02}", cents % 100)
}

Đây là kết quả thật của cargo test --test properties, được cắt gọn một chút:

---- price_survives_format_then_parse stdout ----
proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:
Test failed: assertion failed: `(left == right)`
left: `Err("bad euros in \"1,000.00\"")`,
right: `Ok(100000)` at tests/properties.rs:15.
minimal failing input: cents = 100000

Hãy nhìn minimal failing input: cents = 100000 (đầu vào thất bại nhỏ nhất). Giá trị ngẫu nhiên đầu tiên bị lỗi có lẽ là một con số lộn xộn như 7384912. Sau đó proptest thu nhỏ (shrink) nó: nó liên tục thử các giá trị nhỏ hơn mà vẫn thất bại, rồi báo cáo giá trị đơn giản nhất. 100000 cent chính là 1,000.00, mức giá đầu tiên có dấu phẩy. Điều đó chỉ thẳng vào bug.

Proptest cũng lưu trường hợp thất bại vào một file .proptest-regressions cạnh test, và thử nó đầu tiên trong mọi lần chạy sau. Hãy commit file đó, để bug không bao giờ lặng lẽ quay lại. Nếu bạn thích một thư viện nhỏ hơn, quickcheck (tiếng Anh) làm cùng việc với ít tính năng hơn.

Một số kết quả rất dài: một hóa đơn, một báo cáo lỗi, một tài liệu JSON lớn. Viết assert_eq! với toàn bộ văn bản mong đợi thật mệt. Một snapshot test lưu kết quả vào một file ở lần chạy đầu, và trong các lần sau so sánh kết quả mới với “tấm ảnh” đã lưu đó.

Với insta (tiếng Anh) (cargo add --dev insta), test chỉ có một dòng:

pub fn receipt(items: &[Item]) -> String {
let mut out = String::new();
for i in items {
let line = format_price(i.price_cents * i.qty);
out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line));
}
out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items))));
out
}
#[test]
fn receipt_layout() {
insta::assert_snapshot!(receipt(&sample_cart()));
}

Lần chạy đầu tiên cố ý thất bại và ghi ra một file .snap.new để bạn xem lại. Sau khi bạn chấp nhận nó (bằng cargo insta review của công cụ cargo-insta, hoặc bằng cách đổi tên file thành .snap), nó nằm trong src/snapshots/:

---
source: src/lib.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

Về sau, nếu ai đó đổi "TOTAL" thành "Total", test sẽ thất bại với một bản so sánh khác biệt (diff) rõ ràng:

-old snapshot
+new results
────────────┬───────────────────────────────────────────────────────────────────
1 1 │ coffee x2 9.00
2 2 │ bagel x1 3.00
3 │-TOTAL 12.00
3 │+Total 12.00
────────────┴───────────────────────────────────────────────────────────────────
To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`

Nếu thay đổi đó là có chủ đích, bạn chấp nhận snapshot mới. Nếu không, bạn vừa tìm ra một bug. Snapshot rất hợp cho kiểu kiểm tra “thứ này không được vô tình thay đổi”, nhưng nó không cho bạn biết kết quả có đúng hay không. Hãy đọc kỹ snapshot đầu tiên trước khi chấp nhận.

Khi muốn biết code có nhanh không, hãy đo. Đoán thì sai thường xuyên đến bất ngờ. Đo thời gian bên trong một #[test] cũng không hiệu quả: test được build không có tối ưu hóa, và một lần chạy thì quá nhiễu.

criterion (tiếng Anh) là công cụ thường dùng trên Rust stable. Nó chạy code của bạn nhiều lần, làm phần thống kê giúp bạn, và nhớ kết quả lần trước để báo cho bạn biết code nhanh hơn hay chậm hơn. Thêm nó bằng cargo add --dev criterion, rồi khai báo file benchmark với Cargo, và tắt harness có sẵn:

[[bench]]
name = "stock"
harness = false

Benchmark này hỏi: để kiểm tra một mã sản phẩm có trong kho không, với 1.000 mã thì Vec hay HashSet nhanh hơn?

benches/stock.rs
use std::collections::HashSet;
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) {
// 1.000 mã sản phẩm, như "SKU-0", "SKU-1", ...
let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect();
let code_set: HashSet<String> = codes.iter().cloned().collect();
let wanted = String::from("SKU-999"); // trường hợp tệ nhất cho Vec: phần tử cuối cùng
c.bench_function("vec contains", |b| {
b.iter(|| codes.contains(black_box(&wanted)))
});
c.bench_function("hashset contains", |b| {
b.iter(|| code_set.contains(black_box(&wanted)))
});
}
criterion_group!(benches, stock_lookup);
criterion_main!(benches);

b.iter(...) là đoạn code được đo. std::hint::black_box giấu một giá trị khỏi trình tối ưu hóa. Không có nó, trình biên dịch có thể nhận ra đáp án không bao giờ đổi, tính nó một lần, và bạn sẽ đo… chẳng gì cả.

Chạy nó bằng cargo bench, lệnh này luôn build ở chế độ tối ưu (release). Con số trên máy bạn sẽ khác:

vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]
Found 2 outliers among 100 measurements (2.00%)
1 (1.00%) low mild
1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe

Cách đọc: ba con số thời gian là một khoảng. Số ở giữa là ước lượng tốt nhất, hai số bên ngoài nói “giá trị thật rất có thể nằm giữa hai số này”. Ở đây HashSet mất khoảng 19 nano giây và Vec khoảng 2,4 micro giây, nên với trường hợp này set nhanh hơn trên 100 lần. “Outliers” (giá trị ngoại lai) là vài lần chạy chậm hoặc nhanh bất thường, thường do các chương trình khác trên máy bạn gây ra. Có vài cái là bình thường.

Chạy lại sau một thay đổi, criterion sẽ so sánh với lần chạy trước:

vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs]
change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05)
No change in performance detected.

Thay đổi dưới 1% và có thể chỉ là nhiễu, nên criterion nói “No change” (không có thay đổi). Nó cũng ghi một báo cáo HTML trong target/criterion/. Về chuyện khi nào nên (và không nên) bận tâm tới tốc độ, xem Hiệu năng.

Bạn cũng có thể thấy các hàm #[bench] trong code cũ. Chúng dùng test::Bencher có sẵn, vốn chỉ chạy trên trình biên dịch nightly:

#![feature(test)] // chỉ có trên nightly
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

Trên Rust stable, hãy dùng criterion.

Khi đã có test, còn hai công cụ nữa giúp ích. Bạn cài chúng bằng cargo install, giống các công cụ khác ở trang Công cụ và cài đặt Rust.

cargo-nextest là một trình chạy test nhanh hơn. Nó chạy mỗi test trong một tiến trình riêng, hiện kết quả rõ ràng hơn, và có thể chạy lại các test chập chờn (flaky). Một điều cần biết: nó không chạy doc test, nên hãy chạy thêm cargo test --doc.

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

Độ phủ (coverage) cho bạn biết những dòng nào thật sự được test chạy qua. cargo-llvm-cov dùng tính năng đo độ phủ có sẵn trong trình biên dịch và chạy được trên Linux, macOS và Windows:

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # bảng tóm tắt trong terminal
cargo llvm-cov --html # báo cáo duyệt được trong target/llvm-cov/html

cargo-tarpaulin là một lựa chọn cũ hơn và phổ biến. Nó chạy tốt nhất trên Linux và hay được dùng trong CI:

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

Hãy coi độ phủ là một tấm bản đồ, không phải một điểm số. Nó cho thấy code mà không test nào chạm tới. Nó không cho bạn biết các assert có kiểm tra đúng thứ cần kiểm tra hay không. Độ phủ 80% với assert tốt hơn hẳn 100% với assert yếu.

Trong CI (GitHub Actions, GitLab, v.v.), một điểm khởi đầu tốt là ba lệnh giống hệt những gì bạn chạy trên máy, để một lần kiểm tra thất bại mang cùng một ý nghĩa ở mọi nơi:

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

Integration test cho một crate chỉ có binary. Nếu dự án của bạn chỉ có src/main.rs, các file trong tests/ không import được gì từ nó:

error[E0432]: unresolved import `todo`
--> tests/tasks.rs:1:5
|
1 | use todo::add_task;
| ^^^^ use of unresolved module or unlinked crate `todo`

Thông báo nói rằng không tìm thấy module hoặc crate todo. Cách sửa: chuyển logic sang src/lib.rs và giữ main.rs thật gọn. Khi đó main.rs gọi thư viện, và test của bạn cũng vậy. Việc này còn giúp code dễ dùng lại hơn.

Đặt helper dùng chung trong tests/common.rs. Mọi file .rs nằm trực tiếp trong tests/ đều được coi là một file test riêng, nên bạn sẽ thấy một phần thừa vô dụng trong kết quả:

Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests

Cách sửa: dùng tests/common/mod.rs, và mod common; trong mỗi file test cần đến nó.

Đặt crate dùng cho test trong [dependencies]. proptest, mockall, insta và criterion thuộc về [dev-dependencies] (dùng cargo add --dev). Nếu không, mọi người dùng crate của bạn đều phải tải và biên dịch chúng.

Các test dùng chung trạng thái. Test chạy song song. Nếu hai test cùng ghi một file hoặc cùng thay đổi một biến môi trường, chúng sẽ thất bại ngẫu nhiên. Hãy cho mỗi test một thư mục tạm riêng (crate tempfile giúp được việc này), hoặc, như phương án cuối cùng, chạy với --test-threads=1.

Benchmark mà không có black_box, hoặc benchmark trong test. Trình tối ưu hóa có thể xóa bỏ những phần việc mà kết quả không được dùng, và đo thời gian một #[test] ở chế độ debug chẳng nói lên được bao nhiêu. Hãy dùng criterion với black_box.

Hàm này không thể kiểm thử một cách đáng tin cậy, vì nó luôn hỏi đồng hồ thật:

fn greeting() -> &'static str {
let hour = current_hour(); // đọc giờ hệ thống
match hour {
5..=11 => "Chào buổi sáng",
12..=17 => "Chào buổi chiều",
_ => "Chào buổi tối",
}
}

Hãy viết lại nó để nhận một trait Clock có một phương thức, fn hour(&self) -> u64. Viết một FixedClock luôn trả về cùng một giờ, và kiểm thử các điểm biên: 11 và 12, 17 và 18. Gợi ý: struct FixedClock(u64); là đủ. Ứng dụng thật sẽ truyền vào một SystemClock đọc giờ hệ thống.

Xem lời giải
trait Clock {
fn hour(&self) -> u64; // từ 0 đến 23
}
// Một chiếc đồng hồ lúc nào cũng báo cùng một giờ. Hoàn hảo cho test.
struct FixedClock(u64);
impl Clock for FixedClock {
fn hour(&self) -> u64 {
self.0
}
}
fn greeting(clock: &impl Clock) -> &'static str {
match clock.hour() {
5..=11 => "Chào buổi sáng",
12..=17 => "Chào buổi chiều",
_ => "Chào buổi tối",
}
}
fn main() {
for hour in [8, 12, 21] {
println!("{hour}:00 -> {}", greeting(&FixedClock(hour)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morning_ends_at_noon() {
assert_eq!(greeting(&FixedClock(11)), "Chào buổi sáng");
assert_eq!(greeting(&FixedClock(12)), "Chào buổi chiều");
}
#[test]
fn evening_starts_at_six() {
assert_eq!(greeting(&FixedClock(17)), "Chào buổi chiều");
assert_eq!(greeting(&FixedClock(18)), "Chào buổi tối");
}
#[test]
fn very_early_is_still_evening() {
assert_eq!(greeting(&FixedClock(3)), "Chào buổi tối");
}
}

Kết quả:

8:00 -> Chào buổi sáng
12:00 -> Chào buổi chiều
21:00 -> Chào buổi tối
▶ Tự chạy thử

greeting không còn tự quyết định lấy giờ từ đâu nữa, bên gọi nó quyết định. Chỉ một thay đổi đó đã khiến mọi giờ trong ngày đều kiểm thử được trong vài mili giây. Chọn Test trong menu của Playground và cả 3 test đều qua.

2. Tìm ra quy tắc, rồi để proptest kiểm tra

Phần tiêu đề “2. Tìm ra quy tắc, rồi để proptest kiểm tra”

Đây là một hàm biến một thẻ (tag) blog như " Web Dev " thành "web-dev":

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}

Viết hai property test với proptest: chuẩn hóa một thẻ đã được chuẩn hóa sẽ không thay đổi gì, và kết quả không bao giờ chứa khoảng trắng. Gợi ý: strategy ".*" sinh ra các chuỗi ngẫu nhiên (nó là một biểu thức chính quy). Với “không có khoảng trắng”, prop_assert!(!s.contains(char::is_whitespace)) dùng được.

Xem lời giải

Các thẻ ví dụ được giữ bằng tiếng Anh vì hàm đổi chữ hoa thành chữ thường, và kết quả bên dưới phải khớp chính xác.

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}
fn main() {
for tag in ["Rust", " Web Dev ", "GAME dev"] {
println!("{tag:?} -> {:?}", normalize_tag(tag));
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn normalizing_twice_changes_nothing(tag in ".*") {
let once = normalize_tag(&tag);
prop_assert_eq!(normalize_tag(&once), once);
}
#[test]
fn result_has_no_whitespace(tag in ".*") {
let normalized = normalize_tag(&tag);
prop_assert!(!normalized.contains(char::is_whitespace));
}
}
}

Kết quả:

"Rust" -> "rust"
" Web Dev " -> "web-dev"
"GAME dev" -> "game-dev"
▶ Tự chạy thử

Run chạy được trong Playground, nhưng Test thì không, vì Playground không có proptest. Trong dự án của riêng bạn, hãy thêm nó bằng cargo add --dev proptest rồi chạy cargo test:

running 2 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

Các chuỗi ngẫu nhiên có cả tab, xuống dòng và chữ cái không phải tiếng Anh (kể cả chữ tiếng Việt có dấu), những thứ mà có lẽ bạn sẽ không bao giờ gõ vào một test tự viết. “Làm hai lần cũng như làm một lần” là một thuộc tính đáng kiểm tra cho mọi hàm dọn dẹp dữ liệu.

  • Unit test nằm trong #[cfg(test)] mod tests cạnh code, integration test nằm trong tests/ (helper dùng chung trong tests/common/mod.rs), còn doc test nằm trong chú thích ///.
  • Dùng #[ignore] cho test chậm, chạy chúng bằng cargo test -- --ignored, lọc theo tên bằng cargo test some_name, và xem kết quả println! bằng -- --nocapture.
  • Để kiểm thử code giao tiếp với thế giới bên ngoài, hãy đặt phần phụ thuộc đó sau một trait và truyền một bản fake trong test. Dùng mockall khi việc tự viết fake trở nên nhàm chán.
  • Property test (proptest) kiểm tra một quy tắc với hàng trăm đầu vào ngẫu nhiên và thu nhỏ một thất bại về ví dụ đơn giản nhất. Snapshot test (insta) bắt các thay đổi vô tình trong những kết quả lớn.
  • Đo tốc độ bằng criterion và cargo bench, và bọc đầu vào trong std::hint::black_box. #[bench] cần nightly.
  • cargo-nextest chạy test nhanh hơn; cargo-llvm-cov và cargo-tarpaulin cho thấy test của bạn phủ được những dòng nào.

Kiểm tra nhanh

1. Nhiều file trong tests/ cần cùng một hàm trợ giúp. Nên đặt nó ở đâu?

2. cargo test -- --ignored làm gì?

3. Một thuộc tính proptest thất bại. Proptest báo cáo gì làm đầu vào thất bại nhỏ nhất?

4. Vì sao benchmark criterion bọc giá trị trong std::hint::black_box?

Điểm: 0 / 4