วิธีการเขียนการทดสอบ (How to Write Tests)
การทดสอบ (Tests) คือฟังก์ชัน Rust ที่ทำหน้าที่ตรวจสอบว่าโค้ดที่ไม่ใช่ส่วนของการทดสอบทำงานได้อย่างถูกต้องตามที่คาดหวังไว้ โดยทั่วไป ตัวฟังก์ชันทดสอบจะดำเนินการ 3 อย่างนี้:
- จัดเตรียมข้อมูลหรือสถานะ (state) ที่จำเป็น
- รันโค้ดที่คุณต้องการทดสอบ
- ยืนยัน (Assert) ว่าผลลัพธ์เป็นไปตามที่คุณคาดหวัง
เรามาดูฟีเจอร์ที่ Rust ให้มาโดยเฉพาะสำหรับการเขียนการทดสอบ ซึ่งประกอบด้วย attribute test, มาโครบางตัว และ attribute should_panic
การจัดโครงสร้างฟังก์ชันการทดสอบ (Structuring Test Functions)
ในรูปแบบที่เรียบง่ายที่สุด การทดสอบใน Rust คือฟังก์ชันที่ถูกระบุด้วย attribute test สำหรับ attributes นั้นคือเมทาดาตา (metadata) เกี่ยวกับชิ้นส่วนโค้ด Rust ตัวอย่างหนึ่งคือ attribute derive ที่เราใช้กับสตรักต์ในบทที่ 5 การเปลี่ยนฟังก์ชันให้กลายเป็นฟังก์ชันการทดสอบ ทำได้โดยการเพิ่ม #[test] ในบรรทัดก่อนหน้า fn เมื่อคุณรันการทดสอบด้วยคำสั่ง cargo test ตัว Rust จะบิลด์ไบนารีทดสอบ (test runner binary) ที่จะเรียกใช้ฟังก์ชันที่มี attribute ดังกล่าวและรายงานว่าฟังก์ชันการทดสอบแต่ละตัวผ่าน (pass) หรือล้มเหลว (fail)
เมื่อใดก็ตามที่เราสร้างโปรเจกต์ไลบรารีใหม่ด้วย Cargo โมดูลทดสอบพร้อมกับฟังก์ชันการทดสอบภายในโมดูลจะถูกสร้างขึ้นให้เราโดยอัตโนมัติ โมดูลนี้จะเป็นเทมเพลตสำหรับการเขียนการทดสอบของคุณ เพื่อที่คุณจะได้ไม่ต้องคอยค้นหาโครงสร้างและไวยากรณ์ที่ถูกต้องทุกครั้งที่เริ่มโปรเจกต์ใหม่ คุณสามารถเพิ่มฟังก์ชันการทดสอบและโมดูลการทดสอบเพิ่มเติมได้มากเท่าที่คุณต้องการ!
เราจะสำรวจการทำงานของการทดสอบโดยการทดลองกับเทมเพลตการทดสอบ ก่อนที่เราจะทดสอบโค้ดจริง จากนั้น เราจะเขียนการทดสอบในโลกจริงเพื่อเรียกใช้โค้ดที่เราเขียนขึ้นและตรวจสอบว่าพฤติกรรมของมันถูกต้อง
ลองสร้างโปรเจกต์ไลบรารีใหม่ชื่อ adder ที่จะทำหน้าที่บวกเลขสองจำนวน:
$ cargo new adder --lib
Created library `adder` project
$ cd adder
เนื้อหาของไฟล์ src/lib.rs ในไลบรารี adder ของคุณควรจะมีหน้าตาเหมือน Listing 11-1
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
ไฟล์นี้เริ่มต้นด้วยฟังก์ชันตัวอย่าง add เพื่อให้เรามีบางอย่างไว้ทดสอบ
สำหรับตอนนี้ ให้เรามุ่งเน้นไปที่ฟังก์ชัน it_works สังเกตการระบุ #[test]: attribute นี้ระบุว่านี่คือฟังก์ชันการทดสอบ เพื่อให้ตัวรันการทดสอบทราบว่าต้องปฏิบัติต่อฟังก์ชันนี้ในฐานะการทดสอบ เราอาจมีฟังก์ชันที่ไม่ใช่การทดสอบอยู่ในโมดูล tests เพื่อช่วยตั้งค่าสถานการณ์ทั่วไปหรือดำเนินการทั่วไป ดังนั้นเราจึงจำเป็นต้องระบุเสมอว่าฟังก์ชันใดเป็นการทดสอบ
ตัวอย่างเนื้อหาฟังก์ชันใช้มาโคร assert_eq! เพื่อตรวจสอบว่า result ซึ่งบรรจุผลลัพธ์จากการเรียก add ด้วย 2 และ 2 นั้นเท่ากับ 4 หรือไม่ การยืนยันนี้เป็นตัวอย่างรูปแบบการทดสอบทั่วไป ลองรันมันเพื่อดูว่าการทดสอบนี้ผ่านหรือไม่
คำสั่ง cargo test จะรันการทดสอบทั้งหมดในโปรเจกต์ของเรา ดังแสดงใน Listing 11-2
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo ได้คอมไพล์และรันการทดสอบแล้ว เราจะเห็นบรรทัด running 1 test บรรทัดถัดไปแสดงชื่อของฟังก์ชันการทดสอบที่สร้างขึ้นคือ tests::it_works และผลลัพธ์ของการรันการทดสอบนั้นคือ ok สรุปโดยรวม test result: ok. หมายความว่าการทดสอบทั้งหมดผ่าน และส่วนที่อ่านว่า 1 passed; 0 failed เป็นการรวมจำนวนการทดสอบที่ผ่านและล้มเหลว
เราสามารถทำเครื่องหมายข้ามการทดสอบ (ignored) เพื่อไม่ให้รันในบางกรณีได้ ซึ่งเราจะพูดถึงในหัวข้อ “การข้ามการทดสอบเว้นแต่จะมีการร้องขอเป็นพิเศษ” ในบทนี้ เนื่องจากเรายังไม่ได้ทำเช่นนั้นในที่นี้ สรุปจึงแสดงเป็น 0 ignored เรายังสามารถส่งอาร์กิวเมนต์ไปยังคำสั่ง cargo test เพื่อรันเฉพาะการทดสอบที่มีชื่อตรงกับข้อความที่กำหนดได้ ซึ่งเรียกว่า การกรอง (filtering) และเราจะพูดถึงในหัวข้อ “การรันส่วนย่อยของการทดสอบตามชื่อ” ในที่นี้เราไม่ได้กรองการทดสอบที่จะรัน ดังนั้นตอนท้ายของสรุปจึงแสดง 0 filtered out
สถิติ 0 measured สำหรับการทดสอบเปรียบเทียบประสิทธิภาพ (benchmark tests) ซึ่งใช้วัดประสิทธิภาพการทำงาน การทดสอบวัดประสิทธิภาพ ณ ขณะที่เขียนนี้ มีให้ใช้งานใน nightly Rust เท่านั้น ดู เอกสารเกี่ยวกับการทดสอบ benchmark เพื่อเรียนรู้เพิ่มเติม
ส่วนถัดไปของผลลัพธ์การทดสอบที่เริ่มต้นด้วย Doc-tests adder คือผลลัพธ์ของการทดสอบเอกสาร (documentation tests) เรายังไม่มีการทดสอบเอกสารใดๆ แต่ Rust สามารถคอมไพล์ตัวอย่างโค้ดที่ปรากฏในเอกสาร API ของเราได้ ฟีเจอร์นี้ช่วยให้เอกสารและโค้ดของคุณสอดคล้องกันอยู่เสมอ! เราจะหารือถึงวิธีเขียนการทดสอบเอกสารในหัวข้อ “ความคิดเห็นเอกสารในฐานะการทดสอบ” ของบทที่ 14 สำหรับตอนนี้ เราจะข้ามผลลัพธ์ของ Doc-tests ไปก่อน
ลองเริ่มปรับแต่งการทดสอบให้ตรงกับความต้องการของเรา ขั้นแรก ให้เปลี่ยนชื่อฟังก์ชัน it_works เป็นชื่ออื่น เช่น exploration ดังนี้:
Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
จากนั้น รัน cargo test อีกครั้ง ตอนนี้ผลลัพธ์จะแสดง exploration แทนที่จะเป็น it_works:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
ตอนนี้เราจะเพิ่มการทดสอบอีกตัวหนึ่ง แต่ครั้งนี้เราจะทำให้การทดสอบล้มเหลว! การทดสอบจะล้มเหลวเมื่อมีบางอย่างในฟังก์ชันการทดสอบเกิด panic การทดสอบแต่ละตัวจะถูกรันในเธรดใหม่ และเมื่อเธรดหลักพบว่าเธรดทดสอบได้ตายลง การทดสอบนั้นจะถูกทำเครื่องหมายว่าล้มเหลว ในบทที่ 9 เราได้พูดถึงวิธีที่ง่ายที่สุดในการทำให้เกิด panic นั่นคือการเรียกใช้มาโคร panic! ให้ใส่การทดสอบใหม่เป็นฟังก์ชันชื่อ another เพื่อให้ไฟล์ src/lib.rs ของคุณมีหน้าตาเหมือน Listing 11-3
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
รันการทดสอบอีกครั้งโดยใช้ cargo test ผลลัพธ์ควรจะเหมือนกับ Listing 11-4 ซึ่งแสดงว่าการทดสอบ exploration ผ่าน และ another ล้มเหลว
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' (6019162) panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
แทนที่จะแสดง ok บรรทัด test tests::another กลับแสดง FAILED มีสองส่วนใหม่ปรากฏขึ้นระหว่างผลลัพธ์ย่อยและผลสรุป: ส่วนแรกแสดงเหตุผลโดยละเอียดของความล้มเหลวในการทดสอบแต่ละรายการ ในกรณีนี้ เราได้รายละเอียดว่า tests::another ล้มเหลวเนื่องจากเกิด panic พร้อมข้อความ Make this test fail ที่บรรทัด 17 ในไฟล์ src/lib.rs ส่วนถัดไปจะแสดงเฉพาะชื่อของการทดสอบทั้งหมดที่ล้มเหลว ซึ่งมีประโยชน์เมื่อมีรายการทดสอบและผลลัพธ์การทดสอบที่ล้มเหลวอย่างละเอียดจำนวนมาก เราสามารถใช้ชื่อของการทดสอบที่ล้มเหลวเพื่อรันเฉพาะการทดสอบนั้นเพื่อดีบักได้ง่ายขึ้น เราจะพูดเพิ่มเติมเกี่ยวกับวิธีรันการทดสอบในหัวข้อ “การควบคุมวิธีรันการทดสอบ”
บรรทัดสรุปแสดงอยู่ที่ตอนท้าย: โดยรวมแล้ว ผลการทดสอบของเราคือ FAILED เรามีการทดสอบผ่านหนึ่งรายการและล้มเหลวหนึ่งรายการ
เมื่อคุณได้เห็นว่าผลการทดสอบมีลักษณะอย่างไรในสถานการณ์ต่างๆ แล้ว เรามาดูมาโครอื่นๆ นอกเหนือจาก panic! ที่มีประโยชน์ในการทดสอบกัน
การตรวจสอบผลลัพธ์ด้วย assert!
มาโคร assert! ที่จัดสรรโดยไลบรารีมาตรฐาน มีประโยชน์เมื่อคุณต้องการตรวจสอบว่าเงื่อนไขบางอย่างในการทดสอบประเมินค่าออกมาเป็น true หรือไม่ เราส่งอาร์กิวเมนต์ที่ประเมินค่าเป็นบูลีน (Boolean) ให้กับมาโคร assert! หากค่านั้นเป็น true จะไม่มีอะไรเกิดขึ้นและการทดสอบจะผ่าน หากค่านั้นเป็น false มาโคร assert! จะเรียก panic! เพื่อทำให้การทดสอบล้มเหลว การใช้มาโคร assert! ช่วยให้เราตรวจสอบได้ว่าโค้ดของเราทำงานในรูปแบบที่เราตั้งใจไว้หรือไม่
ในบทที่ 5 Listing 5-15 เราใช้สตรักต์ Rectangle และเมธอด can_hold ซึ่งถูกนำมาแสดงซ้ำใน Listing 11-5 ให้เรานำโค้ดนี้ใส่ในไฟล์ src/lib.rs แล้วเขียนการทดสอบโดยใช้มาโคร assert!
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
เมธอด can_hold คืนค่าเป็นบูลีน ซึ่งหมายความว่ามันเป็นกรณีการใช้งานที่สมบูรณ์แบบสำหรับมาโคร assert! ใน Listing 11-6 เราได้เขียนการทดสอบเพื่อใช้เมธอด can_hold โดยสร้างอินสแตนซ์ของ Rectangle ที่มีความกว้าง 8 และความสูง 7 และยืนยันว่าสามารถบรรจุอีกอินสแตนซ์หนึ่งของ Rectangle ที่มีความกว้าง 5 และความสูง 1 ได้
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
สังเกตบรรทัด use super::*; ภายในโมดูล tests โมดูล tests เป็นโมดูลปกติที่ปฏิบัติตามกฎการมองเห็น (visibility rules) ตามปกติที่เราได้ครอบคลุมไปในบทที่ 7 ในหัวข้อ “พาธสำหรับอ้างอิงถึงรายการในโครงสร้างโมดูล” เนื่องจากโมดูล tests เป็นโมดูลภายใน เราจึงต้องนำโค้ดที่ต้องการทดสอบในโมดูลภายนอกเข้ามายังขอบเขต (scope) ของโมดูลภายใน เราใช้ glob (*) ในที่นี้ เพื่อให้ทุกอย่างที่เรานิยามไว้ในโมดูลภายนอกพร้อมใช้งานในโมดูล tests นี้
เราได้ตั้งชื่อการทดสอบของเราว่า larger_can_hold_smaller และเราได้สร้างอินสแตนซ์ Rectangle สองตัวที่ต้องการ จากนั้นเราเรียกใช้มาโคร assert! และส่งผลลัพธ์ของการเรียก larger.can_hold(&smaller) นิพจน์นี้ควรคืนค่า true ดังนั้นการทดสอบของเราจึงควรผ่าน มาลองดูกัน!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
มันผ่านจริงด้วย! ลองเพิ่มการทดสอบอีกตัว โดยคราวนี้ยืนยันว่าสี่เหลี่ยมผืนผ้าที่เล็กกว่าไม่สามารถบรรจุสี่เหลี่ยมผืนผ้าที่ใหญ่กว่าได้:
Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
// --snip--
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
เนื่องจากผลลัพธ์ที่ถูกต้องของฟังก์ชัน can_hold ในกรณีนี้คือ false เราจึงต้องกลับค่า (negate) ผลลัพธ์นั้นก่อนที่จะส่งไปยังมาโคร assert! เป็นผลให้การทดสอบของเราจะผ่านหาก can_hold คืนค่าเป็น false:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
สองการทดสอบผ่านแล้ว! ตอนนี้มาดูกันว่าจะเกิดอะไรขึ้นกับผลการทดสอบเมื่อเราใส่บั๊ก (bug) เข้าไปในโค้ด เราจะเปลี่ยนการทำงานของเมธอด can_hold โดยแทนที่เครื่องหมายมากกว่า (>) ด้วยเครื่องหมายน้อยกว่า (<) เมื่อทำการเปรียบเทียบความกว้าง:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --snip--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
การรันการทดสอบตอนนี้จะได้ผลลัพธ์ดังนี้:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' (6020788) panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
การทดสอบของเราดักจับบั๊กได้! เนื่องจาก larger.width คือ 8 และ smaller.width คือ 5 การเปรียบเทียบความกว้างใน can_hold จึงคืนค่า false: 8 ไม่ได้น้อยกว่า 5
การทดสอบความเท่ากันด้วยมาโคร assert_eq! และ assert_ne!
วิธีทั่วไปในการตรวจสอบฟังก์ชันการทำงานคือการทดสอบความเท่ากันระหว่างผลลัพธ์ของโค้ดที่อยู่ภายใต้การทดสอบกับค่าที่คุณคาดหวังว่าโค้ดจะคืนกลับมา คุณสามารถทำได้โดยใช้มาโคร assert! และส่งนิพจน์ที่ใช้ตัวดำเนินการ == อย่างไรก็ตาม เรื่องนี้เป็นรูปแบบการทดสอบที่พบบ่อยมาก ไลบรารีมาตรฐานจึงจัดเตรียมคู่มาโครได้แก่ assert_eq! และ assert_ne! เพื่อดำเนินการทดสอบนี้อย่างสะดวกยิ่งขึ้น มาโครเหล่านี้จะเปรียบเทียบอาร์กิวเมนต์สองตัวว่าเท่ากันหรือไม่เท่ากันตามลำดับ นอกจากนี้ยังพิมพ์ค่าทั้งสองออกมารายงานหากการยืนยันล้มเหลว ซึ่งช่วยให้เห็นได้ง่ายขึ้นว่า ทำไม การทดสอบจึงล้มเหลว ในทางกลับกัน มาโคร assert! จะระบุเพียงแค่ว่าได้รับค่า false สำหรับนิพจน์ == โดยไม่ได้พิมพ์ค่าที่นำไปสู่ค่า false นั้นออกมา
ใน Listing 11-7 เราเขียนฟังก์ชันชื่อ add_two ที่บวกค่า 2 ให้กับพารามิเตอร์ และทดสอบฟังก์ชันนี้โดยใช้มาโคร assert_eq!
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
มาตรวจสอบกันว่ามันผ่านหรือไม่!
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
เราสร้างตัวแปรชื่อ result ที่เก็บผลลัพธ์ของการเรียก add_two(2) จากนั้นเราส่ง result และ 4 เป็นอาร์กิวเมนต์ให้กับมาโคร assert_eq! บรรทัดผลลัพธ์สำหรับการทดสอบนี้คือ test tests::it_adds_two ... ok และข้อความ ok ระบุว่าการทดสอบของเราผ่าน!
ลองเพิ่มบั๊กเข้าไปในโค้ดของเรา เพื่อดูว่า assert_eq! เป็นอย่างไรเมื่อล้มเหลว เปลี่ยนการทำงานของฟังก์ชัน add_two ให้กลายเป็นบวกด้วย 3 แทน:
pub fn add_two(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
รันการทดสอบอีกครั้ง:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' (6020955) panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
การทดสอบของเราดักจับบั๊กได้! การทดสอบ tests::it_adds_two ล้มเหลว และข้อความแจ้งเตือนเราว่าการยืนยันที่ล้มเหลวคือ left == right และแสดงค่าของ left และ right ข้อความนี้ช่วยให้เราเริ่มดีบักได้: อาร์กิวเมนต์ทางด้านซ้าย (left) ซึ่งเราได้รับผลลัพธ์จากการเรียก add_two(2) คือ 5 แต่อาร์กิวเมนต์ทางด้านขวา (right) คือ 4 คุณสามารถจินตนาการได้เลยว่าสิ่งนี้มีประโยชน์อย่างยิ่งเมื่อเรามีการทดสอบจำนวนมากที่กำลังรันอยู่
พึงสังเกตว่าในบางภาษาและเฟรมเวิร์กการทดสอบ พารามิเตอร์ของฟังก์ชันการยืนยันความเท่ากันจะเรียกว่า expected (สิ่งที่คาดหวัง) และ actual (สิ่งที่ได้จริง) และลำดับการระบุอาร์กิวเมนต์จะมีความสำคัญ อย่างไรก็ตาม ใน Rust จะเรียกว่า left และ right และลำดับในการระบุค่าที่เราคาดหวังกับค่าที่โค้ดสร้างขึ้นนั้นไม่มีความแตกต่างกัน เราสามารถเขียนการยืนยันในการทดสอบนี้เป็น assert_eq!(4, result) ซึ่งจะส่งผลให้ได้ข้อความแสดงความล้มเหลวแบบเดียวกันว่า assertion `left == right` failed
มาโคร assert_ne! จะผ่านก็ต่อเมื่อค่าสองค่าที่เราส่งให้ไม่เท่ากัน และจะล้มเหลวหากค่าทั้งสองเท่ากัน มาโครนี้มีประโยชน์ที่สุดสำหรับกรณีที่เราไม่แน่ใจว่าค่านั้น จะเป็น อะไร แต่เรารู้แน่ชัดว่าค่านั้น ไม่ควรเป็น อะไร ตัวอย่างเช่น หากเรากำลังทดสอบฟังก์ชันที่รับประกันว่าจะเปลี่ยนแปลงอินพุตในบางทาง แต่วิธีการเปลี่ยนแปลงอินพุตขึ้นอยู่กับวันในสัปดาห์ที่เราทำการรันการทดสอบ สิ่งที่ดีที่สุดในการยืนยันอาจเป็นว่าผลลัพธ์ของฟังก์ชันไม่เท่ากับอินพุต
เบื้องหลังการทำงาน มาโคร assert_eq! และ assert_ne! ใช้ตัวดำเนินการ == และ != ตามลำดับ เมื่อการยืนยันล้มเหลว มาโครเหล่านี้จะพิมพ์อาร์กิวเมนต์โดยใช้การจัดรูปแบบดีบัก (debug formatting) ซึ่งหมายความว่าค่าที่นำมาเปรียบเทียบจะต้องปรับใช้ trait PartialEq และ Debug ชนิดข้อมูลพื้นฐานทั้งหมดและชนิดข้อมูลส่วนใหญ่ในไลบรารีมาตรฐานมีการ implement trait เหล่านี้แล้ว สำหรับสตรักต์และเอเนิมที่คุณกำหนดขึ้นเอง คุณจะต้อง implement PartialEq เพื่อยืนยันความเท่ากันของชนิดข้อมูลเหล่านั้น คุณจะต้อง implement Debug เพื่อพิมพ์ค่าเมื่อการยืนยันล้มเหลวด้วย เนื่องจากทั้งสอง trait เป็น trait ที่สามารถ derive ได้ ดังที่ได้กล่าวไว้ใน Listing 5-12 ในบทที่ 5 โดยทั่วไปจึงทำได้ง่ายๆ เพียงเพิ่ม attribute #[derive(PartialEq, Debug)] ลงในคำนิยามสตรักต์หรือเอเนิมของคุณ ดูภาคผนวก C “Derivable Traits” สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ trait เหล่านี้และ trait ที่ derive ได้อื่นๆ
การเพิ่มข้อความความล้มเหลวแบบกำหนดเอง (Adding Custom Failure Messages)
คุณยังสามารถเพิ่มข้อความแบบกำหนดเองให้พิมพ์ออกมาพร้อมกับข้อความแสดงความล้มเหลวได้ โดยระบุเป็นอาร์กิวเมนต์เสริมให้กับมาโคร assert!, assert_eq!, และ assert_ne! อาร์กิวเมนต์ใดๆ ที่ระบุตามหลังอาร์กิวเมนต์ที่จำเป็นจะถูกส่งต่อไปยังมาโคร format! (ซึ่งพูดถึงใน “การเชื่อมต่อข้อความด้วย + หรือ format!” ในบทที่ 8) ดังนั้นคุณจึงสามารถส่งสตริงรูปแบบที่มีตัวแทน {} และค่าที่จะใส่ลงในตัวแทนเหล่านั้นได้ ข้อความแบบกำหนดเองมีประโยชน์สำหรับการอธิบายความหมายของการยืนยัน เมื่อการทดสอบล้มเหลว คุณจะเข้าใจสาเหตุของปัญหาในโค้ดได้ดียิ่งขึ้น
ตัวอย่างเช่น สมมติว่าเรามีฟังก์ชันที่ทักทายผู้คนด้วยชื่อ และเราต้องการทดสอบว่าชื่อที่เราส่งไปยังฟังก์ชันปรากฏในผลลัพธ์หรือไม่:
Filename: src/lib.rs
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
ข้อกำหนดของโปรแกรมนี้ยังไม่ได้ตกลงกันอย่างเป็นทางการ และเราค่อนข้างแน่ใจว่าข้อความ Hello ที่จุดเริ่มต้นของคำทักทายอาจมีการเปลี่ยนแปลง เราตัดสินใจว่าเราไม่อยากคอยอัปเดตการทดสอบเมื่อข้อกำหนดเปลี่ยน ดังนั้นแทนที่จะตรวจสอบความเท่ากันกับค่าที่ส่งกลับจากฟังก์ชัน greeting แบบเป๊ะๆ เราจะเพียงแค่ยืนยันว่าผลลัพธ์มีข้อความของพารามิเตอร์อินพุตอยู่หรือไม่
คราวนี้ลองใส่บั๊กในโค้ดนี้โดยเปลี่ยน greeting ให้เว้น name ออกไป เพื่อดูว่าข้อความความล้มเหลวของการทดสอบเริ่มต้นมีหน้าตาอย่างไร:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
การรันการทดสอบนี้จะได้ผลลัพธ์ดังนี้:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (6021143) panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
ผลลัพธ์นี้เพียงแค่ระบุว่าการยืนยันล้มเหลวและอยู่ที่บรรทัดใด ข้อความความล้มเหลวที่มีประโยชน์กว่าควรจะพิมพ์ค่าที่ได้จากฟังก์ชัน greeting ออกมา ลองเพิ่มข้อความความล้มเหลวแบบกำหนดเองที่ประกอบด้วยสตริงรูปแบบที่มีตัวแทนซึ่งเติมด้วยค่าจริงที่เราได้รับจากฟังก์ชัน greeting:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
คราวนี้เมื่อเรารันการทดสอบ เราจะได้ข้อความข้อผิดพลาดที่มีข้อมูลที่เป็นประโยชน์มากขึ้น:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (6021333) panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
เราสามารถเห็นค่าที่เราได้รับจริงในการทดสอบ ซึ่งช่วยให้เราดีบักสิ่งเกิดขึ้นได้ แทนที่จะเห็นเฉพาะสิ่งที่เราคาดหวังให้เกิดขึ้น
การตรวจสอบการเกิด Panic ด้วย should_panic
นอกเหนือจากการตรวจสอบค่าที่คืนกลับมาแล้ว การตรวจสอบว่าโค้ดของเราจัดการกับเงื่อนไขข้อผิดพลาดตามที่เราคาดหวังหรือไม่ก็เป็นสิ่งสำคัญ ตัวอย่างเช่น พิจารณาประเภท Guess ที่เราสร้างขึ้นในบทที่ 9 Listing 9-13 โค้ดอื่นๆ ที่ใช้ Guess จะพึ่งพาการรับประกันว่าอินสแตนซ์ของ Guess จะมีค่าระหว่าง 1 ถึง 100 เท่านั้น เราสามารถเขียนการทดสอบเพื่อรับประกันว่าการพยายามสร้างอินสแตนซ์ Guess ด้วยค่านอกช่วงนั้นจะเกิด panic
เราทำได้โดยการเพิ่ม attribute should_panic ลงในฟังก์ชันการทดสอบของเรา การทดสอบจะผ่านหากโค้ดภายในฟังก์ชันเกิด panic การทดสอบจะล้มเหลวหากโค้ดภายในฟังก์ชันไม่เกิด panic
Listing 11-8 แสดงการทดสอบที่ตรวจสอบว่าเงื่อนไขข้อผิดพลาดของ Guess::new เกิดขึ้นตามที่เราคาดหวังไว้หรือไม่
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
เราวาง attribute #[should_panic] ไว้หลัง attribute #[test] และก่อนฟังก์ชันการทดสอบที่มันนำไปใช้ มาดูผลลัพธ์เมื่อการทดสอบนี้ผ่าน:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
ดูดีทีเดียว! ตอนนี้ลองใส่บั๊กในโค้ดของเราโดยลบเงื่อนไขที่ฟังก์ชัน new จะ panic หากค่ามากกว่า 100 ออกไป:
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
เมื่อเรารันการทดสอบใน Listing 11-8 การทดสอบจะล้มเหลว:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
เราไม่ได้ข้อความที่มีประโยชน์มากนักในกรณีนี้ แต่เมื่อเราดูที่ฟังก์ชันการทดสอบ เราจะเห็นว่ามันถูกระบุด้วย #[should_panic] ความล้มเหลวที่เราได้รับหมายความว่าโค้ดในฟังก์ชันการทดสอบไม่ได้ทำให้เกิด panic
การทดสอบที่ใช้ should_panic อาจขาดความแม่นยำได้ การทดสอบ should_panic จะผ่านแม้ว่าการทดสอบจะเกิด panic ด้วยเหตุผลอื่นที่ต่างจากสิ่งที่เราคาดหวัง เพื่อให้การทดสอบ should_panic มีความแม่นยำมากขึ้น เราสามารถเพิ่มพารามิเตอร์เสริม expected ให้กับ attribute should_panic ได้ ตัวรันการทดสอบจะตรวจสอบว่าข้อความความล้มเหลวมีข้อความที่กำหนดอยู่หรือไม่ ตัวอย่างเช่น พิจารณาโค้ดดัดแปลงสำหรับ Guess ใน Listing 11-9 ซึ่งฟังก์ชัน new จะ panic ด้วยข้อความที่ต่างกันขึ้นอยู่กับว่าค่านั้นเล็กเกินไปหรือใหญ่เกินไป
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
การทดสอบนี้จะผ่านเนื่องจากค่าที่เราใส่ในพารามิเตอร์ expected ของ attribute should_panic เป็นส่วนหนึ่งของข้อความที่ฟังก์ชัน Guess::new เกิด panic เราอาจระบุข้อความ panic ทั้งหมดที่เราคาดหวังก็ได้ ซึ่งในกรณีนี้คือ Guess value must be less than or equal to 100, got 200 สิ่งที่คุณเลือกที่จะระบุจะขึ้นอยู่กับว่าข้อความ panic มีความเฉพาะตัวหรือเป็นแบบไดนามิกมากน้อยเพียงใด และคุณต้องการให้การทดสอบของคุณแม่นยำแค่ไหน ในกรณีนี้ สตริงย่อยของข้อความ panic เพียงพอที่จะรับประกันว่าโค้ดในฟังก์ชันการทดสอบทำงานในกรณี else if value > 100
เพื่อดูว่าจะเกิดอะไรขึ้นเมื่อการทดสอบ should_panic พร้อมข้อความ expected ล้มเหลว ให้เราลองใส่บั๊กในโค้ดอีกครั้งโดยการสลับเนื้อหาของบล็อก if value < 1 และ else if value > 100:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
คราวนี้เมื่อเรารันการทดสอบ should_panic การทดสอบจะล้มเหลว:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' (6021675) panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Guess value must be greater than or equal to 1, got 200."
expected substring: "less than or equal to 100"
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
ข้อความความล้มเหลวระบุว่าการทดสอบนี้เกิด panic จริงตามที่เราคาดไว้ แต่ข้อความ panic ไม่ได้มีสตริงที่คาดหวังคือ less than or equal to 100 ข้อความ panic ที่เราได้รับจริงในกรณีนี้คือ Guess value must be greater than or equal to 1, got 200 ตอนนี้เราก็สามารถเริ่มหาสาเหตุว่าบั๊กของเราอยู่ที่ไหนได้แล้ว!
การใช้ Result<T, E> ในการทดสอบ
การทดสอบทั้งหมดของเราจนถึงตอนนี้จะเกิด panic เมื่อล้มเหลว เรายังสามารถเขียนการทดสอบที่ใช้ Result<T, E> ได้ด้วย! นี่คือการทดสอบจาก Listing 11-1 ที่ถูกเขียนใหม่ให้ใช้ Result<T, E> และคืนค่า Err แทนการเกิด panic:
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
ตอนนี้ฟังก์ชัน it_works มีประเภทการคืนค่าเป็น Result<(), String> ภายในตัวฟังก์ชัน แทนที่จะเรียกใช้มาโคร assert_eq! เราจะคืนค่า Ok(()) เมื่อการทดสอบผ่าน และคืนค่า Err ที่บรรจุ String ไว้ภายในเมื่อการทดสอบล้มเหลว
การเขียนการทดสอบให้คืนค่า Result<T, E> ช่วยให้คุณสามารถใช้ตัวดำเนินการเครื่องหมายคำถาม (?) ในเนื้อหาของการทดสอบได้ ซึ่งเป็นวิธีที่สะดวกในการเขียนการทดสอบที่ควรล้มเหลวหากการทำงานใดๆ ภายในทดสอบคืนค่าออกมาเป็นประเภท Err
คุณไม่สามารถใช้ attribute #[should_panic] กับการทดสอบที่ใช้ Result<T, E> ได้ หากต้องการยืนยันว่าการทำงานนั้นคืนค่าประเภท Err อย่า ใช้ตัวดำเนินการเครื่องหมายคำถามกับค่า Result<T, E> แต่ให้ใช้ assert!(value.is_err()) แทน
เมื่อคุณได้เรียนรู้วิธีการเขียนการทดสอบหลายๆ แบบแล้ว เรามาดูกันว่าจะเกิดอะไรขึ้นเมื่อเรารันการทดสอบ และสำรวจตัวเลือกต่างๆ ที่เราสามารถใช้กับ cargo test ได้