ข้ามไปยังเนื้อหา

Cargo & Testing

Cargo คือระบบ build อย่างเป็นทางการและ package manager ของ Rust โปรเจกต์ Rust ทุกโปรเจกต์คือ crate และ Cargo จัดการ dependency, build, test และเอกสารของตัวเอง

Terminal window
# Create a new binary project
cargo new my_project
cd my_project
# Create a new library crate
cargo 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 step
cargo run
# Run with arguments
cargo run -- arg1 arg2
# Check for compile errors without producing a binary
cargo check
# Add a dependency
cargo add serde --features derive
# Update dependencies to latest compatible versions
cargo update
# Format code
cargo fmt
# Run the linter
cargo 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 project structure
[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 benchmarks
pretty_assertions = "1"

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 chainif let Some(x) = opt && x > 2 compile ผ่าน — เฉพาะ edition 2024
scope ของ temporary ใน if lettemporary ถูก drop ก่อนเข้า branch else ตัด deadlock ของ lock ไปทั้งกลุ่ม
การ capture lifetime ของ impl Traitimpl Trait ที่ return ออกมา capture ทุก lifetime ที่อยู่ใน scope
unsafe externblock extern ต้องเขียนเป็น unsafe extern "C"
สงวนคำว่า gengen กลายเป็น keyword เก็บไว้สำหรับ generator block ในอนาคต

การย้าย crate เดิมมี 2 ขั้น และ Cargo ทำให้เกือบหมด:

Terminal window
# 1. rewrite the code so it means the same thing under both editions
cargo fix --edition
# 2. bump edition = "2024" in Cargo.toml, then confirm behaviour
cargo 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 ทันทีแทนที่จะเงียบๆ ลดเวอร์ชันให้

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 ทั้งหมดด้วย:

Terminal window
cargo 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);
}
}
#[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 tests
test tests::demonstrate_assertions ... ok
test tests::test_panic - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ตัวอย่างทั้งหมดที่ผ่านมาผ่านหมด นี่คือ output จริงเมื่อ test ล้มเหลวจริง ๆ — format ที่แน่นอนสำคัญ เพราะสักวันคุณจะต้องจ้องมันภายใต้ความกดดัน:

// @expect-panic
fn 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 test
test 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: 5
note: 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.00s

assert_eq! เรียกสองฝั่งของมันว่า left/right เสมอ (ตามลำดับที่คุณเขียนใน macro call) ไม่เคยเป็น “expected”/“actual” — รายละเอียดที่จำผิดได้ง่ายและควรเช็คทุกครั้งที่อ่านครั้งแรก เพราะในที่นี้ left คือ add(2, 2) และ right คือ literal 5 ของคุณเอง

#[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 tests
test tests::test_divide_by_zero_expected - should panic ... ok
test 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 นั้นจริง ๆ

ทำเครื่องหมาย 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 tests
test tests::fast_check ... ok
test 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 เพียงอย่างเดียว

ไฟล์ภายใต้ tests/ คือ integration test ซึ่งทดสอบ public API ของ crate ของคุณในฐานะ consumer ภายนอก

tests/integration_test.rs
// @skip-verify multi-file: integration test
use my_lib::multiply;
#[test]
fn integration_multiply() {
assert_eq!(multiply(6, 7), 42);
}

รัน integration test เฉพาะ:

Terminal window
cargo test --test integration_test

รัน test เฉพาะด้วยชื่อ:

Terminal window
cargo test test_divide

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
}
Terminal window
cargo test --doc
Doc-tests cargo_tests_scratch
running 1 test
test src/lib.rs - add (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Doc 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 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 ถ้าคุณไม่ต้องการที่นั่น

rustc เช็คว่าโค้ดคุณ ถูกต้อง; clippy เช็คว่ามัน idiomatic การรันมันพร้อม lint เพิ่มเติมสองสามตัวกับไฟล์เล็ก ๆ ที่มี smell สไตล์ AI หลายอย่างโดยตั้งใจ จะโชว์ warning จริง เจาะจง — ไม่ใช่แค่สมมติ:

// @skip-verify demo: illustrates multiple clippy lints together, not meant to run
use 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
}
Terminal window
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_variant
warning: 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_map
25 | | 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 bytes
31 | | Polygon([f64; 64]), // clippy::large_enum_variant
| | ------------------ the largest variant contains at least 512 bytes
32 | | }
| |_^ the entire enum is at least 520 bytes
help: 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 จัด format โค้ดของคุณให้เป็น style มาตรฐานของโปรเจกต์; cargo fmt --check (เวอร์ชันที่เหมาะกับ CI) รายงานสิ่งที่จะเปลี่ยนโดยไม่แตะไฟล์เลย และ exit ไม่เป็นศูนย์ถ้ามีอะไรที่ยังไม่ถูก format:

Terminal window
cargo fmt --check
Diff 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 โดยค่าเริ่มต้น — ต้องติดตั้งแยก (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 ที่ 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.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 เดียว

Debug build เปิด overflow check ทางคณิตศาสตร์โดยค่าเริ่มต้น release build ไม่เปิดเพื่อประสิทธิภาพ test เดียวกันสามารถผ่านหรือล้มเหลวขึ้นอยู่กับว่ารันภายใต้ profile ไหนล้วน ๆ:

// @skip-verify demo: shows debug-vs-release divergence, not a single runnable outcome
pub 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 overflow
test tests::test_overflow ... FAILED
# cargo test --release --lib (release profile)
result: 4
test 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 เป็นคนตัดสินใจแทนคุณ

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 ได้โดยไม่ได้ตรวจสอบอะไรเลย

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 ล้มเหลว ไม่ใช่แค่ดูล้าสมัย

  • “ถ้า cargo test บอก ok แปลว่าฟังก์ชันถูกต้อง” มันหมายความแค่ว่าทุก assertion ในทุก test ที่รันนั้นเป็นจริง #[should_panic] ที่ไม่มี expected หรือ test ที่ไม่มี assert!/assert_eq! เลย รายงาน ok โดยไม่ได้ตรวจสอบอะไรที่มีความหมายเลย ตามที่ demo expected ข้างบนแสดงตรง ๆ
  • “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 เป็นคำใบ้ให้ไปอ่านว่า ทำไม ก่อนตัดสินใจว่าเพิกเฉยได้อย่างปลอดภัย

ชุด 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"]);

edition = "2024" ใน Cargo.toml ควบคุมอะไร?
#[cfg(test)] บน mod block ทำอะไร?
macro assertion ใดที่ตรวจสอบว่าสองค่าไม่เท่ากัน?
ทั้งสอง test นี้รายงาน ok มีอะไรผิดจริง ๆ?
#[test]
#[should_panic]
fn test_divide_by_zero_no_expected() {
    let v: Vec<i32> = Vec::new();
    let _ = v[0]; // panics here, unrelated to division
    let _ = divide(1, 0); // never reached
}
ทำไม test เดียวกันถึงผ่านภายใต้ cargo test แต่ล้มเหลว (หรือกลับกัน) ภายใต้ cargo test --release?
ควรวาง integration test ไว้ที่ไหนใน Cargo project?
ใช้ attribute ใดเพื่อทดสอบ async function ด้วย tokio?