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

การจัดโครงสร้างการทดสอบ (Test Organization)

ดังที่ได้กล่าวไว้ในตอนต้นของบทนี้ การทดสอบเป็นศาสตร์ที่มีความซับซ้อน และแต่ละคนใช้ศัพท์และวิธีการจัดโครงสร้างที่แตกต่างกัน ชุมชน Rust มองการทดสอบออกเป็นสองหมวดหมู่หลัก: การทดสอบหน่วย (unit tests) และการทดสอบรวมระบบ (integration tests) การทดสอบหน่วย (Unit tests) มีขนาดเล็กและมุ่งเน้นการทดสอบโมดูลเดียวแบบแยกส่วน ณ เวลาหนึ่งๆ และสามารถทดสอบอินเทอร์เฟซส่วนตัว (private interfaces) ได้ การทดสอบรวมระบบ (Integration tests) เป็นการทดสอบภายนอกไลบรารีของคุณโดยสิ้นเชิง และใช้โค้ดของคุณในรูปแบบเดียวกับที่โค้ดภายนอกอื่นๆ จะใช้งาน โดยใช้เฉพาะอินเทอร์เฟซสาธารณะ (public interface) และอาจประมวลผลหลายโมดูลต่อหนึ่งการทดสอบ

การเขียนการทดสอบทั้งสองประเภทมีความสำคัญเพื่อให้แน่ใจว่าแต่ละชิ้นส่วนของไลบรารีของคุณทำหน้าที่ตามที่คุณคาดหวัง ทั้งแบบแยกส่วนและทำงานร่วมกัน

การทดสอบหน่วย (Unit Tests)

วัตถุประสงค์ของการทดสอบหน่วยคือการทดสอบแต่ละหน่วยของโค้ดแบบแยกออกจากส่วนที่เหลือ เพื่อชี้จุดได้อย่างรวดเร็วว่าโค้ดส่วนใดทำงานและส่วนใดไม่ทำงานตามที่คาดหวัง คุณจะวางการทดสอบหน่วยไว้ในไดเรกทอรี src ในแต่ละไฟล์ที่มีโค้ดที่ต้องการทดสอบ แนวทางปฏิบัติทั่วไปคือการสร้างโมดูลชื่อ tests ในแต่ละไฟล์เพื่อบรรจุฟังก์ชันการทดสอบ และกำกับโมดูลด้วย cfg(test)

โมดูล tests และ #[cfg(test)]

การระบุ #[cfg(test)] บนโมดูล tests บอกให้ Rust คอมไพล์และรันโค้ดทดสอบเฉพาะเมื่อคุณรัน cargo test ไม่ใช่ตอนที่รัน cargo build สิ่งนี้ช่วยประหยัดเวลาในการคอมไพล์เมื่อคุณต้องการบิลด์เฉพาะไลบรารี และช่วยประหยัดพื้นที่ในไฟล์ไบนารีผลลัพธ์ (compiled artifact) เนื่องจากไม่ได้รวมการทดสอบเข้าไปด้วย คุณจะเห็นว่าการทดสอบรวมระบบอยู่ในไดเรกทอรีที่ต่างออกไป ดังนั้นจึงไม่จำเป็นต้องระบุ #[cfg(test)] อย่างไรก็ตาม เนื่องจากทดสอบหน่วยอยู่ในไฟล์เดียวกับโค้ด คุณจึงต้องใช้ #[cfg(test)] เพื่อระบุว่าไม่ควรรวมพวกมันไว้ในผลลัพธ์การคอมไพล์

ย้อนกลับไปเมื่อตอนที่เราสร้างโปรเจกต์ adder ใหม่ในหัวข้อแรกของบทนี้ Cargo ได้สร้างโค้ดนี้ให้กับเรา:

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

