Danh sách bài viết

Bài 190: #[should_panic] — Test Cho Panic Path

Bài 190 của series Rust Cơ Bản — attribute #[should_panic] là một dạng inverted assertion: thay vì kỳ vọng code chạy xong bình thường, kỳ vọng nó phải panic. Test pass khi và chỉ khi body panic; fail nếu chạy hết function mà không panic. Bài này phân tích kỹ tham số expected = "msg" để verify panic message chứa substring chỉ định, lý do should_panic không tương thích với signature fn() -> Result, anti-pattern thường gặp khi lạm dụng attribute cho mọi test bug thay vì dùng Result, và danh sách use case thực tế nơi should_panic là lựa chọn đúng: validation trong constructor public, assertion macro chạy fail, integer overflow ở debug profile, slice cắt out-of-bounds, unwrap() trên None / Err, chia cho 0. Sau bài này bạn biết khi nào panic là một phần của contract API và phải có test đảm bảo nó panic đúng cách — chứ không phải mọi panic đều là bug cần Result hoá.

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

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu attribute #[should_panic] đảo ngược điều kiện pass/fail của một test: PASS khi body panic, FAIL nếu chạy xong bình thường.
  • Biết cú pháp tối giản và cú pháp đầy đủ với tham số expected = "msg" để bắt buộc panic message phải chứa substring chỉ định.
  • Biết lý do #[should_panic] không kết hợp được với signature fn() -> Result đã học ở Bài 189.
  • Nhận diện anti-pattern dùng should_panic cho mọi test bug và hiểu khi nào Result là lựa chọn đúng hơn.
  • Có danh sách use case thực tế: validation trong constructor public, integer overflow debug, slice out-of-bounds, unwrap() trên None, divide by zero, assertion fail trong helper.

Bài này là bước cuối cùng trong chuỗi cơ chế test cá nhân trước khi sang phần filter / ignore / threads. Tham khảo Bài 189: Tests Trả Về Result — ? Trong Test để xem signature fn() -> Result đối lập với attribute này.

2

#[should_panic] Là Gì

#[should_panic] là một attribute đặt cùng tầng với #[test]. Khi test framework chạy function được đánh dấu, nó kỳ vọng body phải panic — bằng panic!() tường minh, assert! fail, unwrap() trên None, hoặc một panic ngầm bất kỳ trong stdlib (xem Bài 140).

Quy tắc đánh giá ngược với #[test] thông thường:

  • Body panic → test PASS.
  • Body chạy hết, không panic → test FAIL với message note: test did not panic as expected.

Cơ chế hiện thực dựa trên std::panic::catch_unwind đã giới thiệu ở Bài 140 — test runner gói body vào closure, bắt panic (nếu có) thay vì để nó propagate. Vì thế #[should_panic] chỉ hoạt động khi profile compile dùng panic = "unwind" (mặc định dev/test). Nếu cấu hình panic = "abort", test sẽ không thể bắt panic, attribute mất tác dụng và toàn bộ binary test crash khi panic xảy ra.

Đây là điểm khác biệt quan trọng so với assertion thông thường: bạn không khẳng định "code đúng → không panic", mà khẳng định ngược "với input invalid này, code phải panic — nếu im lặng chạy qua nghĩa là invariant bị vỡ".

3

Cú Pháp Cơ Bản

Đặt #[should_panic] ngay dưới #[test]. Function signature giữ nguyên fn() -> () — không trả về gì:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("division by zero");
    }
    a / b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn divide_by_zero_panics() {
        divide(10, 0); // phải panic
    }

    #[test]
    fn divide_normal_works() {
        assert_eq!(divide(10, 2), 5);
    }
}

Chạy cargo test: cả hai test đều PASS. Test thứ nhất pass vì divide(10, 0) panic đúng kỳ vọng; test thứ hai pass vì không có panic — đó là test bình thường.

Để chứng minh attribute hoạt động, thử sửa divide trả 0 thay vì panic khi b == 0: test divide_by_zero_panics sẽ FAIL với message "test did not panic as expected". Đây là tín hiệu cho thấy invariant của function (b ≠ 0) đã bị vi phạm âm thầm.

4

expected = "msg" Verify Message

Vấn đề của #[should_panic] tối giản: mọi panic đều làm test pass — kể cả panic ở chỗ không mong muốn. Ví dụ test có nhiều dòng setup, một dòng setup panic do bug khác, test vẫn pass và bạn nghĩ logic chính đã được verify. Tham số expected giải quyết điều này:

#[test]
#[should_panic(expected = "division by zero")]
fn divide_by_zero_with_message() {
    divide(10, 0);
}

#[test]
#[should_panic(expected = "must be positive")]
fn new_age_with_negative_fails() {
    Person::new("Alice", -5); // panic nội bộ: "age must be positive, got -5"
}

