Cargo & Testing
Cargo: ระบบ Build และ Package Manager ของ Rust
หัวข้อที่มีชื่อว่า “Cargo: ระบบ Build และ Package Manager ของ Rust”Cargo คือระบบ build อย่างเป็นทางการและ package manager ของ Rust โปรเจกต์ Rust ทุกโปรเจกต์คือ crate และ Cargo จัดการ dependency, build, test และเอกสารของตัวเอง
คำสั่ง Cargo ที่จำเป็น
หัวข้อที่มีชื่อว่า “คำสั่ง Cargo ที่จำเป็น”# Create a new binary projectcargo new my_projectcd my_project
# Create a new library cratecargo new my_lib --lib
# Build in debug mode (fast compile, unoptimized)cargo build
# Build in release mode (slow compile, optimized)cargo build --release
# Build and run in one stepcargo run
# Run with argumentscargo run -- arg1 arg2
# Check for compile errors without producing a binarycargo check
# Add a dependencycargo add serde --features derive
# Update dependencies to latest compatible versionscargo update
# Format codecargo fmt
# Run the lintercargo clippyโครงสร้างโปรเจกต์
หัวข้อที่มีชื่อว่า “โครงสร้างโปรเจกต์”flowchart TD root["my_project/"] toml["Cargo.toml — manifest: name, version, dependencies"] lock["Cargo.lock — exact dependency versions (commit for binaries)"] src["src/"] main["main.rs — entry point for binary crates"] lib["lib.rs — entry point for library crates"] tests["tests/"] itest["integration_test.rs — integration tests (optional)"] root --> toml root --> lock root --> src root --> tests src --> main src --> lib tests --> itest
โครงสร้าง Cargo.toml
หัวข้อที่มีชื่อว่า “โครงสร้าง Cargo.toml”[package]name = "my_project"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }anyhow = "1"
[dev-dependencies]# only compiled for tests and benchmarkspretty_assertions = "1"Edition: edition = "2024" คืออะไร
หัวข้อที่มีชื่อว่า “Edition: edition = "2024" คืออะไร”key edition เลือก dialect ของภาษาที่ crate ของคุณจะถูก compile ด้วย edition ทำให้ Rust เปลี่ยน syntax และ idiom ได้โดยไม่ทำให้ ecosystem แตกเป็นเสี่ยง — crate ที่อยู่คนละ edition link เข้าด้วยกันได้ปกติ แต่ละ crate จึงอัปเกรดตามจังหวะของตัวเองได้
cargo new สร้าง edition = "2024" มาตั้งแต่ Rust 1.85 (กุมภาพันธ์ 2025) สิ่งที่ edition 2024 เปลี่ยน เรียงตามลำดับที่คุณน่าจะเจอก่อน:
| สิ่งที่เปลี่ยน | มีผลกับคุณอย่างไร |
|---|---|
| Let chain | if let Some(x) = opt && x > 2 compile ผ่าน — เฉพาะ edition 2024 |
scope ของ temporary ใน if let | temporary ถูก drop ก่อนเข้า branch else ตัด deadlock ของ lock ไปทั้งกลุ่ม |
การ capture lifetime ของ impl Trait | impl Trait ที่ return ออกมา capture ทุก lifetime ที่อยู่ใน scope |
unsafe extern | block extern ต้องเขียนเป็น unsafe extern "C" |
สงวนคำว่า gen | gen กลายเป็น keyword เก็บไว้สำหรับ generator block ในอนาคต |
การย้าย crate เดิมมี 2 ขั้น และ Cargo ทำให้เกือบหมด:
# 1. rewrite the code so it means the same thing under both editionscargo fix --edition
# 2. bump edition = "2024" in Cargo.toml, then confirm behaviourcargo testการ resolve dependency แบบรู้จัก MSRV เป็นกลไกแยกต่างหากของ Cargo ที่ต้อง opt-in เอง — มาจาก field rust-version บวกกับ config resolver.incompatible-rust-versions (ค่า default คือ fallback ตั้งแต่ Cargo 1.84) และ**ไม่เกี่ยวกับ edition**เลย เมื่อตั้ง rust-version ไว้ พฤติกรรม fallback ที่เป็นค่า default จะทำให้ Cargo ข้ามเวอร์ชัน dependency ที่ประกาศ rust-version ของตัวเองใหม่กว่าของคุณ แล้วถอยไปใช้เวอร์ชันเก่ากว่าที่เข้ากันได้แทน หากตั้ง resolver.incompatible-rust-versions = "deny" จะทำให้กรณีนั้นกลายเป็น error ทันทีแทนที่จะเงียบๆ ลดเวอร์ชันให้
Unit Testing ด้วย #[test]
หัวข้อที่มีชื่อว่า “Unit Testing ด้วย #[test]”Rust มีการทดสอบที่สร้างไว้ในภาษาและ Cargo — ไม่ต้องการ framework ทดสอบเพิ่มเติม ใส่ #[test] บนฟังก์ชันเพื่อทำเครื่องหมายว่าเป็น test
fn add(a: i32, b: i32) -> i32 { a + b}
#[test]fn test_add() { assert_eq!(add(2, 3), 5); assert_eq!(add(-1, 1), 0); assert_ne!(add(2, 3), 6);}รัน test ทั้งหมดด้วย:
cargo testรูปแบบ Module #[cfg(test)]
หัวข้อที่มีชื่อว่า “รูปแบบ Module #[cfg(test)]”สถานที่ idiomatic สำหรับ unit test คือภายในไฟล์ source ที่ทดสอบ ภายในบล็อก mod tests ที่ถูกปิดกั้นด้วย #[cfg(test)] Cargo compile module นี้เฉพาะเมื่อรัน cargo test ทำให้ไม่อยู่ใน binary ที่ใช้งานจริง
pub fn multiply(a: i32, b: i32) -> i32 { a * b}
pub fn divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) }}
#[cfg(test)]mod tests { use super::*; // bring parent module items into scope
#[test] fn test_multiply() { assert_eq!(multiply(3, 4), 12); assert_eq!(multiply(0, 100), 0); assert_eq!(multiply(-2, 5), -10); }
#[test] fn test_divide_by_zero() { assert_eq!(divide(10.0, 0.0), None); }
#[test] fn test_divide_normal() { let result = divide(10.0, 4.0).unwrap(); assert!((result - 2.5).abs() < f64::EPSILON); }}Test Assertion
หัวข้อที่มีชื่อว่า “Test Assertion”#[cfg(test)]mod tests { #[test] fn demonstrate_assertions() { // equality assert_eq!(2 + 2, 4); assert_ne!(2 + 2, 5);
// boolean assert!(4 > 3); assert!(!"hello".is_empty());
// with custom message assert_eq!(1 + 1, 2, "basic arithmetic must hold");
// panics // use #[should_panic] to assert a function panics }
#[test] #[should_panic(expected = "divide by zero")] fn test_panic() { let _ = 1 / std::hint::black_box(0); // this panics — test passes because we expect it }}literal 1 / 0 ตรงๆ นั้นไปไม่ถึงขั้นรันด้วยซ้ำ: การวิเคราะห์ constant-propagation พิสูจน์ได้ว่าการหารนี้ panic แน่นอน จึงปฏิเสธไม่ compile ให้ เพราะ unconditional_panic เป็น lint ที่ deny-by-default std::hint::black_box ช่วยซ่อนค่า 0 จากการวิเคราะห์นั้น ทำให้ panic เกิดตอน runtime แทน ซึ่งเป็นสิ่งที่ #[should_panic] ต้องการเพื่อทดสอบ
error: this operation will panic at runtime --> src/main.rs:23:17 |23 | let _ = 1 / 0; // this panics — test passes because we expect it | ^^^^^ attempt to divide `1_i32` by zero | = note: `#[deny(unconditional_panic)]` on by defaultพอแก้แล้ว cargo test ก็ compile ผ่านและ test ผ่านจริง:
running 2 teststest tests::demonstrate_assertions ... oktest tests::test_panic - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sassert_eq! พิมพ์อะไรเมื่อมันล้มเหลวจริง ๆ
หัวข้อที่มีชื่อว่า “assert_eq! พิมพ์อะไรเมื่อมันล้มเหลวจริง ๆ”ตัวอย่างทั้งหมดที่ผ่านมาผ่านหมด นี่คือ output จริงเมื่อ test ล้มเหลวจริง ๆ — format ที่แน่นอนสำคัญ เพราะสักวันคุณจะต้องจ้องมันภายใต้ความกดดัน:
// @expect-panicfn add(a: i32, b: i32) -> i32 { a + b}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_add_wrong_on_purpose() { assert_eq!(add(2, 2), 5); }}running 1 testtest tests::test_add_wrong_on_purpose ... FAILED
failures:
---- tests::test_add_wrong_on_purpose stdout ----
thread 'tests::test_add_wrong_on_purpose' (<id>) panicked at src/main.rs:11:9:assertion `left == right` failed left: 4 right: 5note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures: tests::test_add_wrong_on_purpose
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sassert_eq! เรียกสองฝั่งของมันว่า left/right เสมอ (ตามลำดับที่คุณเขียนใน macro call) ไม่เคยเป็น “expected”/“actual” — รายละเอียดที่จำผิดได้ง่ายและควรเช็คทุกครั้งที่อ่านครั้งแรก เพราะในที่นี้ left คือ add(2, 2) และ right คือ literal 5 ของคุณเอง
ทำไม argument expected ของ should_panic ถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไม argument expected ของ should_panic ถึงสำคัญ”#[should_panic] เฉยๆ เช็คแค่ว่า test panic อย่างใดอย่างหนึ่ง — มันไม่บอกอะไรเลยว่า ทำไม ช่องว่างนั้นทำให้ test ผ่านได้โดยไม่ได้ตรวจสอบอะไรเลยเกี่ยวกับฟังก์ชันที่มันอ้างว่าทดสอบ:
fn divide(a: i32, b: i32) -> i32 { if b == 0 { panic!("cannot divide by zero"); } a / b}
#[cfg(test)]mod tests { use super::*;
// BUG: expected is missing, so this test passes even though the panic // message has nothing to do with division — it "passes for the wrong reason". #[test] #[should_panic] fn test_divide_by_zero_no_expected() { let v: Vec<i32> = Vec::new(); let _ = v[0]; // panics with an out-of-bounds index, NOT "cannot divide by zero" let _ = divide(1, 0); // never reached }
// FIXED: expected pins down which panic message is acceptable. #[test] #[should_panic(expected = "cannot divide by zero")] fn test_divide_by_zero_expected() { divide(1, 0); }}running 2 teststest tests::test_divide_by_zero_expected - should panic ... oktest tests::test_divide_by_zero_no_expected - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sทั้งสอง test รายงาน ok test_divide_by_zero_no_expected ไม่เคยไปถึงการเรียก divide เลยด้วยซ้ำ — มัน panic จาก index ของ Vec ที่เกินขอบเขตก่อน — แต่ #[should_panic] ที่ไม่มี expected ยอมรับ panic อะไรก็ได้ ดังนั้น test นี้ผ่านโดยไม่ได้ทดสอบอะไรเกี่ยวกับ divide เลย #[should_panic(expected = "cannot divide by zero")] ปิดช่องว่างนั้น: test จะผ่านก็ต่อเมื่อข้อความ panic มี substring นั้นจริง ๆ
#[ignore] และ --nocapture
หัวข้อที่มีชื่อว่า “#[ignore] และ --nocapture”ทำเครื่องหมาย test ที่ช้าหรือขึ้นกับ environment ด้วย #[ignore] เพื่อข้ามมันโดยค่าเริ่มต้น รันมันตรงๆ ด้วย --ignored เมื่อต้องการ:
#[cfg(test)]mod tests { #[test] #[ignore] // skipped by default — run explicitly with `cargo test -- --ignored` fn slow_integration_check() { std::thread::sleep(std::time::Duration::from_secs(5)); assert!(true); }
#[test] fn fast_check() { assert_eq!(2 + 2, 4); }}running 2 teststest tests::fast_check ... oktest tests::slow_integration_check ... ignored
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00sโดยค่าเริ่มต้น cargo test จะ capture stdout ของทุก test ที่ผ่านแล้วทิ้งไป — คุณจะเห็น output เฉพาะจาก test ที่ล้มเหลว ใส่ -- --nocapture เพื่อดู output ของ println! จาก test ที่ผ่านด้วย ซึ่งมีประโยชน์มากตอนเขียน test ใหม่และเช็คว่ามันทำสิ่งที่คุณคาดหวังก่อนที่จะเชื่อ assertion เพียงอย่างเดียว
Integration Tests
หัวข้อที่มีชื่อว่า “Integration Tests”ไฟล์ภายใต้ tests/ คือ integration test ซึ่งทดสอบ public API ของ crate ของคุณในฐานะ consumer ภายนอก
// @skip-verify multi-file: integration testuse my_lib::multiply;
#[test]fn integration_multiply() { assert_eq!(multiply(6, 7), 42);}รัน integration test เฉพาะ:
cargo test --test integration_testรัน test เฉพาะด้วยชื่อ:
cargo test test_divideDoc Test: ตัวอย่างที่เป็น Test ด้วย
หัวข้อที่มีชื่อว่า “Doc Test: ตัวอย่างที่เป็น Test ด้วย”code example ในคอมเมนต์ /// ก็ถูก compile และรันโดย cargo test เช่นกัน — นี่คือวิธีที่ standard library รักษาความถูกต้องของตัวอย่างในเอกสาร ถ้าตัวอย่าง compile ไม่ผ่านหรือ assert_eq! ของมันล้มเหลว cargo test จะล้มเหลวไปด้วย ตัวอย่างในเอกสารมักเขียนเป็น fenced block สามแบ็กทิกภายในคอมเมนต์ ส่วนแบบ indent ด้านล่าง (เว้น 4 ช่องว่าง ไม่มีแบ็กทิก) เป็นวิธีเก่ากว่าแต่ยังรองรับเต็มรูปแบบในการทำเครื่องหมายตัวอย่างที่รันได้แบบเดียวกัน ใช้ที่นี่เพียงเพื่อไม่ให้ตัวอย่างนี้ต้องมี code fence ซ้อนอยู่ใน code fence อีกอัน
/// Adds two numbers together.////// # Examples////// use cargo_tests_scratch::add;/// assert_eq!(add(2, 3), 5);pub fn add(a: i32, b: i32) -> i32 { a + b}cargo test --doc Doc-tests cargo_tests_scratch
running 1 testtest src/lib.rs - add (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sDoc test รันแยก pass จาก unit test และ integration test (คุณเห็น header Doc-tests แยกต่างหาก) และแต่ละอัน compile เป็น binary จิ๋วของตัวเองที่เจาะจงกับ API สาธารณะ ของ crate — ขอบเขตเดียวกับที่ integration test ใน tests/ ใช้ ซึ่งเป็นเหตุผลที่ตัวอย่าง import add ด้วย use cargo_tests_scratch::add; แทนที่จะพึ่งการอยู่ภายใน crate เดียวกัน
การทดสอบ Async Code ด้วย tokio
หัวข้อที่มีชื่อว่า “การทดสอบ Async Code ด้วย tokio”สำหรับ async function ใช้ #[tokio::test]:
// @skip-verify crate: tokio#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn test_async_fetch() { let result = fetch_data(1).await; assert!(!result.is_empty()); }}เพิ่ม tokio ใน [dev-dependencies] เพื่อใช้ #[tokio::test] โดยไม่ดึง tokio เข้า production binary ถ้าคุณไม่ต้องการที่นั่น
cargo clippy: จับ Smell ที่ rustc จับไม่ได้
หัวข้อที่มีชื่อว่า “cargo clippy: จับ Smell ที่ rustc จับไม่ได้”rustc เช็คว่าโค้ดคุณ ถูกต้อง; clippy เช็คว่ามัน idiomatic การรันมันพร้อม lint เพิ่มเติมสองสามตัวกับไฟล์เล็ก ๆ ที่มี smell สไตล์ AI หลายอย่างโดยตั้งใจ จะโชว์ warning จริง เจาะจง — ไม่ใช่แค่สมมติ:
// @skip-verify demo: illustrates multiple clippy lints together, not meant to runuse std::sync::Mutex;
async fn bad_pattern(shared: &Mutex<i32>) { let guard = shared.lock().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(1)).await; // guard is still held here println!("{}", *guard);}
fn redundant_clone_example(s: &String) -> String { let t = s.clone(); t.clone() // clippy::redundant_clone}
fn needless_range_loop_example(v: &[i32]) -> i32 { let mut sum = 0; for i in 0..v.len() { sum += v[i]; // clippy::needless_range_loop } sum}
fn manual_map_example(opt: Option<i32>) -> Option<i32> { match opt { Some(x) => Some(x + 1), // clippy::manual_map None => None, }}
enum Shape { Circle(f64), Polygon([f64; 64]), // clippy::large_enum_variant}cargo clippy -- \ -W clippy::redundant_clone \ -W clippy::unwrap_used \ -W clippy::await_holding_lock \ -W clippy::needless_range_loop \ -W clippy::manual_map \ -W clippy::large_enum_variantwarning: this `MutexGuard` is held across an await point --> src/main.rs:4:9 |4 | let guard = shared.lock().unwrap(); | ^^^^^ | = help: consider using an async-aware `Mutex` type or ensuring the `MutexGuard` is dropped before calling `await`
warning: used `unwrap()` on a `Result` value --> src/main.rs:4:17 |4 | let guard = shared.lock().unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic
warning: redundant clone --> src/main.rs:11:6 |11 | t.clone() // clippy::redundant_clone | ^^^^^^^^ help: remove this |note: this value is dropped without further use
warning: the loop variable `i` is only used to index `v` --> src/main.rs:16:14 |16 | for i in 0..v.len() { | ^^^^^^^^^^ |help: consider using an iterator |16 - for i in 0..v.len() {16 + for <item> in &v { |
warning: manual implementation of `Option::map` --> src/main.rs:23:5 |23 | / match opt {24 | | Some(x) => Some(x + 1), // clippy::manual_map25 | | None => None,26 | | } | |_____^ help: try: `opt.map(|x| x + 1)`
warning: large size difference between variants --> src/main.rs:29:1 |29 | / enum Shape {30 | | Circle(f64), | | ----------- the second-largest variant contains at least 8 bytes31 | | Polygon([f64; 64]), // clippy::large_enum_variant | | ------------------ the largest variant contains at least 512 bytes32 | | } | |_^ the entire enum is at least 520 byteshelp: consider boxing the large fields or introducing indirection in some other way to reduce the total size of the enumหก lint ที่แตกต่างกัน หกทางแก้ที่เจาะจง — ไม่มีอันไหนเป็นแค่ความจู้จี้เรื่อง style เลย แต่ละอันชี้ต้นทุนจริง (lock ที่ถือนานเกินไป, clone ที่ไม่ทำอะไรเลย, unwrap() ที่ panic ได้, enum ที่เปลืองพื้นที่ทุก instance) clippy::unwrap_used และ clippy::redundant_clone ไม่อยู่ใน warn set เริ่มต้นของ clippy (ต้องใส่ -W ชัดเจนตามที่แสดง) เพราะมันพบได้บ่อยในโค้ดที่ถูกต้องตามกฎหมาย clippy จึงไม่อยากรบกวนโดยค่าเริ่มต้น — แต่การเปิดมันโดยตั้งใจใน CI สำหรับ codebase ที่ AI เขียนหรือ review เป็นกับดักที่คุ้มค่าและถูก
cargo fmt --check
หัวข้อที่มีชื่อว่า “cargo fmt --check”cargo fmt จัด format โค้ดของคุณให้เป็น style มาตรฐานของโปรเจกต์; cargo fmt --check (เวอร์ชันที่เหมาะกับ CI) รายงานสิ่งที่จะเปลี่ยนโดยไม่แตะไฟล์เลย และ exit ไม่เป็นศูนย์ถ้ามีอะไรที่ยังไม่ถูก format:
cargo fmt --checkDiff in /path/to/project/src/badfmt.rs:1:-pub fn messy( a:i32,b :i32)->i32{a+b}+pub fn messy(a: i32, b: i32) -> i32 {+ a + b+}รันคำสั่งนี้ใน CI (ไม่ใช่ cargo fmt เองซึ่งจะเขียนทับไฟล์เงียบๆ) เพื่อให้ pull request ที่ format ไม่สอดคล้องกันล้มเหลวตั้งแต่ build แทนที่จะปล่อยให้ style ของ codebase ค่อย ๆ เพี้ยนไปเรื่อย ๆ
cargo audit และ cargo deny: ความปลอดภัยของ Dependency
หัวข้อที่มีชื่อว่า “cargo audit และ cargo deny: ความปลอดภัยของ Dependency”ทั้งสองไม่มาพร้อม Cargo โดยค่าเริ่มต้น — ต้องติดตั้งแยก (cargo install cargo-audit, cargo install cargo-deny) แล้วรันเป็น cargo audit / cargo deny check cargo audit เทียบ Cargo.lock ของคุณกับฐานข้อมูล security advisory ของ RustSec แล้วชี้ dependency ที่มีช่องโหว่ที่รู้จัก cargo deny กว้างกว่านั้น: มันบังคับ license allowlist ได้ด้วย ห้าม crate หรือ crate source เฉพาะ และชี้ dependency ที่มีหลายเวอร์ชันซ้ำใน dependency tree ทั้งสองมักถูกต่อเข้ากับ CI เพื่อให้ช่องโหว่ที่เพิ่งถูกเปิดเผยใน dependency ทางอ้อมทำให้ build ล้มเหลวแทนที่จะปล่อยผ่านไปเงียบ ๆ
rust-toolchain.toml: การ Pin เวอร์ชัน Compiler
หัวข้อที่มีชื่อว่า “rust-toolchain.toml: การ Pin เวอร์ชัน Compiler”ไฟล์ rust-toolchain.toml ที่ project root จะ pin toolchain ที่แน่นอนที่ rustup ใช้สำหรับโปรเจกต์นั้น ทำให้ทุกคนที่ contribute (และ CI) build ด้วย compiler เดียวกันโดยไม่ต้องจำว่าต้องรัน rustup override set:
[toolchain]channel = "1.96.1"components = ["clippy", "rustfmt"]นี่คือกลไกที่ต่างจาก field rust-version ใน Cargo.toml ที่คลุมไปข้างบน: rust-version บันทึก (และให้ resolver ใช้พิจารณา) เวอร์ชันต่ำสุดที่ crate ที่เผยแพร่รองรับ; rust-toolchain.toml pin เวอร์ชันที่แน่นอนที่ checkout ของ repository นี้ในเครื่อง build ด้วย ไม่ว่าเครื่องนั้นจะติดตั้งอะไรอยู่บ้าง
Workspace: หลาย Crate หนึ่ง Cargo.lock
หัวข้อที่มีชื่อว่า “Workspace: หลาย Crate หนึ่ง Cargo.lock”Workspace รวมหลาย crate ไว้ใต้ Cargo.toml ระดับบนสุดเดียวและ Cargo.lock เดียวที่ใช้ร่วมกัน ทำให้พวกมัน build ด้วย dependency version เดียวกันเสมอ:
# Cargo.toml at the workspace root[workspace]members = ["api", "core", "cli"]resolver = "3"แต่ละ member (api/, core/, cli/) มี Cargo.toml ของตัวเองพร้อม [dependencies] ของตัวเอง แต่ cargo build/cargo test จาก workspace root จะ build และ test ทั้งหมดพร้อมกัน และ cargo test -p core เจาะจงแค่ member เดียว
cargo test --release: Overflow Check ถูกปิด
หัวข้อที่มีชื่อว่า “cargo test --release: Overflow Check ถูกปิด”Debug build เปิด overflow check ทางคณิตศาสตร์โดยค่าเริ่มต้น release build ไม่เปิดเพื่อประสิทธิภาพ test เดียวกันสามารถผ่านหรือล้มเหลวขึ้นอยู่กับว่ารันภายใต้ profile ไหนล้วน ๆ:
// @skip-verify demo: shows debug-vs-release divergence, not a single runnable outcomepub fn will_overflow(x: u8) -> u8 { x + std::hint::black_box(10) // overflows once x > 245}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_overflow() { println!("result: {}", will_overflow(250)); }}# cargo test --lib (debug profile — default)thread 'tests::test_overflow' (<id>) panicked at src/lib.rs:2:5:attempt to add with overflowtest tests::test_overflow ... FAILED
# cargo test --release --lib (release profile)result: 4test tests::test_overflow ... okใน debug 250_u8 + 10 panic ด้วย “attempt to add with overflow” — บั๊กจริงถูกจับได้ทันที ใน release เลขคณิตเดียวกันจะ wrap เงียบ ๆ (250 + 10 = 260 ซึ่ง wrap เป็น 4 ใน u8) และ test ก็ผ่านทั้ง ๆ ที่คำนวณผิด นี่คือเหตุผลว่าทำไม cargo test --release ควรถูกสงสัยสักครู่เมื่อมันทำให้ test ที่เคยล้มเหลวผ่านไปได้: ตัวเลขที่ wrap และผิดอย่างเงียบ ๆ คือบั๊กจริงใน production ไม่ใช่การแก้ไข ถ้าการ wrap เป็นพฤติกรรมที่ตั้งใจจริง ๆ ให้เขียนให้ชัดเจนด้วย wrapping_add แทนที่จะให้ profile เป็นคนตัดสินใจแทนคุณ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Cargo, rustc และ clippy แบ่งงานกันชัดเจน: rustc (ผ่าน cargo build/cargo check) ตอบคำถาม “โค้ดนี้ถูกต้องไหม” — memory safety, type, borrow clippy ตอบคำถาม “โค้ดนี้ idiomatic ไหม” — โค้ดที่ถูกต้องเหมือนกัน แต่ถูกชี้ให้เห็น pattern ที่มักเป็นความผิดพลาดหรือต้นทุนที่ไม่จำเป็น cargo test ตอบคำถาม “โค้ดนี้ทำสิ่งที่ฉันอ้างว่ามันทำจริงไหม” — และคำอ้างนั้นแข็งแรงเท่ากับ assertion ที่อยู่ในแต่ละ test เท่านั้น ซึ่งเป็นเหตุผลที่ #[should_panic] ที่ไม่มี expected หรือ test ที่ไม่มี assert เลย สามารถรายงาน ok ได้โดยไม่ได้ตรวจสอบอะไรเลย
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”Unit test (#[cfg(test)] mod tests ในไฟล์ source เดียวกัน) compile เร็วและเข้าถึง item ที่ private ได้ แต่ทดสอบแค่ module เดียวแบบแยกโดด Integration test (tests/) compile ช้ากว่า (แต่ละไฟล์เป็น crate ของตัวเอง) และเข้าถึงได้เฉพาะ public API แต่จับบั๊กเรื่องการเชื่อมต่อที่ unit test มองไม่เห็นโดยโครงสร้าง Doc test ช้าที่สุดต่อตัวอย่าง (แต่ละตัวอย่าง compile เป็น binary จิ๋วของตัวเอง) แต่คุ้มค่าด้วยการรักษาไม่ให้เอกสารเน่าเงียบ ๆ — ตัวอย่างที่หยุดทำงานจะทำให้ build ล้มเหลว ไม่ใช่แค่ดูล้าสมัย
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ถ้า
cargo testบอกokแปลว่าฟังก์ชันถูกต้อง” มันหมายความแค่ว่าทุก assertion ในทุก test ที่รันนั้นเป็นจริง#[should_panic]ที่ไม่มีexpectedหรือ test ที่ไม่มีassert!/assert_eq!เลย รายงานokโดยไม่ได้ตรวจสอบอะไรที่มีความหมายเลย ตามที่ demoexpectedข้างบนแสดงตรง ๆ - “
cargo test --releaseแค่เป็นวิธีรัน test เดียวกันที่เร็วกว่า” มันเปลี่ยนพฤติกรรมจริง ไม่ใช่แค่ความเร็ว: overflow check ถูก compile ออกไป test ที่พึ่งพา panic ตอน debug mode จริง ๆ (หรือที่ซ่อนบั๊กไว้หลัง wrapping ของ release mode) สามารถผ่านหรือล้มเหลวต่างกันแค่เพราะ profile - “clippy warning เป็นแค่ความเห็นเรื่อง style ที่เพิกเฉยได้” บางอันก็ใช่ (เช่นข้อตกลงการตั้งชื่อ) แต่บางอัน —
await_holding_lock,large_enum_variant,redundant_clone— ชี้ต้นทุนหรือความเสี่ยงจริงตอน runtime ที่บังเอิญมองเห็นได้แบบ static ด้วย ให้ถือว่า clippy warning เป็นคำใบ้ให้ไปอ่านว่า ทำไม ก่อนตัดสินใจว่าเพิกเฉยได้อย่างปลอดภัย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ชุด test ที่ AI เขียนมักสร้างฟังก์ชัน test ที่รันโค้ดแล้วพิมพ์ผลลัพธ์ โดยไม่เคย assert อะไรเกี่ยวกับมันเลยจริง ๆ — มัน “ดูเหมือน test” (compile ได้ มี attribute #[test] รันโดยไม่ panic) โดยไม่ได้ตรวจสอบอะไรเลย
🐛 หา bug ในโค้ดนี้
fn parse_config(input: &str) -> Vec<String> { input.split(',').map(|s| s.trim().to_string()).collect()}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_parse_config() { let result = parse_config("a, b, c"); println!("{:?}", result); // looks like a test, but nothing is actually checked }}เฉลย
test_parse_config เรียก parse_config แล้วพิมพ์ผลลัพธ์ แต่ไม่มี assert!, assert_eq! หรือการเช็คใด ๆ เลยใน body ของฟังก์ชัน test นี้ผ่าน (ok) สำหรับ implementation ใดก็ตาม ของ parse_config รวมถึงตัวที่ return Vec ว่างเสมอ — regression ที่ทำลายฟังก์ชันอย่างเงียบ ๆ จะปล่อยให้ test นี้เขียวอยู่ ทางแก้ไม่ใช่โค้ดเพิ่ม แต่คือ assertion ที่ไม่เคยถูกเขียน: assert_eq!(result, vec!["a", "b", "c"]);