ในโมดูล tests ที่ถูกสร้างขึ้นโดยอัตโนมัติ attribute cfg ย่อมาจาก configuration (การกำหนดค่า) และบอก Rust ว่ารายการถัดไปควรรวมอยู่เฉพาะเมื่อมีการกำหนดค่าตัวเลือกตามที่ระบุ ในกรณีนี้ ตัวเลือกการกำหนดค่าคือ test ซึ่ง Rust ให้ไว้สำหรับการคอมไพล์และรันการทดสอบ การใช้ attribute cfg ทำให้ Cargo คอมไพล์โค้ดทดสอบของเราก็ต่อเมื่อเรารันการทดสอบด้วย cargo test อย่างตั้งใจเท่านั้น สิ่งนี้นับรวมถึงฟังก์ชันช่วยเหลือ (helper functions) ใดๆ ที่อาจอยู่ภายในโมดูลนี้ เพิ่มเติมจากฟังก์ชันที่ระบุด้วย #[test]

การทดสอบฟังก์ชันส่วนตัว (Private Function Tests)

มีการถกเถียงกันในชุมชนการทดสอบว่าควรจะทดสอบฟังก์ชันส่วนตัว (private functions) โดยตรงหรือไม่ และภาษาอื่นๆ ทำให้การทดสอบฟังก์ชันส่วนตัวทำได้ยากหรือเป็นไปไม่ได้ ไม่ว่าคุณจะยึดถือแนวคิดการทดสอบแบบใด กฎความเป็นส่วนตัวของ Rust อนุญาตให้คุณทดสอบฟังก์ชันส่วนตัวได้ พิจารณาโค้ดใน Listing 11-12 ที่มีฟังก์ชันส่วนตัว internal_adder

pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}

สังเกตว่าฟังก์ชัน internal_adder ไม่ได้ระบุเป็น pub การทดสอบเป็นเพียงโค้ด Rust และโมดูล tests ก็เป็นเพียงอีกโมดูลหนึ่ง ดังที่เราได้หารือในหัวข้อ “พาธสำหรับอ้างอิงถึงรายการในโครงสร้างโมดูล” รายการในโมดูลลูกสามารถใช้รายการในโมดูลบรรพบุรุษได้ ในการทดสอบนี้ เรานำรายการทั้งหมดที่เป็นของโมดูลแม่ของ tests เข้ามาสู่ขอบเขตด้วย use super::* จากนั้นการทดสอบจึงสามารถเรียก internal_adder ได้ หากคุณคิดว่าไม่ควรทดสอบฟังก์ชันส่วนตัว ก็ไม่มีอะไรใน Rust ที่จะบังคับให้คุณต้องทำเช่นนั้น

การทดสอบรวมระบบ (Integration Tests)

ใน Rust การทดสอบรวมระบบเป็นการทดสอบภายนอกไลบรารีของคุณโดยสิ้นเชิง พวกมันใช้ไลบรารีของคุณในรูปแบบเดียวกับที่โค้ดอื่นๆ จะใช้งาน ซึ่งหมายความว่าพวกมันสามารถเรียกใช้ได้เฉพาะฟังก์ชันที่เป็นส่วนหนึ่งของ public API ของไลบรารีของคุณเท่านั้น วัตถุประสงค์ของการทดสอบรวมระบบคือเพื่อทดสอบว่าหลายๆ ส่วนของไลบรารีทำงานร่วมกันได้อย่างถูกต้องหรือไม่ หน่วยของโค้ดที่ทำงานได้ถูกต้องด้วยตัวมันเองอาจมีปัญหาได้เมื่อนำมารวมกัน ดังนั้นความครอบคลุมของการทดสอบ (test coverage) สำหรับโค้ดที่นำมารวมกันจึงมีความสำคัญเช่นกัน ในการสร้างการทดสอบรวมระบบ ก่อนอื่นคุณต้องมีไดเรกทอรี tests

ไดเรกทอรี tests

เราสร้างไดเรกทอรี tests ไว้ที่ระดับบนสุดของไดเรกทอรีโปรเจกต์ ถัดจาก src Cargo รู้อยู่แล้วว่าจะมองหาไฟล์การทดสอบรวมระบบในไดเรกทอรีนี้ จากนั้นเราจะสร้างไฟล์ทดสอบกี่ไฟล์ก็ได้ตามต้องการ และ Cargo จะคอมไพล์แต่ละไฟล์เป็น crate แยกกัน

