Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu mô hình Testing Pyramid và lý do nhiều unit test, ít e2e.
- Viết được unit test trong
src/với#[cfg(test)] mod testsvà truy cập private item. - Viết được integration test trong
tests/chỉ qua public API, kèm helper dùng chung trongtests/common/mod.rs. - Tổ chức được e2e test gọi real service (Postgres, Redis) qua docker-compose.
- Dùng
proptestđể generate random input bắt edge case. - Setup GitHub Actions workflow chạy
cargo test --workspace.
Testing Pyramid Là Gì
Mike Cohn đề xuất mô hình Testing Pyramid trong cuốn Succeeding with Agile (2009). Ba tầng từ đáy lên đỉnh:
/\
/E2\ <-- ít, chậm, đắt, dễ flaky
/----\
/ INT \ <-- vừa phải, chạy qua public API
/--------\
/ UNIT \ <-- nhiều, nhanh (ms), cô lập, đáng tin
/------------\
Lý do hình kim tự tháp:
- Unit: rẻ (mỗi test ms), cô lập, dễ debug. Nên chiếm 70–80% số test.
- Integration: chạy qua nhiều module, kiểm tra tương tác. Chậm hơn (chục đến trăm ms), khoảng 15–25%.
- End-to-end: real service, real network, real DB. Rất chậm (giây đến phút), dễ flaky. Chỉ 5–10% cho happy path quan trọng.
Rust ánh xạ vào pyramid này rất tự nhiên qua cấu trúc cargo. Không cần framework ngoài — cargo test chạy được cả ba tầng, mỗi tầng đặt đúng chỗ.
Layer 1 — Unit Test Trong src/
Unit test ở Rust sống ngay trong file .rs của code production, trong module con đánh dấu #[cfg(test)]. Cargo chỉ compile module này khi chạy cargo test, không kéo vào binary release:
// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
fn double_internal(x: i32) -> i32 {
x * 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_positive() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn add_with_zero() {
assert_eq!(add(0, 7), 7);
}
#[test]
fn double_internal_works() {
// Truy cập được hàm private nhờ ở cùng module
assert_eq!(double_internal(4), 8);
}
}
Đặc điểm quan trọng: module tests ở cùng scope với code production nên test được cả private item — đặc quyền mà integration test không có. Đây là lý do unit test phải đặt trong src/, không tách ra.
Idiom: mỗi file production có 1 module tests ở cuối. Khi file lớn quá, tách module logic — module tests theo cùng.
Layer 2 — Integration Test Trong tests/
Integration test sống trong folder tests/ ở root crate, song song với src/. Mỗi file .rs trong tests/ là một crate riêng biệt, compile thành binary độc lập, chỉ thấy public API của lib — y hệt cách consumer bên ngoài dùng. Đây là điểm khác cốt lõi với unit test.
my-crate/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
├── common/
│ └── mod.rs # helper dùng chung
├── api_users.rs # crate riêng
└── api_orders.rs # crate riêng
// tests/api_users.rs
use my_crate::{User, create_user, find_user};
mod common;
#[test]
fn create_then_find() {
let db = common::setup_test_db();
let id = create_user(&db, "Mai").unwrap();
let user = find_user(&db, id).unwrap();
assert_eq!(user.name, "Mai");
}
Helper dùng chung phải đặt trong subfolder tests/common/mod.rs (không phải tests/common.rs) — nếu đặt thẳng file trực tiếp trong tests/, cargo sẽ coi nó là test crate riêng và báo "không có test nào".
// tests/common/mod.rs
use my_crate::Database;
pub fn setup_test_db() -> Database {
Database::in_memory()
}
Vì chỉ thấy public API, integration test giúp review thiết kế API: nếu test phải vào quá nhiều internal trick, public surface đang thiếu hoặc thiết kế chưa ổn. Đây là feedback rất giá trị, gần như free.
Layer 3 — End-To-End Với Real Service
E2E test boot toàn bộ binary, gọi qua HTTP/gRPC, tương tác với real DB / Redis / queue. Cargo không có folder convention cho e2e — thường tách thành workspace member riêng (vd e2e/) hoặc dùng tests/ với feature flag --features e2e để skip khi chạy nhanh.
Dependency bên ngoài chạy qua docker-compose, kích hoạt trước khi cargo test:
# docker-compose.test.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports:
- "5433:5432"
redis:
image: redis:7
ports:
- "6380:6379"
// e2e/tests/http_user_flow.rs
use reqwest::Client;
#[tokio::test]
async fn signup_login_logout() {
let base = "http://localhost:8080";
let client = Client::new();
let r = client.post(format!("{base}/signup"))
.json(&serde_json::json!({ "email": "[email protected]", "pwd": "x" }))
.send().await.unwrap();
assert_eq!(r.status(), 201);
let r = client.post(format!("{base}/login"))
.json(&serde_json::json!({ "email": "[email protected]", "pwd": "x" }))
.send().await.unwrap();
assert!(r.status().is_success());
}
Quy tắc tỉ lệ: chỉ viết e2e cho luồng critical (đăng ký, thanh toán, đặt hàng). Mọi edge case nên cover ở tầng unit/integration vì rẻ và ổn định hơn nhiều.
Property-Based Test Bằng proptest
Unit test thường viết với input cố định (5, 10, "hello"). Property-based test khác: bạn khai báo tính chất mà function phải thỏa với mọi input, framework tự sinh hàng trăm input random để check. Hai crate phổ biến: proptest (mạnh hơn, shrinking tốt) và quickcheck (cú pháp gọn hơn).
// Cargo.toml
// [dev-dependencies]
// proptest = "1"
use proptest::prelude::*;
fn reverse<T: Clone>(v: &[T]) -> Vec<T> {
v.iter().rev().cloned().collect()
}
proptest! {
#[test]
fn reverse_twice_is_identity(v in prop::collection::vec(any::<i32>(), 0..100)) {
let r = reverse(&reverse(&v));
prop_assert_eq!(r, v);
}
#[test]
fn reverse_keeps_length(v in prop::collection::vec(any::<i32>(), 0..100)) {
prop_assert_eq!(reverse(&v).len(), v.len());
}
}
prop::collection::vec(any::<i32>(), 0..100) là một strategy — mô tả cách sinh dữ liệu (vec i32, độ dài 0..100). Khi test fail, proptest tự shrink input về dạng tối giản nhất vẫn fail — giúp debug nhanh hơn so với "test fail với vec 87 phần tử".
Property-based hợp với: parser, codec, math, data structure invariant (push xong pop = ban đầu). Không thay thế unit test — bổ sung cho fixed-input test.
Doc Test — Bonus Layer Miễn Phí
Đã học chi tiết ở Bài 307: Documentation Style: code block trong /// doc comment được rustdoc tự chạy như test. Mỗi block là một test case nhỏ — chứng minh ví dụ trong docs luôn hoạt động, không bị lỗi thời khi API đổi.
/// Cộng hai số nguyên.
///
/// # Examples
///
/// ```
/// use my_crate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
Doc test nằm ngoài 3 tầng Testing Pyramid truyền thống — nên coi là "tầng số 0" miễn phí: vừa làm tài liệu, vừa đảm bảo tài liệu không bị stale. cargo test chạy luôn cùng unit + integration.
cargo test --workspace & CI GitHub Actions
Trong workspace nhiều crate, cargo test --workspace chạy tất cả test trong tất cả member (unit + integration + doc test) bằng một lệnh duy nhất. Thêm --all-features nếu có feature gate, --release khi muốn benchmark hành vi build optimized.
Workflow GitHub Actions điển hình, kích hoạt service Postgres cho e2e:
# .github/workflows/test.yml (đặt là yml, ví dụ minh hoạ syntax)
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports: [ '5433:5432' ]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace --all-features
Mẹo CI: cache target/ qua Swatinem/rust-cache giảm build từ 5+ phút xuống dưới 1 phút cho run incremental. Tách job e2e ra workflow riêng chạy nightly nếu nó quá chậm cho mỗi PR — giữ feedback loop PR nhanh ở mức unit/integration.
Tổng Kết
- Testing Pyramid: nhiều unit (đáy) → vừa integration → ít e2e (đỉnh). Tỉ lệ kinh điển 70/20/10.
- Unit test sống trong
src/với#[cfg(test)] mod tests— truy cập được private item, cùng module với production code. - Integration test trong
tests/— mỗi file là crate riêng, chỉ thấy public API. Helper chung phải đặttests/common/mod.rs. - E2E test gọi real service qua HTTP/gRPC + docker-compose; chỉ cover happy path critical vì chậm và dễ flaky.
- Property-based test (proptest, quickcheck) sinh random input theo strategy, tự shrink khi fail — bắt edge case mà fixed input bỏ sót.
- Doc test là bonus layer miễn phí: ví dụ trong
///tự được rustdoc chạy như test. cargo test --workspacechạy hết một lệnh; CI GitHub Actions kèmSwatinem/rust-cachegiữ feedback loop nhanh.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Vì sao unit test phải đặt trong
src/mà không tách ratests/? Trả lời theo khả năng truy cập item. - Bạn tạo file
tests/helpers.rschứa hàm dùng chung, không có hàm#[test]nào.cargo testbáo "0 tests run" cho file này. Sửa thế nào? - Khi nào nên viết e2e thay vì integration test? Cho 1 ví dụ luồng nên có e2e và 1 luồng nên dừng ở integration.
- Khác biệt cốt lõi giữa unit test với input cố định (
assert_eq!(add(2,3), 5)) và property-based test (prop_assert!(...)) là gì? - Trong GitHub Actions, nếu mọi PR đều phải chờ 15 phút e2e thì developer bị block. Đề xuất chiến lược tách workflow.
- Function
parse_csvnhận&strtrảResult<Vec<Row>, ParseError>. Outline test cho cả 4 tầng (unit / integration / e2e / property-based) — mỗi tầng tập trung kiểm tra gì.
Đáp án
- Vì module
#[cfg(test)] mod teststrongsrc/ở cùng scope với code production, truy cập được private function/struct. Test trongtests/là crate riêng, chỉ thấy public API — không test được internal helper. Hai vai trò khác nhau cùng cộng tác. - Đổi từ
tests/helpers.rsthànhtests/common/mod.rs(subfolder + mod.rs). Cargo chỉ coi file.rstrực tiếp trongtests/là test crate; file trong subfolder chỉ được include nếu test crate khác khai báomod common;. - E2E khi cần chứng minh nhiều component (web, DB, cache, queue) phối hợp đúng — ví dụ "user signup → email sent → activation link works". Integration là đủ khi chỉ kiểm tra 1 module gọi public API của module khác — ví dụ "service layer gọi repo trả kết quả đúng" có thể mock DB.
- Unit test fixed-input chứng minh function đúng với vài case cụ thể; property-based chứng minh function thỏa tính chất phổ quát với hàng trăm random input. Property-based hay bắt được edge case (empty, overflow, unicode) mà người viết test không nghĩ tới; khi fail còn auto shrink input về dạng tối giản giúp debug.
- Tách 2 workflow: (a)
pr.ymltrigger trên PR, chỉ chạy fmt + clippy + unit + integration + doc test (~2 phút). (b)nightly-e2e.ymltrigger schedule mỗi đêm hoặc tage2etrên PR, chạy full e2e với docker-compose. Block merge khi nightly fail nhưng không block PR thông thường. - Unit: test private helper
split_field,unescape_quote; chạy nhanh, cover edge syntax. Integration: gọiparse_csvqua public API với input thực tế từ file fixture. E2E: chạy CLI binary./mycli parse data.csv, kiểm tra stdout/exit code. Property-based: sinh CSV hợp lệ ngẫu nhiên, assertserialize(parse(s)) == s(round-trip property).
Bài Tiếp Theo
Bài 309: Performance — Alloc, Clone Awareness — chuyển từ correctness sang performance: tránh clone không cần thiết, prefer &str trên String trong API, pre-allocate qua Vec::with_capacity, và benchmark bằng criterion crate.
