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ệ.
Sắp xếp các test
Phần tiêu đề “Sắp xếp các test”Đâ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ầnpub. - 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ề
Resultcho 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 testsẽ 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:
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 }, ]}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 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest 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 testtest 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 testtest 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.00sChọn test để chạy
Phần tiêu đề “Chọn test để chạy”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.
cargo test discount # chỉ các test có tên chứa "discount"cargo test --test checkout # chỉ file tests/checkout.rscargo test --lib # chỉ các unit test trong src/cargo test --doc # chỉ các doc testcargo test -- --ignored # chỉ các test có #[ignore]cargo test -- --include-ignored # tất cả, kể cả các test bị bỏ quacargo test -- --nocapture # hiện kết quả println! của các test quacargo test -- --test-threads=1 # chạy từng test mộtBộ 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 testtest 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 testtest discounted_breakfast_total ... okTest 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 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sMặ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 -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... okTest double: làm giả bằng một trait
Phần tiêu đề “Test double: làm giả bằng một trait”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 { }}
fn main() { let mut mailer = ConsoleMailer; 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();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}Kết quả:
email tới [email protected]: Đơn hàng #7 đã được xác nhậnemail tới [email protected]: Đơn hàng #8 đã được xác nhậnemail tới [email protected]: Đơn hàng lớn #8Ví 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 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_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.
Mock với mockall
Phần tiêu đề “Mock với mockall”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):
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.
#[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() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
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 1Thô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:
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 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sHã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 = 100000Hã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.
Snapshot test với insta
Phần tiêu đề “Snapshot test với insta”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.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00Về 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.
Benchmark với criterion
Phần tiêu đề “Benchmark với criterion”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 = falseBenchmark 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?
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 severeCá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 nightlyextern 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.
Độ phủ test và CI
Phần tiêu đề “Độ phủ test và CI”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.
cargo install cargo-nextest --lockedcargo 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:
cargo install cargo-llvm-covcargo llvm-cov # bảng tóm tắt trong terminalcargo llvm-cov --html # báo cáo duyệt được trong target/llvm-cov/htmlcargo-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:
cargo install cargo-tarpaulincargo tarpaulin --out HtmlHã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:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testLỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”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 testsCá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.
Luyện tập
Phần tiêu đề “Luyện tập”1. Một lời chào kiểm thử được
Phần tiêu đề “1. Một lời chào kiểm thử được”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áng12:00 -> Chào buổi chiều21:00 -> Chào buổi tốigreeting 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"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 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sCá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.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Unit test nằm trong
#[cfg(test)] mod testscạnh code, integration test nằm trongtests/(helper dùng chung trongtests/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ằngcargo test -- --ignored, lọc theo tên bằngcargo 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 trongstd::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
Phần tiêu đề “Kiểm tra nhanh”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
Đọc thêm
Phần tiêu đề “Đọc thêm”- Writing Automated Tests — The Rust Book (tiếng Anh): chương chính thức về cách viết, chạy và sắp xếp unit test và integration test.
- Testing — Rust by Example (tiếng Anh): các ví dụ ngắn về unit test, doc test, integration test và dev-dependencies.
- The proptest book (tiếng Anh): strategy, shrinking, cấu hình và so sánh proptest với quickcheck.
- mockall documentation (tiếng Anh):
#[automock], expectation, khớp đối số, giá trị trả về và mock struct. - Criterion.rs user guide (tiếng Anh): bắt đầu, đọc kết quả, so sánh các hàm và báo cáo HTML.