มาลองสร้างการทดสอบรวมระบบกัน โดยมีโค้ดใน Listing 11-12 อยู่ในไฟล์ src/lib.rs ให้สร้างไดเรกทอรี tests และสร้างไฟล์ใหม่ชื่อ tests/integration_test.rs โครงสร้างไดเรกทอรีของคุณควรมีหน้าตาดังนี้:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

ป้อนโค้ดใน Listing 11-13 ลงในไฟล์ tests/integration_test.rs

use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}

แต่ละไฟล์ในไดเรกทอรี tests เป็น crate แยกกัน ดังนั้นเราจึงต้องนำไลบรารีของเราเข้าสู่ขอบเขตของ crate ทดสอบแต่ละตัว ด้วยเหตุนี้เราจึงเพิ่ม use adder::add_two; ไว้ที่ด้านบนของโค้ด ซึ่งเราไม่จำเป็นต้องทำในการทดสอบหน่วย

เราไม่จำเป็นต้องระบุโค้ดใดๆ ใน tests/integration_test.rs ด้วย #[cfg(test)] Cargo ปฏิบัติต่อไดเรกทอรี tests เป็นพิเศษ และคอมไพล์ไฟล์ในไดเรกทอรีนี้เฉพาะเมื่อเรารัน cargo test เท่านั้น รัน cargo test ตอนนี้:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test 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

ผลลัพธ์ทั้งสามส่วนประกอบด้วย การทดสอบหน่วย (unit tests), การทดสอบรวมระบบ (integration test) และ การทดสอบเอกสาร (doc tests) สังเกตว่าหากการทดสอบใดๆ ในส่วนหนึ่งล้มเหลว ส่วนถัดไปจะไม่ถูกรัน ตัวอย่างเช่น หากการทดสอบหน่วยล้มเหลว จะไม่มีผลลัพธ์สำหรับการทดสอบรวมระบบและการทดสอบเอกสาร เนื่องจากทดสอบเหล่านั้นจะรันก็ต่อเมื่อการทดสอบหน่วยทั้งหมดผ่านแล้วเท่านั้น

ส่วนแรกสำหรับการทดสอบหน่วยจะเหมือนกับที่เราเห็นก่อนหน้านี้: หนึ่งบรรทัดสำหรับแต่ละการทดสอบหน่วย (ตัวหนึ่งชื่อ internal ที่เราเพิ่มใน Listing 11-12) แล้วจึงเป็นบรรทัดสรุปสำหรับการทดสอบหน่วย

ส่วนการทดสอบรวมระบบเริ่มต้นด้วยบรรทัด Running tests/integration_test.rs ถัดไปคือบรรทัดสำหรับแต่ละฟังก์ชันการทดสอบในการทดสอบรวมระบบนั้น และบรรทัดสรุปผลลัพธ์ของการทดสอบรวมระบบก่อนที่ส่วน Doc-tests adder จะเริ่มต้น

ไฟล์ทดสอบรวมระบบแต่ละไฟล์มีส่วนของตัวเอง ดังนั้นหากเราเพิ่มไฟล์ในไดเรกทอรี tests เพิ่มเติม ก็จะมีส่วนการทดสอบรวมระบบมากขึ้น

เรายังคงสามารถรันฟังก์ชันการทดสอบรวมระบบเฉพาะได้โดยระบุชื่อฟังก์ชันการทดสอบเป็นอาร์กิวเมนต์ให้กับ cargo test และหากต้องการรันการทดสอบทั้งหมดในไฟล์ทดสอบรวมระบบไฟล์ใดไฟล์หนึ่ง ให้ใช้อาร์กิวเมนต์ --test ของ cargo test ตามด้วยชื่อไฟล์:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