Cơ chế: test runner so sánh panic message thực tế (lấy từ payload của panic) với chuỗi expected bằng .contains(...). Tức là expected chỉ cần là substring — không phải string đầy đủ. Nhờ vậy bạn vẫn match được khi panic message có thêm dữ liệu động (giá trị, line number).

Nếu panic xảy ra nhưng message KHÔNG chứa substring kỳ vọng, test FAIL với chẩn đoán rõ ràng:

note: panic did not contain expected string
      panic message: `"index out of bounds: the len is 3 but the index is 5"`,
 expected substring: `"division by zero"`

Đây là biện pháp phòng vệ cực kỳ giá trị: nó buộc panic phải đến từ đúng chỗ. Khuyến nghị mặc định dùng dạng expected = "..." cho mọi test should_panic ở public API.

5

Vì Sao Hữu Ích — Test Invariant

Trong Rust, một số function cố ý panic khi input vi phạm contract — không nuốt lỗi, không trả Result, mà fail nhanh để bug nổ ngay tại điểm sai. Đây là pattern phổ biến của constructor cho type có invariant chặt:

pub struct NonZeroAge(u32);

impl NonZeroAge {
    /// Contract: age phải > 0 và <= 150. Vi phạm = bug ở caller.
    pub fn new(age: u32) -> Self {
        assert!(age > 0, "age must be positive, got 0");
        assert!(age <= 150, "age must be <= 150, got {}", age);
        Self(age)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "must be positive")]
    fn new_rejects_zero() {
        NonZeroAge::new(0);
    }

    #[test]
    #[should_panic(expected = "must be <= 150")]
    fn new_rejects_too_large() {
        NonZeroAge::new(999);
    }
}

Test phía trên đảm bảo invariant 0 < age ≤ 150 được enforce mãi mãi. Nếu một ngày developer khác refactor và vô tình bỏ assert! đầu tiên, test new_rejects_zero sẽ fail với "test did not panic as expected" — bug được phát hiện trước khi merge.

Cùng cách dùng cho setter (trạng thái không hợp lệ), index access có ràng buộc miền giá trị, parser có quy tắc cứng, hoặc bất kỳ hàm public nào quyết định panic là contract chứ không phải lỗi recoverable.

6

KHÔNG Mix Với Result Return

Bài 189 đã giới thiệu signature fn() -> Result<(), E> cho test — pattern dùng ? propagate lỗi I/O. Một câu hỏi tự nhiên: kết hợp được không với #[should_panic]?

Không. Hai cơ chế xung đột về mặt ý nghĩa:

  • Test trả Result: fail = Err(_), pass = Ok(()). Không liên quan panic.
  • #[should_panic]: fail = không panic, pass = panic. Không liên quan giá trị trả về.

Compiler sẽ báo lỗi nếu bạn cố gắn cả hai:

error: functions using `#[should_panic]` must return `()`

Quy tắc: khi cần test một panic path, viết một test riêng fn() -> () với #[should_panic(expected = "...")]. Khi cần propagate lỗi I/O, viết test khác fn() -> Result<(), MyError>. Hai loại test sống song song, mỗi loại làm một việc.

Trường hợp muốn assert một Result trả Err với variant cụ thể, đừng dùng should_panic — dùng assert!(matches!(result, Err(MyError::Foo))) trong test thường. Tham khảo lại Bài 189 cho cách viết.

7

Anti-Pattern Overuse

Sai lầm phổ biến của người mới: thấy code panic ở đâu thì viết #[should_panic] ở đó — biến mọi error test thành panic test. Đây là anti-pattern.

// ANTI-PATTERN: parse_int trả Result, nhưng tác giả unwrap rồi should_panic
pub fn parse_age(s: &str) -> Result<u32, std::num::ParseIntError> {
    s.parse::<u32>()
}

#[test]
#[should_panic] // TỆ: nuốt mất loại lỗi cụ thể
fn parse_invalid_panics() {
    parse_age("abc").unwrap(); // unwrap để ép panic, chỉ để dùng should_panic
}

Vấn đề: parse_age đã trả Result rất rõ — caller có thể recover. Test nên match đúng error variant, không nên gọi unwrap() rồi gắn #[should_panic]. Cách đúng:

#[test]
fn parse_invalid_returns_err() {
    let result = parse_age("abc");
    assert!(result.is_err());
}

Quy tắc heuristic khi quyết định:

  • Function trả Result → test bằng assertion trên Result, KHÔNG dùng should_panic.
  • Function panic là một phần của contract (constructor invariant, assert!, indexing) → DÙNG #[should_panic(expected = "...")].
  • Function panic vì bug nội bộ → đừng test panic đó; sửa bug.

Nói cách khác: should_panic dành cho contract chứ không dành cho convenient assertion.

8

Use Case Thực Tế

