Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu
#[test]attribute biến function bình thường thành test case màcargo testtự phát hiện và chạy. - Biết signature bắt buộc của test function:
fn name()— không tham số, return()(hoặcResultở bài 189). - Phân biệt vị trí unit test (cùng file source) và integration test (folder
tests/) — phạm vi bài này tập trung unit test. - Hiểu
#[cfg(test)]là conditional compilation — module gắn nó chỉ tồn tại khi build cho test, không vào binary release. - Biết vì sao convention Rust dùng
use super::*ngay đầumod testsđể import mọi item của parent module. - Hiểu unit test trong cùng module access được private function — đặc quyền integration test không có.
- Phân biệt helper function (không gắn
#[test]) và test function trong cùngmod tests.
Bài 186 chỉ ra cách chạy test; bài này chỉ ra cách viết test idiomatic.
#[test] Attribute
#[test] là attribute built-in của test harness mặc định (libtest). Gắn lên function nào, function đó được đánh dấu là test case — chạy bởi cargo test mà không cần đăng ký thủ công ở đâu hết.
// File: src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn test_add_two_positive() {
let result = add(2, 3);
assert_eq!(result, 5);
}
Chạy cargo test, libtest scan binary tìm mọi function có #[test], gom thành danh sách, chạy từng cái, in pass/fail. Bạn không phải viết main() chạy test, không khai báo danh sách — attribute lo hết.
Signature bắt buộc:
- Không tham số:
fn test_x(). - Return
()ngầm — bỏ trống là OK; hoặc trảResult<(), E>(bài 189). - Visibility tự do:
fn,pub fn— libtest không quan tâm. - Tên bất kỳ; chỉ lưu ý
cargo test name_partialdùng filter theo tên.
Function không có #[test] trong cùng file không bị cargo test chạy — chúng là code production bình thường (hoặc helper, xem bước 8).
Vị Trí Test Function
Rust chia test thành hai loại theo vị trí, mỗi loại có phạm vi access khác nhau:
- Unit test: đặt trong file source (
src/lib.rs,src/foo.rs) cùng module với code đang test. Access được mọi item của module — kể cả private function, private struct field. Convention đặt ở cuối file source, trongmod tests. - Integration test: đặt trong folder
tests/ngang hàngsrc/. Mỗi file là một crate độc lập, import crate quause my_lib::*. Chỉ access public API — đúng như user thật. Sẽ tìm hiểu chi tiết ở Bài 192.
my_crate/
├── Cargo.toml
├── src/
│ ├── lib.rs // unit test ở cuối file
│ └── parser.rs // unit test cho module parser
└── tests/ // integration test, mỗi file = 1 crate
├── api.rs
└── workflow.rs
Bài này chỉ bàn unit test — đặt cùng file source là cách phổ biến nhất, đủ phục vụ phần lớn nhu cầu test logic nội bộ. Convention cuối file không phải bắt buộc kỹ thuật; compile được khắp nơi. Nhưng đặt cuối giúp người đọc thấy code trước, test sau — luồng đọc tự nhiên.
#[cfg(test)] Module
#[cfg(...)] là attribute conditional compilation — Rust chỉ compile item nếu điều kiện đúng. cfg(test) đúng khi cargo build với target --test (nghĩa là cargo test), sai trong cargo build, cargo build --release, cargo run.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
Chuyện gì xảy ra dưới hood:
cargo buildhoặccargo build --release: compiler thấy#[cfg(test)]sai — toàn bộmod tests { ... }bị bỏ qua. Không có function nào trong module được compile, không gì vào binary.cargo test: compiler bật flag--test,cfg(test)đúng — module được compile, libtest scan và chạy#[test]bên trong.
Gắn #[cfg(test)] lên module (không lên từng function) là pattern phổ biến nhất — gom tất cả code chỉ-cho-test vào một chỗ, gắn một attribute là xong. Có thể gắn lên function lẻ (#[cfg(test)] fn helper() { ... }) nhưng hiếm khi cần.
Lợi Ích cfg(test)
Tại sao Rust chọn pattern này thay vì luôn compile mọi function rồi cargo test chỉ "filter"? Hai lý do thực tế quan trọng:
- Binary release không phình: test code thường có nhiều fixture, mock data, helper string lớn. Nếu compile chung vào
cargo build --release, binary giao cho user sẽ chứa hàng loạt symbol không bao giờ chạy.#[cfg(test)]đảm bảo chúng không xuất hiện trong artifact production. - Dev-dependencies tách biệt: trong
Cargo.tomlcó section[dev-dependencies]chỉ kéo về khi build test. Crate nhưpretty_assertions,tokio-test,mockallđược khai báo ở đây. Test code nằm trongmod testscó#[cfg(test)]— chỉ chỗ này đượcusedev-dep mà không ảnh hưởng build release.
# Cargo.toml
[dependencies]
serde = "1.0"
[dev-dependencies]
pretty_assertions = "1.4" # chỉ vào binary khi cargo test
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq; // OK — chỉ compile khi test
#[test]
fn test_x() {
assert_eq!(add(2, 3), 5);
}
}
Nếu use pretty_assertions::assert_eq đặt ngoài #[cfg(test)], cargo build báo lỗi vì dev-dep không có sẵn ở build production. Gắn module #[cfg(test)] giải quyết gọn vấn đề này — không cần khai báo dep ở hai chỗ.
use super::* Trong Test Module
mod tests là module con của module hiện hành. Theo quy tắc scoping của Rust, module con không tự động thấy item của module cha — phải import. use super::* là cú pháp wildcard import: kéo mọi item từ module cha (super) vào scope tests.
pub fn add(a: i32, b: i32) -> i32 { a + b }
fn double(x: i32) -> i32 { x * 2 } // private — không pub
#[cfg(test)]
mod tests {
use super::*; // kéo add, double, mọi item parent
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_double_private() {
assert_eq!(double(4), 8); // OK — vì cùng cây module
}
}
Trường hợp không có use super::*:
#[cfg(test)]
mod tests {
#[test]
fn test_add() {
assert_eq!(super::add(2, 3), 5); // phải prefix super:: mỗi lần
}
}
Compile được nhưng dài dòng. Convention use super::* chấp nhận wildcard ở đây vì đây là test module — không leak ra public API, không ảnh hưởng downstream. Wildcard import bình thường bị Clippy cảnh báo, nhưng được khuyên trong test module vì lợi ích tiện lợi cao hơn rủi ro name clash.
Private Function Test
Đặc quyền lớn nhất của unit test (cùng module) so với integration test (folder tests/): truy cập private function. Rust visibility cho phép module con thấy mọi thứ của module cha — public hay private — vì cùng cây sở hữu.
pub fn process(input: &str) -> String {
let cleaned = normalize(input); // gọi private helper
let parts = split_words(&cleaned); // gọi private helper
parts.join("-")
}
fn normalize(s: &str) -> String { // private
s.trim().to_lowercase()
}
fn split_words(s: &str) -> Vec<String> { // private
s.split_whitespace().map(String::from).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_trims_and_lowers() {
assert_eq!(normalize(" HELLO "), "hello"); // test private trực tiếp
}
#[test]
fn test_split_words_basic() {
let v = split_words("a b c");
assert_eq!(v, vec!["a", "b", "c"]);
}
#[test]
fn test_process_end_to_end() {
assert_eq!(process(" Hello World "), "hello-world");
}
}
Test cả end-to-end (process) và từng private helper riêng. Khi process fail, test private giúp khoanh vùng nhanh xem normalize hay split_words sai. Integration test ở tests/ không làm được — chúng chỉ thấy process public.
Lưu ý ngược: không nên test mọi private function một cách máy móc. Test private chỉ khi nó phức tạp đủ để cần test riêng. Phần lớn private helper được kiểm gián tiếp qua public API là đủ.
Multiple Test Function & Helper
Một mod tests có thể chứa nhiều #[test] function. Mỗi function có attribute là một test case độc lập, chạy riêng, fail riêng. Function không có attribute coi như function thường — không bị cargo test chạy, dùng làm helper cho các test khác.
#[cfg(test)]
mod tests {
use super::*;
// Helper — không #[test] — không phải test case
fn fixture_user(name: &str) -> User {
User { name: name.to_string(), age: 30 }
}
fn assert_valid(u: &User) {
assert!(!u.name.is_empty());
assert!(u.age > 0);
}
#[test]
fn test_user_created_via_fixture() {
let u = fixture_user("alice");
assert_valid(&u);
assert_eq!(u.name, "alice");
}
#[test]
fn test_user_with_different_name() {
let u = fixture_user("bob");
assert_valid(&u);
assert_eq!(u.name, "bob");
}
}
Cargo test thấy 2 test (test_user_created_via_fixture, test_user_with_different_name), không thấy fixture_user và assert_valid — chúng chỉ tồn tại để các test gọi lại. Quên gắn #[test] lên function ý định là test → cargo test bỏ qua không cảnh báo, dễ tạo bug "test thầm lặng". Reviewer nên check kỹ.
Common Layout Mẫu
Tổng hợp mọi mảnh đã học vào một layout mẫu — đây là cấu trúc bạn sẽ thấy ở 90% file source Rust open-source:
// File: src/calculator.rs
//! Module Calculator — phép tính cơ bản với history.
use std::fmt;
pub struct Calculator {
history: Vec<String>,
}
impl Calculator {
pub fn new() -> Self {
Calculator { history: Vec::new() }
}
pub fn add(&mut self, a: i32, b: i32) -> i32 {
let result = a + b;
self.record(format!("{} + {} = {}", a, b, result));
result
}
pub fn history(&self) -> &[String] {
&self.history
}
// Private helper
fn record(&mut self, entry: String) {
self.history.push(entry);
}
}
// =================================================================
// Test module — cuối file, gắn cfg(test), import super::*
// =================================================================
#[cfg(test)]
mod tests {
use super::*;
fn new_calc() -> Calculator {
Calculator::new()
}
#[test]
fn test_add_returns_correct_value() {
let mut c = new_calc();
assert_eq!(c.add(2, 3), 5);
}
#[test]
fn test_history_records_operation() {
let mut c = new_calc();
c.add(2, 3);
assert_eq!(c.history().len(), 1);
assert_eq!(c.history()[0], "2 + 3 = 5");
}
#[test]
fn test_private_record_directly() {
let mut c = new_calc();
c.record("manual".to_string()); // test private method
assert_eq!(c.history()[0], "manual");
}
}
Layout này thoả mãn mọi quy tắc đã thảo luận: code production ở trên, comment phân cách rõ ràng, #[cfg(test)] bao module, use super::* ngay đầu, helper new_calc không gắn attribute, ba test case có #[test]. Mở bất kỳ crate stdlib hay tokio nào, bạn cũng thấy cấu trúc tương tự.
Tổng Kết
#[test]đánh dấu function là test case — signaturefn name(), libtest tự scan và chạy.- Vị trí test: cùng file source (unit test) hoặc folder
tests/(integration). Bài này tập trung unit test. #[cfg(test)]baomod tests— module chỉ compile khicargo test, không lọt vàocargo build --release.- Lợi ích cfg(test): binary release sạch không chứa code test; dev-dependencies tách biệt khỏi runtime deps.
use super::*đầu mod tests — kéo mọi item parent vào scope, wildcard chấp nhận được vì local trong test.- Unit test access được private function nhờ cùng cây module — đặc quyền integration test không có.
- Trong mod tests, function không gắn
#[test]là helper — không bị chạy, dùng làm fixture/assertion riêng. - Common layout: code → comment phân cách →
#[cfg(test)] mod tests { use super::*; #[test] fn ... }ở cuối file source.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Function
fn helper(x: i32) -> i32 { x + 1 }nằm trongmod testsnhưng không có#[test]. Cargo test có chạy nó không? Vai trò của nó là gì? - Vì sao
#[cfg(test)]gắn lên module thay vì gắn lên từng#[test]function? Có khác biệt ngữ nghĩa không? - Integration test ở
tests/foo.rscó gọi được private functionfn parse(s: &str)củasrc/lib.rskhông? Vì sao? - Bỏ
use super::*trongmod tests— code còn compile không? Phải sửa thế nào để test vẫn gọi được hàmaddcủa module cha? cargo build --releasecó compilemod testskhông? Binary release có chứa symbol của test function?
Đáp án
- Không, cargo test bỏ qua function không có
#[test].helperlà function thường — các test case khác trong cùng module có thể gọi nó như fixture/utility, ví dụlet v = helper(5); assert_eq!(v, 6);. Pattern phổ biến cho fixturenew_user(), custom assertionassert_valid(&x), v.v. - Gắn lên module là gom mọi item test vào một boundary, một attribute là xong — gọn và rõ. Ngữ nghĩa khác:
#[cfg(test)] mod testsnghĩa "cả module chỉ tồn tại khi test", còn#[cfg(test)] fn x()chỉ riêngxconditional. Module-level đảm bảo cảusedev-dep, helper, struct test bên trong đều bị strip khi build release. - Không.
tests/foo.rslà crate độc lập, import quause my_lib::*nên chỉ thấy itempub.parsekhông cópub→ invisible với integration test. Đây là lý do unit test thích hợp cho test private logic, integration test thích hợp cho test API public từ góc nhìn user. - Không compile —
addkhông có trong scopemod tests. Hai cách sửa: thêm lạiuse super::*;đầu module, hoặc prefix mỗi gọi bằngsuper::add(...). Convention chọn cách đầu vì gọn hơn nhiều test case. - Không.
cargo build --releasekhông bật flag--test,cfg(test)sai → toàn bộmod testsbị skip ở giai đoạn parse-AST. Binary release sạch tinh, không có symbol nào của test function. Đây là điểm khác biệt so với một số ngôn ngữ luôn compile test rồi chỉ chạy có chọn lọc.
Bài Tiếp Theo
Bài 188: assert!, assert_eq!, assert_ne! — bộ ba macro chính để kiểm tra điều kiện trong test. assert! nhận biểu thức bool; assert_eq!, assert_ne! in cả giá trị trái-phải khi fail nhờ trait Debug. Bài tiếp cũng giới thiệu cú pháp custom message khi assertion fail, giúp test output đọc được tự nhiên.
