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

วิธีเขียนเทส

เทส คือฟังก์ชัน Rust ที่ตรวจสอบว่าโค้ดส่วนที่ไม่ใช่เทสทำงานในแบบที่ คาดหวัง body ของฟังก์ชันเทสโดยทั่วไปทำสามอย่างนี้:

  • เตรียมข้อมูลหรือ state ที่จำเป็น
  • รันโค้ดที่คุณต้องการทดสอบ
  • assert ว่าผลลัพธ์ตรงกับที่คุณคาดหวัง

มาดูฟีเจอร์ที่ Rust ให้มาเฉพาะสำหรับเขียนเทสที่ทำสามขั้นตอนนี้ ซึ่งรวมถึง attribute test, มาโครจำนวนหนึ่ง และ attribute should_panic

โครงสร้างของฟังก์ชันเทส

ง่ายที่สุดเลย เทสใน Rust คือฟังก์ชันที่ถูก annotate ด้วย attribute test Attribute คือ metadata เกี่ยวกับชิ้นส่วนของโค้ด Rust ตัวอย่างหนึ่งคือ attribute derive ที่เราใช้กับ struct ในบทที่ 5 เพื่อเปลี่ยนฟังก์ชันให้ เป็นฟังก์ชันเทส เพิ่ม #[test] ในบรรทัดก่อน fn เมื่อคุณรันเทสด้วยคำสั่ง cargo test Rust จะ build binary test runner ที่รันฟังก์ชันที่ annotate และรายงานว่าแต่ละฟังก์ชันเทสผ่านหรือไม่ผ่าน

ทุกครั้งที่เราสร้างโปรเจกต์ library ใหม่ด้วย Cargo จะมีโมดูลเทสพร้อม ฟังก์ชันเทสในนั้นถูกสร้างอัตโนมัติให้เรา โมดูลนี้ให้ template สำหรับเขียน เทสของคุณ เพื่อให้คุณไม่ต้องค้นหาโครงสร้างและ syntax ที่แน่นอนทุกครั้งที่ เริ่มโปรเจกต์ใหม่ คุณเพิ่มฟังก์ชันเทสและโมดูลเทสเพิ่มเติมได้มากเท่าที่ ต้องการ!

เราจะสำรวจบางแง่มุมของวิธีทำงานของเทส โดยทดลองกับเทสจาก template ก่อนที่ เราจะทดสอบโค้ดจริง จากนั้นเราจะเขียนเทสจริงที่เรียกโค้ดที่เราเขียนและ assert ว่าพฤติกรรมของมันถูกต้อง

มาสร้างโปรเจกต์ library ใหม่ชื่อ adder ที่จะบวกตัวเลขสองตัว:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

เนื้อหาของไฟล์ src/lib.rs ใน library adder ของคุณควรเหมือน Listing 11-1

Filename: src/lib.rs
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);
    }
}
Listing 11-1: โค้ดที่ถูกสร้างอัตโนมัติโดย cargo new

ไฟล์เริ่มด้วยฟังก์ชัน add ตัวอย่าง เพื่อให้เรามีอะไรไว้ทดสอบ

ตอนนี้ มาโฟกัสเฉพาะที่ฟังก์ชัน it_works สังเกต annotation #[test] attribute นี้บอกว่านี่คือฟังก์ชันเทส ดังนั้น test runner รู้ว่าควรปฏิบัติ กับฟังก์ชันนี้เป็นเทส เราอาจมีฟังก์ชันที่ไม่ใช่เทสในโมดูล tests ด้วย เพื่อช่วยตั้งค่า scenario ทั่วไปหรือทำ operation ทั่วไป ดังนั้นเราจึง ต้องระบุเสมอว่าฟังก์ชันใดเป็นเทส

body ของฟังก์ชันตัวอย่างใช้มาโคร assert_eq! เพื่อ assert ว่า result ซึ่งมีผลลัพธ์ของการเรียก add ด้วย 2 และ 2 เท่ากับ 4 assertion นี้เป็น ตัวอย่าง format ของเทสทั่วไป มาลองรันดูว่าเทสนี้ผ่านไหม

คำสั่ง 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

Listing 11-2: output จากการรันเทสที่ถูกสร้างอัตโนมัติ

