Danh sách bài viết

Bài 308: Testing Pyramid Trong Rust

Bài 308 của series Rust Cơ Bản — Testing Pyramid là mô hình kinh điển của Mike Cohn: nhiều unit test ở đáy, vừa phải integration ở giữa, rất ít end-to-end ở đỉnh. Rust ánh xạ rất sạch vào mô hình này nhờ cargo: unit test sống trong src/ với #[cfg(test)] module, integration test sống trong tests/ folder (mỗi file là crate riêng, chỉ thấy public API), e2e là binary tách rời chạy với real service qua docker-compose, plus property-based test (proptest, quickcheck) để bắt edge case mà fixed input bỏ sót. Doc test là bonus layer free. Bài này đi qua từng layer kèm ví dụ code, layout thư mục, và CI workflow GitHub Actions để chạy hết bằng cargo test --workspace.

11/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 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 tests và truy cập private item.
  • Viết được integration test trong tests/ chỉ qua public API, kèm helper dùng chung trong tests/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.
2

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

3

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 testscù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.

4

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.

5

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.

6

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.

7

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.

8

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.

9

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 đặt tests/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 --workspace chạy hết một lệnh; CI GitHub Actions kèm Swatinem/rust-cache giữ feedback loop nhanh.
10

Bài Tập Củng Cố

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

  1. Vì sao unit test phải đặt trong src/ mà không tách ra tests/? Trả lời theo khả năng truy cập item.
  2. Bạn tạo file tests/helpers.rs chứa hàm dùng chung, không có hàm #[test] nào. cargo test báo "0 tests run" cho file này. Sửa thế nào?
  3. 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.
  4. 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ì?
  5. 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.
  6. Function parse_csv nhận &str trả 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
  1. Vì module #[cfg(test)] mod tests trong src/cùng scope với code production, truy cập được private function/struct. Test trong tests/ 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.
  2. Đổi từ tests/helpers.rs thành tests/common/mod.rs (subfolder + mod.rs). Cargo chỉ coi file .rs trực tiếp trong tests/ là test crate; file trong subfolder chỉ được include nếu test crate khác khai báo mod common;.
  3. 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.
  4. 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.
  5. Tách 2 workflow: (a) pr.yml trigger trên PR, chỉ chạy fmt + clippy + unit + integration + doc test (~2 phút). (b) nightly-e2e.yml trigger schedule mỗi đêm hoặc tag e2e trê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.
  6. Unit: test private helper split_field, unescape_quote; chạy nhanh, cover edge syntax. Integration: gọi parse_csv qua 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, assert serialize(parse(s)) == s (round-trip property).
11

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.