ทดสอบโค้ดผ่านหมดแล้ว มั่นใจได้แค่ไหน
เวลาเราเขียนโปรแกรม เรามักจะเขียน Unit Test (การทดสอบโค้ดส่วนย่อยๆ ว่าทำงานถูกไหม) เพื่อยืนยันว่าโค้ดของเราทำงานได้ตามที่คิดไว้ หลายคนดีใจเมื่อเห็นแถบสีเขียวขึ้นว่าผ่านหมด แต่นั่นเป็นแค่การบอกว่าโค้ดทำงานตรงกับที่ Test (การทดสอบ) เขียนไว้เท่านั้น
ลองนึกภาพว่าเราเขียนคำสั่งให้หุ่นยนต์เดินไปข้างหน้า 5 ก้าว แล้วเราก็เขียนบททดสอบว่า "หุ่นยนต์ต้องเดินได้ 5 ก้าว" ถ้าหุ่นยนต์เดินได้จริง บททดสอบก็ผ่าน แต่ถ้าหุ่นยนต์ตัวนั้นมีบั๊ก (ข้อผิดพลาด) ที่ทำให้มันเดินถอยหลังก่อนจะเดินหน้า 5 ก้าวล่ะ บททดสอบเดิมก็อาจจะยังมองว่าผ่านอยู่ดี
นี่คือเหตุผลว่าทำไมโค้ดที่ผ่านการทดสอบแล้ว ถึงยังอาจมีบั๊กซ่อนอยู่มากมาย การทดสอบแบบเดิมๆ อาจจะไม่ได้ครอบคลุมทุกกรณี หรือบททดสอบนั้นอาจจะ "ตาบอด" ในจุดที่โค้ดมีปัญหาพอดี วันนี้เราจะมาทำความรู้จักกับวิธีที่เรียกว่า Mutation Testing (การทดสอบโดยการจงใจสร้างบั๊ก) เพื่อหาจุดบอดเหล่านี้กัน
Mutation Testing คืออะไรและทำไมต้องใช้
Mutation Testing คือเทคนิคการทดสอบที่เราจะจงใจใส่บั๊กเข้าไปในโค้ดทีละจุด แล้วดูว่าบททดสอบของเรา "จับได้" หรือไม่ ถ้าเราใส่บั๊กไปแล้วบททดสอบยังผ่าน (Green) แสดงว่าชุดการทดสอบของเรามีปัญหา เพราะมันไม่สามารถตรวจจับความผิดพลาดพื้นฐานได้
เปรียบเทียบง่ายๆ เหมือนการตรวจสอบความปลอดภัยของประตูบ้าน ถ้าเราลองแอบเอาไขควงมางัดกลอนประตูแล้วประตูยังล็อคอยู่ แสดงว่าระบบรักษาความปลอดภัยเราใช้ได้ แต่ถ้าเรางัดจนประตูเปิดได้แล้วระบบเตือนภัยเงียบกริบ นั่นแปลว่าระบบตรวจจับของเรามีปัญหา
การทำแบบนี้ช่วยให้เรามั่นใจได้ว่าบททดสอบของเราไม่ได้แค่ "ผ่านไปวันๆ" แต่มันมีความสามารถในการตรวจจับบั๊กจริงๆ มันช่วยเปลี่ยนความรู้สึกมั่นใจจากการเดา ให้กลายเป็นตัวเลขวัดผลที่ชัดเจนว่าโค้ดของเราแข็งแกร่งแค่ไหน
ขั้นตอนการทำ Mutation Testing แบบมือโปร
การทดสอบแบบนี้มีขั้นตอนที่ไม่ซับซ้อนแต่ต้องทำอย่างเป็นระบบ เพื่อให้ได้ผลลัพธ์ที่น่าเชื่อถือที่สุด เริ่มจากตรวจสอบให้แน่ใจว่าชุดทดสอบเดิมของเราไม่ Flaky (การทดสอบที่ผลลัพธ์ไม่แน่นอน เดี๋ยวผ่านเดี๋ยวไม่ผ่าน) เพราะถ้าการทดสอบหลักเชื่อถือไม่ได้ ผลการทดสอบกลายพันธุ์ก็เชื่อถือไม่ได้เช่นกัน
- เลือกฟังก์ชันที่ต้องการทดสอบ
- สร้าง Mutant (โค้ดที่ถูกจงใจใส่บั๊กเข้าไป) โดยการเปลี่ยนตัวดำเนินการ เช่น เปลี่ยนเครื่องหมายบวกเป็นลบ หรือลบเงื่อนไขบางอย่างออก
- รันชุดทดสอบเดิมกับโค้ดที่แกล้งทำบั๊กไว้
- ถ้าบททดสอบล้มเหลว (Fail) แสดงว่าเรา "ฆ่า" ตัวกลายพันธุ์ได้สำเร็จ แต่ถ้ายังผ่าน (Pass) แสดงว่าตัวกลายพันธุ์นั้น "รอด" ซึ่งเราต้องกลับไปเพิ่มบททดสอบใหม่
จุดที่มือใหม่มักพลาดคือพยายามทดสอบทุกอย่างในครั้งเดียว ซึ่งจะทำให้งงมากว่าบั๊กมาจากตรงไหน ให้ลองทำทีละนิด เปลี่ยนแค่จุดเดียวแล้วทดสอบทันที จะช่วยให้เราเห็นภาพชัดเจนว่าโค้ดส่วนไหนที่เรายังไม่ได้เขียนบททดสอบไว้
ตัวอย่างการสร้างบั๊กเพื่อทดสอบ (Mutation)
สมมติเรามีคิว (ชุดข้อมูลที่เรียงลำดับ) ที่จำกัดขนาดไว้ เราจะลองจงใจลบคำสั่งที่ใช้จัดการการหมุนวนของข้อมูล เพื่อดูว่าบททดสอบจะจับได้ไหม
// โค้ดเดิมที่ทำงานถูกต้อง
tail_ = (tail_ + 1) % data_.size();
// โค้ดที่จงใจใส่บั๊ก (Mutation)
tail_ = (tail_ + 1);
// ลบเครื่องหมาย % ออก ทำให้ tail_ เพิ่มขึ้นเรื่อยๆ จนเกินขนาดข้อมูล
ในตัวอย่างนี้ บรรทัดแรกคือการใช้ Modulo (เครื่องหมายหารเอาเศษ) เพื่อให้ค่าวนกลับมาที่จุดเริ่มต้นของคิว ส่วนบรรทัดที่สองเราลบมันออก ถ้าบททดสอบของเราไม่เคยทดสอบกรณีที่คิวเต็มจนต้องวนรอบ มันก็จะมองว่าโค้ดที่บั๊กนี้ "ทำงานผ่าน" ซึ่งถือว่าอันตรายมาก
ผลลัพธ์ที่ควรเห็นคือ ถ้าบททดสอบเราฉลาดพอ มันจะต้องแจ้งเตือนว่าเกิดข้อผิดพลาดในการเข้าถึงหน่วยความจำ (Memory Error) แต่ถ้ามันไม่แจ้งอะไรเลย นั่นคือสัญญาณเตือนว่าเราต้องเขียนบททดสอบเพิ่มตรงกรณีที่คิวเต็มให้มากขึ้น
เมื่อบททดสอบ "ตาบอด" เราต้องทำอย่างไร
เมื่อเราพบว่ามีตัวกลายพันธุ์รอดชีวิตไปได้ นั่นคือโอกาสดีที่เราจะได้เรียนรู้ว่าบททดสอบของเราขาดอะไรไป อย่ามองว่ามันเป็นเรื่องแย่ แต่ให้มองว่ามันคือการค้นพบจุดบอดที่เราไม่เคยนึกถึงมาก่อน
วิธีแก้คือการเขียน Regression Test (การทดสอบเพื่อป้องกันไม่ให้บั๊กเดิมกลับมาอีก) เพิ่มเข้าไปในกรณีที่ตัวกลายพันธุ์นั้นรอดไปได้ เช่น ถ้าเราลืมทดสอบเรื่องการวนรอบของคิว เราก็ต้องเขียนบททดสอบที่บังคับให้คิวเต็มและทำการวนรอบจริงๆ เพื่อให้แน่ใจว่าโค้ดจัดการได้ถูกต้อง
จำไว้ว่า Sanitizers (เครื่องมือตรวจจับข้อผิดพลาดขณะรันโปรแกรม) อย่าง ASan หรือ UBSan จะทำงานได้ก็ต่อเมื่อบททดสอบวิ่งผ่านโค้ดส่วนนั้น ถ้าเราไม่เขียนบททดสอบให้ครอบคลุม เครื่องมือเหล่านี้ก็ช่วยอะไรไม่ได้เลย เพราะมันไม่ใช่คนเขียนโค้ดแทนเรา
สรุป: เปลี่ยนความมั่นใจให้เป็นตัวเลข
Mutation Testing ไม่ใช่เรื่องที่ต้องทำในทุกบรรทัดของโค้ด เพราะมันมีต้นทุนเรื่องเวลาในการรันที่สูง แต่มันมีประโยชน์มากกับโค้ดส่วนสำคัญ (Core Logic) ที่ถ้าพังแล้วจะส่งผลกระทบต่อระบบใหญ่ทั้งหมด
สรุปสั้นๆ สำหรับคนที่กำลังฝึกเขียนโค้ด:
- อย่าเชื่อมั่นในแถบสีเขียว 100%
- ลองจงใจแก้โค้ดให้พังดูบ้าง เพื่อดูว่าบททดสอบแจ้งเตือนไหม
- ถ้าบททดสอบไม่เตือน นั่นคือจุดที่คุณต้องเพิ่มบททดสอบเข้าไป
ในชีวิตการทำงานจริง การทำแบบนี้จะทำให้คุณกลายเป็นโปรแกรมเมอร์ที่ละเอียดรอบคอบ เพื่อนร่วมทีมจะรู้สึกมั่นใจมากเวลาเห็นโค้ดของคุณ เพราะเขารู้ว่าคุณไม่ได้แค่เขียนให้มันทำงานได้ แต่คุณเขียนให้มัน "พังยาก" ด้วยครับ
ที่มา: The Agent's Tests Passed. Mutation Testing Showed 2 of 4 Faults Survived. — DEV Community