Cargo คอมไพล์และรันเทส เราเห็นบรรทัด running 1 test บรรทัดถัดมาแสดง ชื่อของฟังก์ชันเทสที่ถูกสร้าง ชื่อ tests::it_works และผลของการรันเทส นั้นคือ ok สรุปรวม test result: ok. แปลว่าเทสทั้งหมดผ่าน และส่วนที่ อ่านว่า 1 passed; 0 failed รวมจำนวนเทสที่ผ่านและไม่ผ่าน

เราทำเครื่องหมายเทสว่า ignored ได้ เพื่อไม่ให้มันรันใน instance นั้น เราจะครอบคลุมในส่วน “Ignore เทสยกเว้นถูกร้องขอเฉพาะ” ภายในบทนี้ เพราะเราไม่ได้ทำเช่นนั้นที่นี่ สรุปจึงแสดง 0 ignored เรายัง ส่งอาร์กิวเมนต์ให้คำสั่ง cargo test เพื่อรันเฉพาะเทสที่ชื่อตรงกับ string ได้ นี่เรียกว่า filtering และเราจะครอบคลุมในส่วน “รันเทสบางส่วนตามชื่อ” ที่นี่เราไม่ได้ filter เทสที่กำลังรัน ดังนั้นท้ายสุดของสรุปแสดง 0 filtered out

สถิติ 0 measured ใช้สำหรับ benchmark test ที่วัด performance เทส benchmark ณ ตอนเขียนนี้ ใช้ได้เฉพาะใน nightly Rust ดู เอกสารเกี่ยวกับ benchmark test เพื่อเรียนรู้เพิ่ม

ส่วนถัดไปของ output เทสที่เริ่มที่ Doc-tests adder คือผลของ documentation test เรายังไม่มี documentation test แต่ Rust คอมไพล์ ตัวอย่างโค้ดใดก็ตามที่ปรากฏใน API documentation ของเราได้ ฟีเจอร์นี้ช่วยให้ docs และโค้ดของคุณ sync กัน! เราจะพูดถึงวิธีเขียน documentation test ใน ส่วน “Documentation Comment เป็นเทส” ของ บทที่ 14 ตอนนี้ เราจะ ignore output 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 อีก output ตอนนี้แสดง 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

ตอนนี้เราจะเพิ่มเทสอีกตัว แต่คราวนี้เราจะสร้างเทสที่ fail! เทส fail เมื่อ อะไรบางอย่างในฟังก์ชันเทส panic แต่ละเทสรันในเธรดใหม่ และเมื่อเธรดหลัก เห็นว่าเธรดเทสตายไป เทสจะถูกทำเครื่องหมายว่า fail ในบทที่ 9 เราพูดถึงว่า วิธีง่ายที่สุดในการ panic คือเรียกมาโคร panic! ใส่เทสใหม่เป็นฟังก์ชัน ชื่อ another เพื่อให้ไฟล์ src/lib.rs ของคุณดูเหมือน Listing 11-3

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);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: เพิ่มเทสที่สองที่จะ fail เพราะเราเรียกมาโคร panic!

รันเทสอีกครั้งด้วย cargo test output ควรดูเหมือน Listing 11-4 ซึ่งแสดง ว่าเทส exploration ของเราผ่าน และ another fail

$ 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' 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`
Listing 11-4: ผลเทสเมื่อเทสหนึ่งผ่านและเทสหนึ่ง fail

แทนที่จะเป็น ok บรรทัด test tests::another แสดง FAILED มีสองส่วน ใหม่ปรากฏระหว่างผลแต่ละตัวกับสรุป — ส่วนแรกแสดงเหตุผลละเอียดของแต่ละ failure ของเทส ในกรณีนี้ เราได้รายละเอียดว่า tests::another fail เพราะ มัน panic ด้วยข้อความ Make this test fail ที่บรรทัด 17 ในไฟล์ src/lib.rs ส่วนถัดไป list เพียงชื่อของเทสทั้งหมดที่ fail ซึ่งมีประโยชน์ เมื่อมีเทสจำนวนมากและ output เทสที่ fail แบบละเอียดจำนวนมาก เราใช้ชื่อ ของเทสที่ fail เพื่อรันเฉพาะเทสนั้นเพื่อ debug ง่ายขึ้นได้ เราจะพูด เพิ่มเติมเกี่ยวกับวิธีรันเทสในส่วน “ควบคุมว่าจะรันเทสอย่างไร”

บรรทัดสรุปแสดงที่ท้ายสุด — โดยรวม ผลเทสของเราคือ FAILED เรามีเทสหนึ่ง ผ่านและหนึ่ง fail