คำสั่งนี้รันเฉพาะการทดสอบในไฟล์ tests/integration_test.rs

โมดูลย่อยในการทดสอบรวมระบบ (Submodules in Integration Tests)

เมื่อคุณเพิ่มการทดสอบรวมระบบมากขึ้น คุณอาจต้องการสร้างไฟล์เพิ่มเติมในไดเรกทอรี tests เพื่อช่วยจัดระเบียบ ตัวอย่างเช่น คุณสามารถจัดกลุ่มฟังก์ชันการทดสอบตามฟังก์ชันการทำงานที่พวกมันกำลังทดสอบ ดังที่ได้กล่าวไว้ก่อนหน้านี้ แต่ละไฟล์ในไดเรกทอรี tests จะถูกคอมไพล์เป็น crate แยกกันของตัวเอง ซึ่งมีประโยชน์สำหรับการสร้างขอบเขตแยกต่างหากเพื่อเลียนแบบวิธีที่ผู้ใช้จริงจะใช้ crate ของคุณได้ใกล้เคียงยิ่งขึ้น อย่างไรก็ตาม สิ่งนี้หมายความว่าไฟล์ในไดเรกทอรี tests จะไม่ใช้พฤติกรรมเดียวกับไฟล์ใน src ดังที่คุณได้เรียนรู้ในบทที่ 7 เกี่ยวกับการแยกโค้ดออกเป็นโมดูลและไฟล์

พฤติกรรมที่แตกต่างกันของไฟล์ในไดเรกทอรี tests จะเห็นได้ชัดเจนที่สุดเมื่อคุณมีชุดฟังก์ชันช่วยเหลือที่ต้องการใช้ในไฟล์ทดสอบรวมระบบหลายๆ ไฟล์ และคุณพยายามทำตามขั้นตอนในหัวข้อ “การแยกโมดูลออกเป็นไฟล์ต่างๆ” ในบทที่ 7 เพื่อดึงพวกมันออกมาเป็นโมดูลส่วนกลาง ตัวอย่างเช่น หากเราสร้าง tests/common.rs และวางฟังก์ชันชื่อ setup ไว้ในนั้น เราจะสามารถเพิ่มโค้ดบางอย่างใน setup ที่เราต้องการเรียกใช้จากหลายๆ ฟังก์ชันการทดสอบในหลายๆ ไฟล์ทดสอบ:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

เมื่อเรารันการทดสอบอีกครั้ง เราจะเห็นส่วนใหม่ในผลลัพธ์การทดสอบสำหรับไฟล์ common.rs แม้ว่าไฟล์นี้จะไม่ได้บรรจุฟังก์ชันการทดสอบใดๆ และเราก็ไม่ได้เรียกใช้ฟังก์ชัน setup จากที่ใดเลยก็ตาม:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test 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

การที่มี common ปรากฏในผลการทดสอบโดยแสดง running 0 tests ไม่ใช่สิ่งที่เราต้องการ เราเพียงแค่ต้องการแชร์โค้ดบางส่วนกับไฟล์ทดสอบรวมระบบอื่นๆ เพื่อหลีกเลี่ยงไม่ให้ common ปรากฏในผลลัพธ์การทดสอบ แทนที่จะสร้าง tests/common.rs เราจะสร้าง tests/common/mod.rs แทน ไดเรกทอรีโปรเจกต์ตอนนี้จะมีหน้าตาดังนี้:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

นี่คือข้อตกลงการตั้งชื่อแบบเก่าที่ Rust เข้าใจเช่นกัน ซึ่งเราได้กล่าวไว้ในหัวข้อ “พาธไฟล์ทางเลือก” ในบทที่ 7 การตั้งชื่อไฟล์ด้วยวิธีนี้บอก Rust ไม่ให้ปฏิบัติต่อโมดูล common ในฐานะไฟล์การทดสอบรวมระบบ เมื่อเราย้ายโค้ดฟังก์ชัน setup เข้าไปใน tests/common/mod.rs และลบไฟล์ tests/common.rs ออกไป ส่วนดังกล่าวกระทั่งในผลลัพธ์การทดสอบจะไม่ปรากฏอีกต่อไป ไฟล์ในไดเรกทอรีย่อยของไดเรกทอรี tests จะไม่ถูกคอมไพล์เป็น crate แยกกัน หรือมีส่วนของตัวเองในผลลัพธ์การทดสอบ

