การจัดโครงสร้างการทดสอบ (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 จะช่วยป้องกันบั๊กบางประเภทได้ แต่การทดสอบก็ยังมีความสำคัญในการลดบั๊กทางตรรกะที่เกี่ยวกับพฤติกรรมที่คาดหวังของโค้ด
ลองนำความรู้ที่คุณได้เรียนรู้ในบทนี้และในบทก่อนหน้ามารวมกันเพื่อทำงานในโปรเจกต์กันเลย!