ตอนนี้คุณเห็นว่าผลเทสดูเป็นยังไงใน scenario ต่าง ๆ มาดูมาโครอื่น ๆ นอกจาก panic! ที่มีประโยชน์ในเทส

ตรวจสอบผลลัพธ์ด้วย assert!

มาโคร assert! ที่ standard library ให้มา มีประโยชน์เมื่อคุณต้องการ แน่ใจว่าเงื่อนไขบางอย่างในเทสประเมินเป็น true เราให้มาโคร assert! อาร์กิวเมนต์ที่ประเมินเป็น Boolean ถ้าค่าเป็น true ไม่มีอะไรเกิดขึ้น และเทสผ่าน ถ้าค่าเป็น false มาโคร assert! เรียก panic! เพื่อทำให้ เทส fail การใช้มาโคร assert! ช่วยให้เราตรวจสอบว่าโค้ดของเราทำงานในแบบ ที่เราตั้งใจ

ในบทที่ 5 Listing 5-15 เราใช้ struct Rectangle และเมธอด can_hold ซึ่งถูกนำมาแสดงอีกครั้งที่นี่ใน Listing 11-5 มาวางโค้ดนี้ในไฟล์ src/lib.rs จากนั้นเขียนเทสบางตัวสำหรับมันด้วยมาโคร assert!

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
    }
}
Listing 11-5: struct Rectangle และเมธอด can_hold จากบทที่ 5

เมธอด can_hold return Boolean ซึ่งหมายความว่ามันเป็น use case ที่ สมบูรณ์แบบสำหรับมาโคร assert! ใน Listing 11-6 เราเขียนเทสที่ใช้งาน เมธอด can_hold โดยสร้าง instance Rectangle ที่มีความกว้าง 8 และ ความสูง 7 และ assert ว่ามันสามารถบรรจุ instance Rectangle อีกตัวที่ มีความกว้าง 5 และความสูง 1 ได้

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() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: เทสสำหรับ can_hold ที่ตรวจสอบว่า rectangle ที่ใหญ่กว่าสามารถบรรจุ rectangle ที่เล็กกว่าได้จริงไหม

สังเกตบรรทัด use super::*; ในโมดูล tests โมดูล tests เป็นโมดูล ปกติที่ตามกฎ visibility ทั่วไปที่เราครอบคลุมในบทที่ 7 ส่วน “Path สำหรับอ้างถึง item ใน Module Tree” เพราะโมดูล tests เป็นโมดูลภายใน เราต้องนำโค้ดที่อยู่ใต้การทดสอบใน โมดูลภายนอกเข้า scope ของโมดูลภายใน เราใช้ glob ที่นี่ ดังนั้นอะไรที่ เรานิยามในโมดูลภายนอกจะใช้ได้ในโมดูล tests นี้

เราตั้งชื่อเทสของเราว่า larger_can_hold_smaller และเราสร้าง instance Rectangle สองตัวที่เราต้องการ จากนั้น เราเรียกมาโคร assert! และส่ง ผลของการเรียก larger.can_hold(&smaller) ให้มัน expression นี้ควร return 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

ผ่าน! มาเพิ่มเทสอีกตัว คราวนี้ assert ว่า rectangle ที่เล็กกว่าบรรจุ rectangle ที่ใหญ่กว่าไม่ได้:

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 เรา ต้องเปลี่ยนผลนั้นเป็นค่าตรงข้ามก่อนส่งให้มาโคร assert! ผลคือเทสของ เราจะผ่านถ้า can_hold return 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 ใน โค้ดของเรา เราจะเปลี่ยน implementation ของเมธอด 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' 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`

เทสของเราจับ bug ได้! เพราะ larger.width คือ 8 และ smaller.width คือ 5 การเปรียบเทียบความกว้างใน can_hold ตอนนี้ return false — 8 ไม่น้อยกว่า 5

ทดสอบความเท่ากันด้วย assert_eq! และ assert_ne!