หลังจากที่เราสร้าง tests/common/mod.rs แล้ว เราสามารถใช้งานมันในฐานะโมดูลจากไฟล์การทดสอบรวมระบบใดก็ได้ นี่คือตัวอย่างการเรียกใช้ฟังก์ชัน setup จากการทดสอบ it_adds_two ใน tests/integration_test.rs:

Filename: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

สังเกตว่าการประกาศ mod common; จะเหมือนกับการประกาศโมดูลที่เราสาธิตใน Listing 7-21 จากนั้น ในฟังก์ชันการทดสอบ เราจะสามารถเรียกฟังก์ชัน common::setup() ได้

การทดสอบรวมระบบสำหรับ Binary Crates

หากโปรเจกต์ของเราเป็น binary crate ที่มีเฉพาะไฟล์ src/main.rs และไม่มีไฟล์ src/lib.rs เราจะไม่สามารถสร้างการทดสอบรวมระบบในไดเรกทอรี tests และนำฟังก์ชันที่กำหนดไว้ในไฟล์ src/main.rs เข้าสู่ขอบเขตด้วยคำสั่ง use ได้ มีเพียง library crates เท่านั้นที่จะเปิดเผยฟังก์ชันที่ crate อื่นสามารถใช้ได้ โดย binary crates ถูกออกแบบมาให้รันด้วยตัวของมันเอง

นี่เป็นหนึ่งในเหตุผลที่โปรเจกต์ Rust ที่มีไบนารีจะมีไฟล์ src/main.rs ที่ตรงไปตรงมา ซึ่งทำหน้าที่เรียกใช้ตรรกะที่อยู่ในไฟล์ src/lib.rs โดยใช้โครงสร้างนั้น การทดสอบรวมระบบ จะสามารถ ทดสอบ library crate ด้วย use เพื่อให้ฟังก์ชันการทำงานที่สำคัญพร้อมใช้งาน หากฟังก์ชันการทำงานที่สำคัญทำงานได้ถูกต้อง โค้ดส่วนเล็กๆ ในไฟล์ src/main.rs ก็จะทำงานได้ถูกต้องเช่นกัน และโค้ดส่วนเล็กๆ นั้นก็ไม่จำเป็นต้องถูกทดสอบ

สรุป (Summary)

ฟีเจอร์การทดสอบของ Rust มอบวิธีระบุว่าโค้ดควรทำงานอย่างไร เพื่อให้แน่ใจว่าโค้ดจะยังคงทำงานตามที่คุณคาดหวังแม้ว่าจะมีการเปลี่ยนแปลง การทดสอบหน่วยทดสอบแต่ละส่วนของไลบรารีแยกกัน และสามารถทดสอบรายละเอียดการใช้งานส่วนตัวได้ การทดสอบรวมระบบตรวจสอบว่าหลายๆ ส่วนของไลบรารีทำงานร่วมกันได้อย่างถูกต้อง และใช้ public API ของไลบรารีเพื่อทดสอบโค้ดในรูปแบบเดียวกับที่โค้ดภายนอกจะใช้งาน แม้ว่าระบบประเภทข้อมูล (type system) และกฎความเป็นเจ้าของ (ownership rules) ของ Rust จะช่วยป้องกันบั๊กบางประเภทได้ แต่การทดสอบก็ยังมีความสำคัญในการลดบั๊กทางตรรกะที่เกี่ยวกับพฤติกรรมที่คาดหวังของโค้ด

ลองนำความรู้ที่คุณได้เรียนรู้ในบทนี้และในบทก่อนหน้ามารวมกันเพื่อทำงานในโปรเจกต์กันเลย!