Danh sách bài viết

Bài 187: #[test] Attribute & #[cfg(test)] Module

Bài 187 của series Rust Cơ Bản — đi sâu hai building block cơ bản nhất của hệ test Rust: #[test] attribute đánh dấu function nào là test case, và #[cfg(test)] mod tests tạo module chỉ compile khi cargo test. Hiểu hai mảnh này là hiểu vì sao test code không phình binary release, vì sao unit test access được private function của module cha, và vì sao convention Rust luôn đặt use super::* ngay đầu mod tests. Bài phân tích signature fn name(), vị trí unit test vs integration test, lợi ích cfg(test), pattern use super::*, helper function không #[test], và common layout cuối file source mà thư viện Rust nào cũng dùng.

09/06/2026
10 phút đọc
2 lượt xem
1

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 test tự 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ặc Result ở 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 đầu mod 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ùng mod tests.

Bài 186 chỉ ra cách chạy test; bài này chỉ ra cách viết test idiomatic.

2

#[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_partial dù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).

3

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, trong mod tests.
  • Integration test: đặt trong folder tests/ ngang hàng src/. Mỗi file là một crate độc lập, import crate qua use 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.

4

#[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 build hoặc cargo 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.

5

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.toml có 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 trong mod tests#[cfg(test)] — chỉ chỗ này được use dev-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ỗ.

6

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.

7

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à đủ.

8

Multiple Test Function & Helper

Một mod tests có thể chứa nhiều #[test] function. Mỗi function 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_userassert_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ỹ.

9

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ự.

10

Tổng Kết

  • #[test] đánh dấu function là test case — signature fn 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)] bao mod tests — module chỉ compile khi cargo test, không lọt vào cargo 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.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Function fn helper(x: i32) -> i32 { x + 1 } nằm trong mod tests nhưng không#[test]. Cargo test có chạy nó không? Vai trò của nó là gì?
  2. 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?
  3. Integration test ở tests/foo.rs có gọi được private function fn parse(s: &str) của src/lib.rs không? Vì sao?
  4. Bỏ use super::* trong mod tests — code còn compile không? Phải sửa thế nào để test vẫn gọi được hàm add của module cha?
  5. cargo build --release có compile mod tests không? Binary release có chứa symbol của test function?
Đáp án
  1. Không, cargo test bỏ qua function không có #[test]. helper là 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 fixture new_user(), custom assertion assert_valid(&x), v.v.
  2. 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 tests nghĩa "cả module chỉ tồn tại khi test", còn #[cfg(test)] fn x() chỉ riêng x conditional. Module-level đảm bảo cả use dev-dep, helper, struct test bên trong đều bị strip khi build release.
  3. Không. tests/foo.rs là crate độc lập, import qua use my_lib::* nên chỉ thấy item pub. parse khô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.
  4. Không compile — add không có trong scope mod tests. Hai cách sửa: thêm lại use super::*; đầu module, hoặc prefix mỗi gọi bằng super::add(...). Convention chọn cách đầu vì gọn hơn nhiều test case.
  5. Không. cargo build --release không bật flag --test, cfg(test) sai → toàn bộ mod tests bị 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.
12

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.