วิธีทั่วไปในการตรวจสอบ functionality คือทดสอบความเท่ากันระหว่างผลของ โค้ดที่อยู่ใต้การทดสอบและค่าที่คุณคาดว่าโค้ดจะ return คุณทำสิ่งนี้ได้ โดยใช้มาโคร assert! และส่ง expression ที่ใช้ operator == ให้ อย่างไร ก็ตาม นี่เป็นเทสที่ทั่วไปมาก standard library จึงให้คู่มาโคร — assert_eq! และ assert_ne! — เพื่อทำเทสนี้สะดวกขึ้น มาโครเหล่านี้ เปรียบเทียบสองอาร์กิวเมนต์ว่าเท่ากันหรือไม่เท่ากันตามลำดับ พวกมันยัง print ทั้งสองค่าถ้า assertion fail ซึ่งทำให้ง่ายขึ้นที่จะเห็นว่า ทำไม เทสจึง fail ในทางตรงกันข้าม มาโคร assert! ระบุเพียงว่ามันได้ค่า false สำหรับ expression == โดยไม่ print ค่าที่นำไปสู่ค่า false

ใน Listing 11-7 เราเขียนฟังก์ชันชื่อ add_two ที่บวก 2 กับ parameter จากนั้นเราทดสอบฟังก์ชันนี้ด้วยมาโคร assert_eq!

Filename: src/lib.rs
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);
    }
}
Listing 11-7: ทดสอบฟังก์ชัน add_two ด้วยมาโคร assert_eq!

มาตรวจดูว่ามันผ่าน!

$ 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! บรรทัด output สำหรับเทสนี้คือ test tests::it_adds_two ... ok และข้อความ ok ระบุว่าเทสของเราผ่าน!

มาใส่ bug ในโค้ดของเราเพื่อดูว่า assert_eq! ดูเป็นยังไงเมื่อ fail เปลี่ยน implementation ของฟังก์ชัน 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' 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`

เทสของเราจับ bug ได้! เทส tests::it_adds_two fail และข้อความบอกเรา ว่า assertion ที่ fail คือ left == right และค่าของ left กับ right คืออะไร ข้อความนี้ช่วยให้เราเริ่ม debug — อาร์กิวเมนต์ left ที่เรามี ซึ่งเป็นผลของการเรียก add_two(2) คือ 5 แต่อาร์กิวเมนต์ right คือ 4 คุณ นึกภาพได้ว่านี่จะช่วยมากเป็นพิเศษเมื่อเรามีเทสจำนวนมาก

สังเกตว่าในบางภาษาและ test framework parameter ของฟังก์ชัน assertion ความเท่ากันถูกเรียก expected และ actual และลำดับที่เราระบุอาร์กิวเมนต์ มีความสำคัญ อย่างไรก็ตาม ใน Rust พวกมันถูกเรียก left และ right และ ลำดับที่เราระบุค่าที่เราคาดหวังกับค่าที่โค้ดสร้าง ไม่สำคัญ เราเขียน assertion ในเทสนี้เป็น assert_eq!(4, result) ก็ได้ ซึ่งจะให้ข้อความ failure แบบเดียวกันที่แสดง assertion `left == right` failed

มาโคร assert_ne! จะผ่านถ้าสองค่าที่เราให้ไม่เท่ากัน และจะ fail ถ้า พวกมันเท่ากัน มาโครนี้มีประโยชน์มากที่สุดสำหรับกรณีที่เราไม่แน่ใจว่าค่า จะ เป็นอะไร แต่เรารู้แน่ ๆ ว่าค่านั้น_ไม่ควร_ เป็นอะไร ตัวอย่างเช่น ถ้าเรากำลังทดสอบฟังก์ชันที่รับประกันว่าจะเปลี่ยน input ของมันในบางวิธี แต่วิธีที่ input ถูกเปลี่ยนขึ้นกับวันของสัปดาห์ที่เรารันเทส สิ่งที่ดี ที่สุดในการ assert อาจเป็นว่า output ของฟังก์ชันไม่เท่ากับ input

ภายใต้ผิว มาโคร assert_eq! และ assert_ne! ใช้ operator == และ != ตามลำดับ เมื่อ assertion fail มาโครเหล่านี้ print อาร์กิวเมนต์ ของพวกมันโดยใช้ debug formatting ซึ่งหมายความว่าค่าที่ถูกเปรียบเทียบ ต้อง implement trait PartialEq และ Debug type primitive ทั้งหมด และ type ส่วนใหญ่ใน standard library implement trait เหล่านี้ สำหรับ struct และ enum ที่คุณนิยามเอง คุณต้อง implement PartialEq เพื่อ assert ความเท่ากันของ type เหล่านั้น คุณจะต้อง implement Debug ด้วย เพื่อ print ค่าเมื่อ assertion fail เพราะ trait ทั้งสองเป็น trait ที่ derive ได้ ตามที่กล่าวถึงใน Listing 5-12 ในบทที่ 5 โดยทั่วไปแล้ว นี่ง่ายแค่เพิ่ม annotation #[derive(PartialEq, Debug)] ในนิยามของ struct หรือ enum ของคุณ ดู Appendix C “Derivable Trait” สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ trait เหล่านี้และ derivable trait อื่น

