Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

วิธีการเขียนการทดสอบ (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 ได้