ทดสอบ crash-consistency — จำลอง crash จริงด้วย cargo test
บท3 เราประกาศ สัญญา เรื่อง durability ไว้ชัด — write_all → sync_data → update index → ack — และบท4 ก็ประกาศว่า tombstone กับ compaction ไม่ทำลายความถูกต้อง แต่ทั้งหมดนั้นยังเป็นแค่ คำพูด compiler ของ Rust พิสูจน์ให้เราได้เยอะมาก — ไม่มี use-after-free, ไม่มี data race, Arc<Mutex<T>> ใช้ถูก — แต่มีสิ่งหนึ่งที่ borrow checker มองไม่เห็นเลย: จะเกิดอะไรขึ้นถ้า process ตายกลางคัน แล้วเปิด file ชุดเดิมขึ้นมาใหม่ ความถูกต้องข้อนี้ไม่ได้อยู่ในระบบชนิด (type system) มันอยู่บน disk และวิธีเดียวที่จะรู้คือ ทดสอบมันจริงๆ
บทนี้เปลี่ยนคำสัญญาเรื่อง durability ให้กลายเป็น oracle อัตโนมัติ: เขียนข้อมูล → จำลอง crash → reopen → replayreplayการอ่าน log ตั้งแต่ต้นจนจบเพื่อสร้างสถานะขึ้นใหม่ log → ยืนยันว่าทุก key/value ที่ ack แล้วรอด และ key ที่ถูก tombstone หายไปจริง จุดที่คมที่สุดคือเราจะไม่จำลอง crash แบบขอไปที — เราจะ SIGKILL process ลูกจริงๆ กลางการเขียน แล้วพิสูจน์ว่าสถานะที่กู้กลับมาเป็น prefix ที่ต่อเนื่อง
code ลงมือของคอร์สนี้อยู่ใน repo kaen-kvstore (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราสร้าง key-value store บนเครือข่ายที่กู้คืนจาก crash ได้ หนึ่งตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde, และไม่มี tempfile — temp dir เขียนเองจาก std::env::temp_dir + pid + nanos + AtomicU64) บทนี้ไม่เพิ่ม feature ให้ store — แต่สร้างชุดทดสอบ crash-consistency ที่ยืนยันสัญญา durability จากบท3–4 รวมถึง SIGKILL จริงที่ไม่รัน destructor เลย
crash-consistency คือคุณสมบัติที่ ทดสอบได้
หัวข้อที่มีชื่อว่า “crash-consistency คือคุณสมบัติที่ ทดสอบได้”Crash-consistencycrash-consistencyคุณสมบัติที่ทดสอบได้: crash แล้ว reopen ต้องได้สถานะที่สอดคล้อง คือคุณสมบัติที่ว่า: ถ้า crash เกิดที่จุดใดก็ตาม แล้วเปิด store ขึ้นมาใหม่ สถานะที่มองเห็นต้องสอดคล้อง — ทุก write ที่ store ตอบ Ok (ack) ไปแล้วต้องยังอยู่ และไม่มี write ครึ่งๆ กลางๆ โผล่มาให้เห็น นี่คือเหตุผลที่ crash-consistency เป็นด่านที่ ทดสอบด้วย unit test ได้จริง — เพราะ durability เป็นคุณสมบัติของ node เดียว (single-node): crash คือเหตุการณ์ที่จำลองได้บนเครื่องเดียว เปิด file เดิม replay แล้วเทียบสถานะได้ตรงๆ (ต่างจากความถูกต้องของ distributed consensus ที่ต้องมี network partition, การสลับลำดับ message, clock skew — จำลองใน unit test ไม่ได้ ซึ่งเป็นเหตุผลที่ #23 จะ ถกเถียง เรื่อง replication แทนที่จะ fsync-พิสูจน์มัน)
borrow checker รับประกันว่า code ของคุณไม่มี memory bug และไม่มี data race ในหน่วยความจำ แต่มัน มองไม่เห็น disk — ลำดับ write→fsync→ack จะถูกหรือผิด, torn tail จะถูกทิ้งหรือถูกอ่านผิด, tombstone จะ replay ครบไหม — เรื่องพวกนี้ compiler เงียบสนิท test คือ oracle เดียวที่ตัดสินความถูกต้องระดับ crash ได้ บทนี้จึงไม่ใช่บทเสริม แต่เป็นบทที่ทำให้สัญญาจากบท3–4 เชื่อถือได้
สองชั้นของ test ใน Rust — unit vs integration
หัวข้อที่มีชื่อว่า “สองชั้นของ test ใน Rust — unit vs integration”ก่อนเขียน crash test เราต้องรู้จักโครงสร้าง test สองชั้นของ Rust ตามที่หนังสือ The Rust Programming Language บท 11 §11.3 (“Test Organization”) วางไว้ เพราะ crash test ของเราต้องอยู่ ชั้นที่ถูก:
- unit test — เขียนไว้ ใน file เดียวกับ code หลัง
#[cfg(test)] mod testsmodule นี้ compile เฉพาะตอนcargo test(ไม่ติดไปกับ binary ที่ ship จริง) และ เข้าถึงของ private ได้ เพราะมันเป็น module ลูกของ crate เดียวกัน — เหมาะกับทดสอบ logic ภายในอย่างremoveที่ต้อง idempotent - integration test — อยู่ใน directory
tests/แยกต่างหาก แต่ละ file ถูก compile เป็น crate แยกกัน ที่useเข้ามาผ่าน public API เท่านั้น — มันคือแรงกดดันที่ซื่อสัตย์ต่อ API สาธารณะของKvStore: ถ้าopen/set/getไม่pubก็เขียน integration test ไม่ได้เลย
crash test ของเราจะเป็น integration test — เพราะการจำลอง crash ที่แท้จริงคือ “ผู้ใช้จากภายนอกเปิด store, เขียน, พัง, แล้วเปิดใหม่” ซึ่งเห็นแต่ public API เหมือนผู้ใช้จริงทุกประการ ส่วน logic ภายในเราทดสอบด้วย unit test สั้นๆ ใน file lib.rs:
#[cfg(test)]mod tests { use super::*;
#[test] fn remove_is_idempotent() -> io::Result<()> { let dir = unit_dir(); // temp dir เฉพาะ test นี้ let mut store = KvStore::open(&dir)?; store.set(b"k", b"v")?; assert!(store.remove(b"k")?); // ลบครั้งแรก: key มีอยู่ -> true assert!(!store.remove(b"k")?); // ลบซ้ำ: หายไปแล้ว -> false assert_eq!(store.get(b"k")?, None); std::fs::remove_dir_all(&dir).ok(); Ok(()) }}remove คืน io::Result<bool> — true เมื่อ key เคยมีอยู่ (จึงเขียน tombstone) และ false เมื่อไม่มีอะไรให้ลบ unit test นี้เข้าถึงได้ทั้ง KvStore และตัวช่วย private อย่าง unit_dir() เพราะมันอยู่ใน crate เดียวกัน — สิ่งที่ integration test ทำไม่ได้
สิ่งที่ crash test พิสูจน์ได้ และ พิสูจน์ไม่ได้
หัวข้อที่มีชื่อว่า “สิ่งที่ crash test พิสูจน์ได้ และ พิสูจน์ไม่ได้”ก่อนเขียนตัวจริง ต้องซื่อสัตย์เรื่องขอบเขตของมันก่อน ไม่งั้นเราจะหลอกตัวเอง:
crash test ที่เรากำลังจะเขียน พิสูจน์ ได้ว่า: ลำดับ write→fsync→ack กับตรรกะ replay ประกอบสถานะที่ มองเห็น ขึ้นมาได้เหมือนเดิมทุก byte หลัง process ตาย
แต่มันพิสูจน์ไม่ได้ว่า fsync ผลัก byte ลงจานแม่เหล็กจริง — เพราะ page cache ของ OS อยู่รอดการตายของ process ถ้าเรา SIGKILL process บนเครื่องเดียวกัน ข้อมูลที่ยังไม่ fsync ก็ยังค้างใน page cache แล้ว process ใหม่อ่านกลับมาได้อยู่ดี → store ที่ ลืม เรียก fsync ก็จะ สอบผ่าน บนเครื่องนี้! สิ่งเดียวที่พิสูจน์ durability จริงคือ ตัดไฟ (power-loss) ซึ่งอยู่นอกวิสัยของ unit test
ทวนจากบท3: sync_all บังคับทั้ง data + metadata (fsync), sync_data บังคับ data + เฉพาะ metadata ที่ผู้อ่านต้องใช้ (fdatasync) — สำหรับ append-only log ตัว sync_data พอและถูกกว่า crash test ยืนยัน ตรรกะ ของลำดับ ไม่ใช่ ฟิสิกส์ ของ disk — พูดตรงๆ ไว้เสมอ
version ดิบ: ทดสอบด้วย drop(store)
หัวข้อที่มีชื่อว่า “version ดิบ: ทดสอบด้วย drop(store)”วิธีที่ง่ายที่สุดในการ “จำลอง crash” คือ drop ตัว store ทิ้งโดยไม่ปิดอย่างสุภาพ แล้วเปิดใหม่ replay:
#[test]fn survives_crash_without_clean_shutdown() -> std::io::Result<()> { let dir = unique_temp_dir("crash"); const N: u32 = 1000; { let mut store = KvStore::open(&dir)?; for i in 0..N { store.set(format!("key-{i}").as_bytes(), format!("val-{i}").as_bytes())?; } for i in (0..N).step_by(10) { // ลบทุก key ที่หาร 10 ลงตัว assert!(store.remove(format!("key-{i}").as_bytes())?); } drop(store); // "crash": ไม่มี shutdown สะอาด } { let mut store = KvStore::open(&dir)?; // reopen -> replay จาก log for i in 0..N { let got = store.get(format!("key-{i}").as_bytes())?; if i % 10 == 0 { assert_eq!(got, None); // key ที่ tombstone แล้ว: หายจริง } else { assert_eq!(got.as_deref(), Some(format!("val-{i}").as_bytes())); } } assert_eq!(store.len() as u32, N - N / 10); // 1000 - 100 = 900 key ที่รอด } std::fs::remove_dir_all(&dir).ok(); Ok(())}เขียน 1000 key ลบไป 100 (ทุกตัวที่หาร 10 ลงตัว) drop แล้ว reopen — replay ต้องได้ 900 key พร้อม value เดิมเป๊ะ และ 100 key ที่ tombstone ต้องคืน None นี่เป็น test ที่ดี แต่มันเป็น version ดิบ เพราะ drop(store) จะ valid ในฐานะ crash จำลอง เฉพาะเมื่อ store ไม่มี buffer และ fsync ทุก write — ซึ่งตรงกับ store ของเรา ตอนนี้
นาทีที่บท7 เพิ่ม BufWriter เข้าไปเพื่อรีดสมรรถนะ ตัว Drop ของ BufWriter จะ flush buffer ให้อัตโนมัติตอนถูก drop — แปลว่า test แบบ drop(store) ข้างบนจะ สอบผ่าน แม้กับ byte ที่ crash จริงจะทำหาย (เพราะ crash จริงไม่มีใคร flush buffer ให้) drop รัน destructor แต่ crash จริงไม่รัน destructor test ที่พึ่ง drop อย่างเดียวจึงเป็นตาข่ายที่มีรูโหว่ตามเงื่อนไข — เราต้องมี มาตรฐานทองคำ ที่ไม่รัน destructor เลย
มาตรฐานทองคำ: SIGKILL process จริงกลางการเขียน
หัวข้อที่มีชื่อว่า “มาตรฐานทองคำ: SIGKILL process จริงกลางการเขียน”crash ที่ จริง คือ process ถูกฆ่าทันทีโดยไม่มีโอกาสเก็บกวาด — ไม่มี destructor, ไม่มี flush, ไม่มี Drop วิธีจำลองให้ตรงคือ: spawn process ลูกที่เขียนไม่หยุด แล้วส่ง SIGKILL ใส่มันกลางคัน บน Unix Child::kill() ส่ง SIGKILL ซึ่งระบบปฏิบัติการฆ่า process ทิ้งทันที ไม่มี destructor ใดได้ทำงาน — นี่คือ crash ของจริง ไม่ใช่การจำลอง
process ลูกคือ binary เล็กๆ ใน src/bin/crash_writer.rs — เขียน key-i → val-i วนไปเรื่อยๆ ไม่มีวันจบเอง:
// src/bin/crash_writer.rs — เขียนไม่หยุด, fsync ทุก write, ไม่เคยออกอย่างสะอาดuse kaen_kvstore::KvStore;
fn main() -> std::io::Result<()> { let dir = std::env::args().nth(1).expect("usage: crash_writer <dir>"); let mut store = KvStore::open(std::path::Path::new(&dir))?; let mut i: u64 = 0; loop { store.set(format!("key-{i}").as_bytes(), format!("val-{i}").as_bytes())?; i += 1; }}ฝั่ง test เรียกมันด้วย std::process::Command แล้ว kill ทิ้งหลังปล่อยให้เขียนไปพักหนึ่ง จุดที่ต้องรู้จักคือ env!("CARGO_BIN_EXE_crash_writer") — มาโครที่ cargo เติม path เต็มของ binary พี่น้องให้ตอน compile test เราจึงไม่ต้อง hardcode target/debug/... เอง (ซึ่งจะพังทันทีเมื่อเปลี่ยน target/profile) และ temp dir เราเขียนเองจาก env::temp_dir + pid + nanos + AtomicU64 — ไม่พึ่ง crate tempfile:
// tests/crash_consistency.rs — เป็น crate ของตัวเอง link เฉพาะ public API ของ kaen_kvstoreuse kaen_kvstore::KvStore;use std::path::PathBuf;use std::sync::atomic::{AtomicU64, Ordering};use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_temp_dir(tag: &str) -> PathBuf { // ไม่ใช้ tempfile: pid + nanos + counter อะตอมมิก การันตี path ไม่ชนกัน let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let mut dir = std::env::temp_dir(); dir.push(format!("kaen-kvstore-{tag}-{}-{nanos}-{n}", std::process::id())); dir}
#[test]fn sigkill_midwrite_leaves_recoverable_prefix() -> std::io::Result<()> { use std::process::Command; use std::thread::sleep; use std::time::Duration; let dir = unique_temp_dir("sigkill"); std::fs::create_dir_all(&dir)?; let exe = env!("CARGO_BIN_EXE_crash_writer"); // path ของ binary พี่น้อง จาก cargo let mut child = Command::new(exe).arg(&dir).spawn()?; sleep(Duration::from_millis(400)); // ปล่อยให้เขียนไปสักพัก child.kill()?; // SIGKILL: ไม่มี destructor -> crash จริง let _ = child.wait(); // เก็บศพ process ที่ถูกฆ่า let mut store = KvStore::open(&dir)?; // reopen -> replay let n = store.len() as u64; assert!(n > 0); for i in 0..n { assert_eq!( store.get(format!("key-{i}").as_bytes())?.as_deref(), Some(format!("val-{i}").as_bytes()) // ทุก key ที่กู้ได้ value ถูกต้อง ); } assert_eq!(store.get(format!("key-{n}").as_bytes())?, None); // key ตัวถัดไปที่ยังไม่ทันจบ: ไม่มี std::fs::remove_dir_all(&dir).ok(); Ok(())}หัวใจของ assertion คือ: เพราะ crash_writer เขียน key ตามลำดับ 0, 1, 2, … และ fsync ทุกตัว file ที่เหลือหลังถูกฆ่าคือ prefix ต่อเนื่อง ของ record สมบูรณ์ [0, n) บวก torn tail ที่อาจค้างอยู่ท้าย file ตอน reopen ตัว replay จะ index เฉพาะ record ที่ครบ แล้ว ทิ้ง torn tail (จำจากบท3: read_exact เจอ UnexpectedEof กลาง record → break) ผลคือทุก key-0..key-{n-1} อยู่ครบพร้อม value ถูกต้อง ส่วน key-n (ตัวที่ยังเขียนไม่จบตอนถูกฆ่า) ต้อง ไม่มี — สถานะสอดคล้องเสมอ ไม่ว่ามีดตกลงตรงไหน
ทุก test สร้าง dir เฉพาะของตัวเอง จาก pid + nanos + AtomicU64 แล้วเก็บกวาดด้วย std::fs::remove_dir_all(&dir).ok() ที่ เจาะจง path เดียว — ห้ามใช้ pattern แบบ rm -rf /tmp/kaen-* หรือลบด้วย glob เด็ดขาด เพราะ test อื่นที่รันขนานกันอาจถือ dir ของมันอยู่ การลบแบบกวาดทั้งกอง = เหยียบข้อมูลของ test อื่น ส่วน .ok() ท้ายสุดจงใจกลืน error ตอนลบ เพื่อไม่ให้ความล้มเหลวของการเก็บกวาดไปบดบัง assertion จริงที่เป็นเป้าหมายของ test
sequenceDiagram
participant W as crash_writer (process ลูก)
participant D as file log บน disk
participant T as test (process แม่)
W->>D: set key-0..key-(n-1) (write_all + fsync ทุกตัว)
T->>W: Child::kill() = SIGKILL
Note over W: ถูกฆ่ากลางเขียน key-n<br/>ไม่มี destructor ทำงาน
T->>D: KvStore::open() -> replay
Note over T,D: index เฉพาะ record ที่ครบ [0,n)<br/>ทิ้ง torn tail (UnexpectedEof)
T->>T: assert key-0..key-(n-1) ครบ + value ถูก
T->>T: assert key-n = None
คำบรรยายภาพ: การทดสอบ crash-consistency ด้วย SIGKILL จริง — process ลูกเขียนแล้ว fsync ทีละ record จนถูกฆ่ากลางคัน (destructor ไม่ทำงาน) process แม่ reopen แล้ว replay ได้ prefix ต่อเนื่อง [0,n) พร้อมทิ้ง torn tail
test ปิดท้าย: torn tail ต้องถูกทิ้ง
หัวข้อที่มีชื่อว่า “test ปิดท้าย: torn tail ต้องถูกทิ้ง”ยังเหลืออีกกรณีที่ต้องตอกให้แน่น — crash ที่เกิด กลางการเขียน record เดียว (ไม่ใช่ระหว่าง record) เราจำลองมันตรงๆ ด้วยการเขียน record สมบูรณ์หนึ่งตัวให้ durable ก่อน แล้ว ต่อท้ายด้วย header ขยะที่อ้างว่ามี value ยาว แต่ไม่มี byte ตามมา:
#[test]fn torn_tail_after_durable_write_is_discarded() -> std::io::Result<()> { use std::io::Write; let dir = unique_temp_dir("torn"); { let mut store = KvStore::open(&dir)?; store.set(b"alpha", b"one")?; // record สมบูรณ์ + durable หนึ่งตัว } { let path = dir.join("kvstore.log"); let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; f.write_all(&5u32.to_le_bytes())?; // key_len = 5 f.write_all(&9999u32.to_le_bytes())?; // val_len = 9999 (ไม่มีวันมาถึง) f.write_all(b"beta")?; // tail ที่ขาดครึ่ง f.sync_all()?; } { let mut store = KvStore::open(&dir)?; assert_eq!(store.get(b"alpha")?.as_deref(), Some(b"one".as_ref())); assert_eq!(store.len(), 1); // ทิ้ง torn tail, record durable รอด } std::fs::remove_dir_all(&dir).ok(); Ok(())}replay เจอ header ที่บอกว่าจะมี value 9999 byte แต่ read_exact อ่านได้ไม่ครบ → UnexpectedEof → break → torn tail ถูกทิ้ง record alpha ที่ durable ก่อนหน้ารอด และ len() == 1 นี่คือคุณสมบัติ truncation-tolerant ที่ length prefix + read_exact มอบให้ (ย้ำจากบท3: มัน ไม่ ทน silent bit-flip ซึ่งต้องใช้ CRC — งานของ Bitcask/production)
รันชุดทดสอบจริง
หัวข้อที่มีชื่อว่า “รันชุดทดสอบจริง”รันด้วย musl target (recipe เดิมจากทั้งคอร์ส เพราะ sandbox ไม่มี linker cc):
LLD=$(find ~/.rustup -name rust-lld | head -1)RUSTFLAGS="-Clink-self-contained=yes -Clinker-flavor=ld.lld -Clinker=$LLD" \ cargo test --target x86_64-unknown-linux-muslผลรันจริงบนเครื่องนี้:
Running unittests src/lib.rsrunning 1 testtest tests::remove_is_idempotent ... oktest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests/crash_consistency.rsrunning 3 teststest torn_tail_after_durable_write_is_discarded ... oktest sigkill_midwrite_leaves_recoverable_prefix ... oktest survives_crash_without_clean_shutdown ... oktest result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.79sเขียวครบ — 1 unit + 3 integration รวม SIGKILL จริงที่ใช้เวลาราว 2.8 วินาที (ส่วนใหญ่คือ 400 ms ที่ปล่อยให้ลูกเขียน คูณจำนวน test ที่มี child) สังเกตว่า crash_writer ปรากฏเป็น “unittests” ที่รัน 0 test — เพราะมันเป็น binary ที่ไม่มี #[test] ในตัวเอง compile ผ่านก็พอ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เราไม่เพิ่ม feature ให้ store แม้แต่บรรทัดเดียว — แต่ทำสิ่งที่สำคัญกว่า: เปลี่ยนคำสัญญา durability จากบท3–4 ให้เป็น oracle ที่รันซ้ำได้ เรารู้จักสองชั้นของ test (unit หลัง #[cfg(test)] เข้าถึง private ได้ vs integration ใน tests/ ที่กดดัน public API — Book ch11 §11.3); เห็นว่า drop(store) เป็น version ดิบ ที่จะโกหกทันทีที่บท7 ใส่ BufWriter; และสร้าง มาตรฐานทองคำ ด้วย SIGKILL จริงผ่าน std::process::Command + Child::kill() ที่ไม่รัน destructor เลย ค้นหา binary พี่น้องด้วย env!("CARGO_BIN_EXE_...") และเก็บกวาด temp dir แบบเจาะจง remove_dir_all(&dir).ok() — ไม่มี glob และเหนือสิ่งอื่นใด เราซื่อสัตย์ว่า test เขียวพิสูจน์ ตรรกะ ของ replay ไม่ใช่ว่า fsync แตะจานจริง (page cache อยู่รอดการตายของ process — พิสูจน์ durability จริงต้องตัดไฟ)
บท7 เราจะวัดราคาของ durability: ตอนนี้เรามั่นใจว่า store ถูกต้อง แล้ว คำถามถัดไปคือ เร็วแค่ไหน — เราจะเขียน benchmark harness เองด้วย std::time::Instant (ไม่ใช้ criterion) วัด latencylatencyเวลาต่อ1 operation รายงานเป็น percentile p50/p95/p99 เป็น percentile p50/p95/p99 และ throughput แล้วจะเห็นตัวเลขที่ชวนสะดุด: ทำไม throughput ถึงได้แค่หลักร้อยต่อวินาที? คำตอบคือภาษี fsync ที่เราจ่ายทุก write — และนั่นคือสะพานสู่บทสุดท้าย
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch11 “Writing Automated Tests”, §11.3 “Test Organization” (เข้าถึง 2026-07-24) — unit test หลัง
#[cfg(test)] mod tests(เข้าถึง private ได้) vs integration test ในtests/(แต่ละ file เป็น crate แยก link เฉพาะ public API) - Database Internals — ch5 Transaction Processing & Recovery (Alex Petrov, 2019) (เข้าถึง 2026-07-24) — durability เป็น ordering rule (
write→fsync→ack); crash recovery / redo จาก log - Designing Data-Intensive Applications — ch3 Storage & Retrieval (Martin Kleppmann, 2017) (เข้าถึง 2026-07-24) — crash recovery ด้วยการ replay log, tombstone, การตรวจ partial write
- std
fs::File::sync_all/sync_data(เข้าถึง 2026-07-24) —sync_all= fsync (data + metadata),sync_data= fdatasync; บาง platform ทำsync_dataเป็นsync_all
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3ตามหนังสือ The Rust Programming Language บท 11 §11.3 unit test กับ integration test ต่างกันอย่างไร และทำไม crash test ของเราจึงเป็น integration test?