เพิ่มข้อความ Failure แบบกำหนดเอง

คุณยังเพิ่มข้อความกำหนดเองที่จะ print พร้อมข้อความ failure ในฐานะ อาร์กิวเมนต์ที่เป็น optional ให้มาโคร assert!, assert_eq! และ assert_ne! ได้ อาร์กิวเมนต์ใดที่ระบุหลังอาร์กิวเมนต์ที่ต้องการ จะถูก ส่งต่อไปยังมาโคร format! (พูดถึงใน “ต่อสตริงด้วย + หรือ format! ในบทที่ 8) ดังนั้นคุณส่ง format string ที่มี placeholder {} และค่า ที่จะใส่ใน placeholder เหล่านั้นได้ ข้อความกำหนดเองมีประโยชน์สำหรับ documenting ว่า assertion หมายความว่าอะไร เมื่อเทส fail คุณจะมีภาพ ดีกว่าว่าปัญหากับโค้ดคืออะไร

ตัวอย่างเช่น สมมติเรามีฟังก์ชันที่ทักทายคนตามชื่อ และเราต้องการทดสอบ ว่าชื่อที่เราส่งเข้าฟังก์ชันปรากฏใน output:

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 ที่จุดเริ่มต้นของการทักทายจะเปลี่ยน เราตัดสินใจว่าเราไม่อยาก ต้อง update เทสเมื่อข้อกำหนดเปลี่ยน ดังนั้นแทนที่จะตรวจสอบความเท่ากัน แน่นอนกับค่าที่ return จากฟังก์ชัน greeting เราจะแค่ assert ว่า output มีข้อความของ parameter input

ตอนนี้มาใส่ bug ในโค้ดนี้โดยเปลี่ยน greeting ให้ตัด name ออก เพื่อ ดูว่าข้อความ failure เทสเริ่มต้นดูเป็นยังไง:

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' 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`

ผลนี้แค่ระบุว่า assertion fail และ assertion อยู่บรรทัดไหน ข้อความ failure ที่มีประโยชน์มากกว่าจะ print ค่าจากฟังก์ชัน greeting มาเพิ่ม ข้อความ failure กำหนดเองที่ประกอบด้วย format string พร้อม placeholder ที่เติมด้วยค่าจริงที่เราได้จากฟังก์ชัน 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}`"
        );
    }
}

ตอนนี้เมื่อเรารันเทส เราจะได้ข้อความ error ที่ informative มากขึ้น:

$ 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' 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`

เราเห็นค่าที่เราได้จริงใน output เทส ซึ่งจะช่วยให้เรา debug ว่าเกิดอะไร ขึ้นแทนที่จะเป็นสิ่งที่เราคาดหวังให้เกิดขึ้น

ตรวจสอบ Panic ด้วย should_panic

นอกจากการตรวจสอบค่าที่ return สำคัญที่จะตรวจสอบว่าโค้ดของเราจัดการ เงื่อนไข error ตามที่เราคาดหวัง ตัวอย่างเช่น พิจารณา type Guess ที่ เราสร้างในบทที่ 9 Listing 9-13 โค้ดอื่นที่ใช้ Guess ขึ้นกับการรับ ประกันว่า instance Guess จะมีเฉพาะค่าระหว่าง 1 และ 100 เราเขียนเทส ที่ทำให้แน่ใจว่าการพยายามสร้าง instance Guess ด้วยค่านอกช่วงนั้น panic ได้

เราทำสิ่งนี้โดยเพิ่ม attribute should_panic ให้ฟังก์ชันเทสของเรา เทสผ่านถ้าโค้ดในฟังก์ชัน panic เทส fail ถ้าโค้ดในฟังก์ชันไม่ panic

Listing 11-8 แสดงเทสที่ตรวจสอบว่าเงื่อนไข error ของ Guess::new เกิด ขึ้นเมื่อเราคาดหวัง

Filename: src/lib.rs
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);
    }
}
Listing 11-8: ทดสอบว่าเงื่อนไขจะทำให้เกิด panic!