Tổng hợp các tình huống #[should_panic] là lựa chọn đúng — bám sát danh sách implicit panic trong stdlib đã liệt kê ở Bài 140:

  • Constructor có invariant: NonZeroAge::new(0), Percent::new(101.0), EmailAddr::new("") — assert ở đầu hàm. Test confirm assertion vẫn còn đó.
  • Assertion macro fail trong helper: hàm require_admin(&user) dùng assert!(user.role == Role::Admin, "admin required"). Test gọi với user thường, kỳ vọng panic message "admin required".
  • Integer overflow ở debug: u8::MAX + 1 trong debug profile gây panic với message "attempt to add with overflow". Test verify wrapper function của bạn vẫn phát hiện overflow ở debug build.
  • Slice out-of-bounds: &v[10] trên vec len 3 panic "index out of bounds". Test cho hàm trả slice với contract input đảm bảo bounds.
  • unwrap() trên None / Err: prototype hoặc helper internal cố ý unwrap. Test confirm function panic khi precondition không thoả.
  • Divide by zero: 10 / 0 panic "attempt to divide by zero" ở debug, hoặc panic tường minh ở mọi profile nếu bạn viết panic!().
  • State machine vi phạm: gọi conn.send() khi conn ở state Closed — assert state rồi panic. Test confirm side effect bảo vệ.

Mẫu chung: panic xảy ra là kết quả mong muốn của input invalid, chứ không phải bug. Đó là tiêu chí phân biệt should_panic đúng với should_panic lạm dụng.

9

Tổng Kết

  • #[should_panic] đảo ngược pass/fail: PASS khi body panic, FAIL khi không.
  • Luôn đi kèm expected = "substring" ở public API để tránh test pass nhờ panic ở chỗ sai.
  • Chỉ hoạt động với profile panic = "unwind" (mặc định dev/test); với panic = "abort" attribute mất tác dụng.
  • KHÔNG kết hợp được với fn() -> Result — compile error.
  • Dùng đúng: contract / invariant violation. Dùng sai: convenient assertion thay cho Result testing.
  • Use case: constructor validation, assertion helper, integer overflow debug, slice oob, divide by zero, unwrap trên None, state machine.
10

Bài Tập Củng Cố

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

  1. Viết struct Percent(f64) với new(value: f64) -> Self assert 0.0 <= value <= 100.0. Viết hai test #[should_panic(expected = "...")] cho cả hai biên (-1.0 và 101.0). Đảm bảo expected substring đủ đặc trưng để không match nhầm.
  2. Cho hàm fn parse_port(s: &str) -> Result<u16, ParseIntError>. Viết test verify input "abc" trả về Err. Giải thích tại sao KHÔNG nên dùng #[should_panic] ở đây.
  3. Test sau pass — hãy chỉ ra điểm sai và sửa lại bằng cách thêm expected:
    #[test]
    #[should_panic]
    fn divide_invalid() {
        let v = vec![1, 2, 3];
        let _ = v[10]; // out of bounds
        let _ = divide(10, 0); // không bao giờ chạy tới
    }
  4. Profile panic = "abort" được set trong [profile.test]. Điều gì xảy ra với mọi test #[should_panic] trong crate? Vì sao?
Đáp án
  1. Hai test riêng. new_rejects_negative() gọi Percent::new(-1.0) với expected = "must be >= 0" chẳng hạn; new_rejects_over_100() gọi Percent::new(101.0) với expected = "must be <= 100". Hai substring phải khác nhau để không match nhầm panic của test còn lại.
  2. Test thường với assert!(parse_port("abc").is_err()) hoặc assert!(matches!(parse_port("abc"), Err(_))). Không dùng should_panic vì hàm trả Result — đó là cơ chế error đã được Rust chọn cho hàm này; ép panic bằng unwrap() rồi should_panic chỉ làm test mất thông tin về loại lỗi và phá vỡ ý đồ API.
  3. Test pass nhờ panic "index out of bounds" chứ không nhờ divide(10, 0) panic. Thêm #[should_panic(expected = "division by zero")] và bỏ dòng v[10]. Bây giờ nếu logic divide bị thay đổi, test sẽ fail đúng chỗ.
  4. Toàn bộ test #[should_panic] không thể bắt panic — khi panic xảy ra, process abort ngay, test binary crash. Test runner báo failure không thể recover được. Trong thực tế, hiếm ai set panic = "abort" cho profile test; nếu cần test panic, để mặc định unwind cho profile test và chỉ chuyển release sang abort.
11

Bài Tiếp Theo

Bài 191: Test Filter, #[ignore], --test-threads — sang phía điều phối test run. Học cách chạy subset test bằng cargo test name_partial filter theo prefix, attribute #[ignore] để skip test chậm khỏi run mặc định nhưng vẫn chạy được qua cargo test -- --ignored, và cờ --test-threads kiểm soát số thread song song — cần khi test đụng tài nguyên global (file system, env var, port cố định) không an toàn để chạy parallel. Hoàn tất bộ ba viết / phân loại / điều phối test trước khi sang integration test ở bài 192.