เราวาง 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

ดูดี! ตอนนี้มาใส่ bug ในโค้ดของเราโดยลบเงื่อนไขที่ฟังก์ชัน 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 มันจะ fail:

$ 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`

เราไม่ได้ข้อความที่มีประโยชน์มากในกรณีนี้ แต่เมื่อเรามองดูฟังก์ชันเทส เราเห็นว่ามัน annotated ด้วย #[should_panic] failure ที่เราได้แปลว่า โค้ดในฟังก์ชันเทสไม่ทำให้เกิด panic

เทสที่ใช้ should_panic อาจไม่แม่นยำ เทส should_panic ก็จะผ่าน แม้ว่าเทสจะ panic ด้วยเหตุผลต่างจากที่เราคาดหวัง เพื่อทำให้เทส should_panic แม่นยำขึ้น เราเพิ่ม parameter expected ที่เป็น optional ให้ attribute should_panic ได้ test harness จะทำให้แน่ใจ ว่าข้อความ failure มี text ที่ให้มา ตัวอย่างเช่น พิจารณาโค้ดที่แก้ไข สำหรับ Guess ใน Listing 11-9 ที่ฟังก์ชัน new panic ด้วยข้อความ ต่างกันขึ้นกับว่าค่าเล็กไปหรือใหญ่ไป

Filename: src/lib.rs
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);
    }
}
Listing 11-9: ทดสอบ panic! ด้วยข้อความ panic ที่มี substring ที่ระบุ

เทสนี้จะผ่านเพราะค่าที่เราใส่ใน parameter expected ของ attribute should_panic เป็น substring ของข้อความที่ฟังก์ชัน Guess::new panic ด้วย เราระบุข้อความ panic ทั้งหมดที่เราคาดหวังได้ ซึ่งในกรณีนี้คือ Guess value must be less than or equal to 100, got 200 สิ่งที่คุณ เลือกระบุขึ้นกับว่ามากเท่าไรของข้อความ panic ที่ unique หรือ dynamic และคุณต้องการให้เทสของคุณแม่นยำเพียงใด ในกรณีนี้ substring ของข้อความ panic เพียงพอที่จะแน่ใจว่าโค้ดในฟังก์ชันเทสทำงานในกรณี else if value > 100

เพื่อดูว่าเกิดอะไรขึ้นเมื่อเทส should_panic ที่มีข้อความ expected fail มาใส่ bug ในโค้ดของเราอีกครั้งโดยสลับ body ของ block 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 มันจะ fail:

$ 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' 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`

ข้อความ failure ระบุว่าเทสนี้ panic ตามที่เราคาดหวังจริง แต่ข้อความ panic ไม่ได้รวม string less than or equal to 100 ที่เราคาดหวัง ข้อความ panic ที่เราได้ในกรณีนี้คือ Guess value must be greater than or equal to 1, got 200 ตอนนี้ เราเริ่มหาว่า bug ของเราอยู่ที่ไหนได้!

ใช้ Result<T, E> ในเทส

เทสทั้งหมดของเราจนถึงตอนนี้ panic เมื่อพวกมัน fail เรายังเขียนเทสที่ ใช้ Result<T, E> ได้! นี่คือเทสจาก Listing 11-1 ที่เขียนใหม่ให้ใช้ Result<T, E> และ return 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 ตอนนี้มี return type Result<(), String> ใน body ของฟังก์ชัน แทนที่จะเรียกมาโคร assert_eq! เรา return Ok(()) เมื่อ เทสผ่าน และ Err พร้อม String ข้างในเมื่อเทส fail

การเขียนเทสให้พวกมัน return Result<T, E> ทำให้คุณใช้ question mark operator ใน body ของเทสได้ ซึ่งเป็นวิธีสะดวกในการเขียนเทสที่ควร fail ถ้า operation ใดภายในพวกมัน return variant Err

คุณใช้ annotation #[should_panic] กับเทสที่ใช้ Result<T, E> ไม่ได้ ในการ assert ว่า operation return variant Err อย่า ใช้ question mark operator กับค่า Result<T, E> แต่ให้ใช้ assert!(value.is_err()) แทน

ตอนนี้คุณรู้หลายวิธีในการเขียนเทสแล้ว มาดูว่าเกิดอะไรขึ้นเมื่อเรารัน เทส และสำรวจตัวเลือกต่าง ๆ ที่เราใช้กับ cargo test ได้