Photo by Michel Bosma on Unsplash

ช่วงนี้ทีมขึ้นโปรเจ็คใหม่ ผมต้องคุยกับ QA Engineer เกี่ยวกับเรื่อง Testing Strategy บ่อยๆ

โดยเนื้อหาที่คุยหลักๆคือ

  1. จะเทสต์อะไรบ้าง
  2. จะเทสต์ด้วยเทสต์ชนิดไหน (ex. Unit, Component, Integration)
  3. เราจะใช้เทสต์แต่ละชนิดในกรณีไหนบ้าง

พอคุยกันเรื่องนี้เยอะๆ ก็ค้นพบหัวข้อที่ 3 เป็นเรื่องที่ซับซ้อนพอควร หนึ่งในเรื่องที่ตกผลึกก็คือคุณสมบัติของเทสต์แต่ละชนิด ซึ่งเป็นเรื่องที่จะนำมาเล่าในบทความนี้

เข้าใจเทสต์ในระดับต่างๆ

ผมเชื่อว่าผู้อ่านคงเคยได้ยินชนิดของเทสต์ต่างๆมาแล้ว แต่เพื่อความเข้าใจที่ตรงกัน ผมอยากชี้แจงเรื่องระดับของเทสต์ด้วยตารางข้างล่าง

ชนิด สิ่งที่ต้องการเทสต์ ตัวอย่างเรื่องที่เทสต์
Functional Acceptance Testing Functional Requirement ของระบบ ลูกค้าสามารถซื้อของได้
System Integration Testing ระบบของเราสามารถทำงานกับระบบอื่นๆ (External Dependencies) ได้ถูกต้องหรือไม่ มีการตัดเงินจากบัญชีธนาคารของผู้ใช้อย่างถูกต้อง
Component Testing ชิ้นส่วนต่างๆในระบบของเราสามารถทำงานร่วมกันได้ถูกต้อง Ordering Component สามารถส่งคำสั่งที่ถูกต้องไปยัง Mocked Database Component
Unit Testing ชิ้นส่วนที่เล็กที่สุดในระบบของเราสามารถทำงาานได้ถูกต้อง คลาส Order สามารถคำนวนราคาของคำสั่งซื้อได้ถูกต้อง

จากตารางด้านบน เทสต์ที่อยู่ระดับบน (เช่น Functional Acceptance Test) จะทดสอบสิ่งที่ใกล้เคียงกับความต้องการของผู้ใช้มากที่สุด

ในขณะที่เทสต์ที่อยู่ระดับล่าง จะมุ่งเน้นไปการทดสอบไปที่ Technical Details เมื่อมองในมุมของผู้ใช้ เทสต์ในระดับล่างจะไม่ให้คุณค่ามากเท่าไร เพราะจุดประสงค์ของผู้ใช้คือต้องการซื้อของ ไม่ได้สนใจว่าคลาส Order จะทำงานได้หรือไม่

จะเทสต์ในระดับไหนดี?

สมมติว่าเรามีผู้ใช้ชื่อสุธี และโปรแกรมเมอร์ชื่อนัท

สิ่งที่สุธีต้องการคือซื้อของ ดูรายการที่ซื้อไป และตรวจสอบว่าของจะมาถึงเมื่อไร

สำหรับสุธีแล้ว เค้าไม่แคร์ในเรื่องของ Technical เลย ตราบเท่าที่เรามี Functional Acceptance Tests ที่ครอบคลุมพอ

ดังนั้น โปรเจ็คของเรา สุธีจึงเสนอให้ทำแต่ Functional Acceptance Tests อย่างเดียว

ฟังดูแล้วก็โอเคดี เป้าหมายของเราคือสร้างโปรแกรมให้กับผู้ใช้ ทำไมเราต้องไปเทสต์ในเรื่องของ Technical ด้วย? มันไม่ได้ให้คุณค่าอะไรกับผู้ใช้เลย

แต่พอนึกต่อ หากโปรเจ็คเรามีแค่ Functional Acceptance Tests ชนิดเดียว ผลที่ตามมาคือ

  1. สุธีต้องใช้เวลานานมากกว่าจะได้เทสต์ระบบ เพราะทุกอย่างต้องเสร็จหมด ก่อนที่สุธีจะทดสอบได้
  2. หากเทสต์แล้วเจอบั๊ก เราจะหาบั๊กยากมากเพราะจะผิดที่ไหนในระบบก็ได้
  3. Functional Acceptance Tests อาจจะ Automate ไม่ได้ทั้งหมด หรือได้ แต่ยากมาก

กลับมาที่โปรแกรมเมอร์นัท เสนอทางที่ตรงกันข้าม คือให้เขียนด้วย Unit test ทั้งหมด เพราะ

  1. เราสามารถทดสอบได้ทันทีที่เขียนโค้ด ไม่ต้องรอให้ระบบเสร็จเพราะทุกอย่างต้องเสร็จหมด
  2. หากเทสต์แล้วเจอ จะรู้ทันทีว่าปัญหาอยู่ที่คลาสหรือฟังก์ชั่นไหน
  3. สามารถ Automate ได้ง่าย แถมรันได้เร็วมาก

แต่ปัญหาของวิธีนี้ก็คือ แม้เทสต์จะผ่านหมด เราไม่สามารถมั่นใจได้เลยว่าผู้ใช้อย่างสุธีจะสามารถซื้อของได้หรือเปล่า

เพราะเวลาเอาชิ้นส่วนต่างๆมาทำงานร่วมกัน หรือติดต่อกับระบบอื่นจริงๆ มันอาจจะไม่ได้ทำงานร่วมกันไม่ได้อย่างที่คิดไว้

ดังนั้น เราจึงต้องมีเทสต์ในระดับกลางๆ อย่าง Component หรือ System Integration ด้วย

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

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

คุณสมบัติที่สำคัญของเทสต์

1. ความถูกต้องของระบบ (System Verification)

ก่อนอื่นเราต้องถามตัวเองก่อนว่าเขียนเทสต์เพื่ออะไร

บางคนจะตอบแบบกำปั้นทุบดินว่าเพื่อตรวจสอบความถูกต้องของระบบ (System Verification) แต่หลายคนอาจจะไม่เห็นด้วยเสียทีเดียว ตัวอย่างเช่น

  • บางคนที่ใช้ Test-Driven-Development (TDD) อาจะมองว่าการเขียนเทสต์เป็นการบังคับให้โปรแกรมเมอร์กำหนด Interface และจุดประสงค์ของคลาสชัดเจน (เช่น Loose Couple, High Cohesion) เป็นการช่วยให้เราออกแบบโค้ดที่อ่านและดูแลได้ง่าย
  • บางคนก็จะมองว่าเทสต์คือ Documentation อย่างหนึ่งที่อธิบายพฤติกรรมของโค้ดได้ถูกต้องที่สุด
  • บางคนอาจจะมองว่าเทสต์คือการสื่อสารระหว่างผู้ใช้กับโปรแกรมเมอร์ (เช่น กรณีของ Behavior-Driven-Development)

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

ซึ่งเทสต์ในระดับสูงอย่าง Functional Acceptance Test จะสามารถตรวจสอบความถูกต้องได้ดีกว่า เพราะทดสอบการทำงานจริง ไม่ใช่แค่ส่วนย่อยๆในสถานการณ์สมมติ

2. การรันอัตโนมัติ (Automation)

ลองถามตัวเองว่า หากเราต้องการทำ Continuous Integration (CI) แต่ไม่มี Automated Tests เลย เราจะทำได้หรือเปล่า?

คำตอบคือได้ แต่จะเจ็บปวดมาก

คือทุกครั้งที่ Push โค้ด ก็ให้เรียกสุธีมาทำการทดสอบทั้งหมดในระบบรอบนึง ถ้าสุธีให้ผ่าน เราก็ไปต่อ แต่ถ้าไม่ให้ผ่าน เราก็ Revert ออกมาแก้ใหม่

จะเห็นว่าหากทีมใหญ่ขึ้น การแก้โค้ดทุกอย่างจะมาตกคอขวดอยู่ที่สุธี การทำ Test Automation จึงเป็นหัวใจสำคัญที่จะทำให้ทีมพัฒนาโปรแกรมได้เร็วโดยไม่ใส่บั๊กเข้าไปในระบบ

สมัยก่อน การทำ Automation ในเทสต์ระดับสูงๆค่อนข้างยากมาก เดี๋ยวนี้ง่ายขึ้นมาก เพราะเรามีเครื่องมือในการ Stub กับ Driver ที่ดีขึ้น (เช่น WireMock, Mountebank, Selenium)

แต่ถึงกระนั้น การทำ Automation ในระดับสูงๆก็ยังยากกว่าอยู่ดี ลองนึกภาพว่าเราต้องทำการทดสอบการสั่งซื้อ ในระดับ System Integration

  • ต้องแก้ Database ทุกครั้งที่มีการ SetUp/TearDown เพราะเรารันซ้ำไปเรื่อยๆโดยไม่ลบออกเราจะมี Order ที่สร้างจากการเทสต์เต็มไปหมด
  • ต้องสร้าง Environment ใหม่ขึ้นมารันเทสต์ เพราะเราจะไปยิงคำสั่งซื้อบน Production มั่วซั่วไม่ได้
  • ถ้าธนาคารไม่มีระบบให้เราลองเทสต์ เราก็ต้องสร้าง Stub Server ขึ้นมาเอง
  • ต้องหาวิธี Simulate Error 404, 5xx, ฯลฯ เวลามี Network call เพื่อทดสอบกรณีที่มีปัญหา

จะเห็นว่านี่ไม่ใช่เรื่องง่ายซะทีเดียว จึงไม่แปลกที่บางโปรเจ็คเลือกที่จะไม่ Automate เทสต์ในระดับบนๆ แล้วจ่ายด้วยการจ้างคนมาทำ Manual แทน

3. ความเร็ว (Speed)

เทสต์ที่อยู่ในระดับล่างๆ (Unit Test) จะใช้เวลาในการรันที่เร็วกว่า แต่เทสต์บนๆจะใช้เวลานานมาก (Integration Test) เพราะอาจจะต้องมีการส่งข้อมูลผ่าน Network หรือเซ็ตอัพข้อมูล Database นี่เป็น Trade-off ที่คนในทีมต้องกำหนดกลยุทธิ์ในการเทสต์ให้ดี

การที่เทสต์ใช้เวลารันนานนั้น ทีมจะต้องจ่ายด้วย Productivity เพราะว่า

1. Feedback Loop ยาวกว่า

หากเทสต์รันเร็วมากๆ อย่าง Unit Test นั้นเราสามารถตั้งให้รันทุกครั้งที่มีการเปลี่ยนแปลงไฟล์ ทำให้โปรแกรมเมอร์รู้ทันทีว่ามีโค้ดไม่ได้ทำงานอย่างที่ตั้้งใจไว้

ในขณะที่ System Integration Test นี่นานมาก เพราะต้องรอจนขึ้น Environment จริงใน Pipeline

หากเราพึ่งเทสต์ในระดับสูงเยอะ ก็จะมีโอกาสที่เทสต์ก็จะไปตายใน Pipeline บ่อยๆ ซึ่งทำให้ติดขัดทั้งทีม และ Productivity ของทีมโดยรวมช้าลง

2. หาบั๊กยาก

เวลาเทสต์พังใน CI จะหา Commit ที่สร้างปัญหายากกว่ามาก

ลองนึกภาพว่าเทสต์ทั้งหมดต้องใช้เวลา 2 ชั่วโมงในการรัน ยิ่งปริมาณทีมมีขนาดใหญ่ขึั้น จำนวน Commit ที่เข้าไปใน 2 ชั่วโมงนั้นอาจจะมากกว่า 2-3 Commits ซึ่งเวลาพังขึ้นมา จะดีบั้กยากว่ามาจากไหน (เพราะต้องหาสาเหตุจาก commits จากโค้ดของคนอื่นๆที่เราไม่ได้เขียนด้วย) และมักจะเกี่ยงกันว่าใครจะเป็นคนหาบั้ก

4. ความไม่สม่ำเสมอ (Flakiness)

Test ในระดับบนๆ ตั้งแต่ System Integration Test ขึ้นไป จะมีปัญหาเรื่องความไม่สม่ำเสมอในผลลัพธ์การรัน เกิด False Alarm ขึ้นบ่อย ตัวอย่างเช่น

  • เทสต์ในระดับ Browser (ex. Selenium) บางคนอาจจะเจออาการว่าเทสต์รันผ่านบ้างไม่ผ่านบ้าง เพราะรีบกดปุ่มในชั้นถัดไปเร็วเกิน หน้า UI ยังเรนเดอร์ใหม่ไม่เสร็จ หรือเทสต์บางตัวรันไม่ผ่าน เพราะขนาดหน้าของ Browser เป็นคนละไซส์และปุ่มไปซ่อนอยู่
  • เทสต์ในระดับที่ต้องยิงผ่าน Network บางทีก็ยิงไม่สำเร็จเพราะเน็ตเวิร์คมีปัญหาพอดี ทำให้เทสต์พังบ้างนานๆที
  • เทสต์ในระดับที่ต้องติดต่อกับ Third-party System บางทีระบบเราทำงานถูกต้องแล้ว แต่ระบบที่เรายิง Request ไปขอข้อมูลเกิดพังขึ้นมา

ความไม่สม่ำเสมอนั้นสร้างปัญหาให้กับ CD มาก เพราะเราจะไม่สามารถปล่อยให้โค้ดไหลผ่าน Pipeline ไปบน Production ได้โดยอัตโนมัติ ต้องมานั่งเช็คว่าเทสต์ที่พังนี่พังจริงๆหรือเพราะ Flakiness ซึ่งจ่ายกันด้วย Productivity อีก

5. ความเปราะบาง (Brittleness)

เทสต์ที่เปราะบาง เวลาแก้โค้ดที่นึง เทสต์จะพังเป็นยวง ต้องแก้เยอะมาก

ความเปราะบางของเทสต์จะมีผลต่อค่าใช้จ่ายในการดูแลรักษา

โดยหลักการแล้ว ส่วนที่มีปัญหาบ่อยคือ UI เพราะเวลาเปลี่ยน UI ทีนึง ID หรือ Class ที่ใช้หา Element เปลี่ยน โปรแกรมเมอร์ดันแก้เทสต์โค้ดไม่หมด ทำให้เทสต์ไปค้น ID เดิม (วิธีนี้อาจจะแก้ได้ด้วย Page Object Pattern)

แต่ถ้าโปรดักต์มีการเปลี่ยน UI บ่อยๆ ปัญหานี้จะหลีกเลี่ยงไม่ได้ ทีมคงได้แต่ลด Test ที่อยู่ในระดับนี้ให้น้อยที่สุด แล้วไปทดสอบ Combination หรือ Edge case ในระดับล่างๆแทน

ในทางปฏิบัติ เทสต์ระดับล่างก็มักจะมีปัญหาแนวนี้ด้วยเหมือนกัน แต่สาเหตุมักจะมาจากการออกแบบที่ไม่ดี เช่น Unit Test ของโค้ดที่เปิด Public Interface ในส่วนที่ไม่ควรจะเปิด เวลา Refactor โค้ดส่วนที่ควรจะเป็น Internal Implementation ดันกลายเป็นทำให้ Unit Test พังด้วย

6. ความง่ายในการหาต้นตอ (Failure Isolation)

เวลา Unit Test พัง เรามักจะรู้เลยว่าน่าจะเป็น Class หรือ Function ไหน ในกรณีอะไร ซึ่งบางครั้งแค่เห็นชื่อเทสต์ที่พังก็จะรู้เลยว่าต้องไปเช็คโค้ดบรรทัดไหน

ในทางตรงข้าม Functional Acceptance Test พอไม่ผ่านทีนึง เราอาจจะได้ดีบั้กกันข้ามเซอร์วิซกันหลายวัน กว่าจะเจอว่าปัญหามากบรรทัดไหน

ส่วนใหญ่ความง่ายในการหาต้นตอจะลดลงเมื่อเทสต์ขึ้นมาในระดับที่สูงขึ้นเรื่อยๆอย่างหลีกเลี่ยงได้ยาก พอเทสต์ขึ้นมาถึงระดับที่ต้องยิงข้าม Service แล้ว ส่วนใหญ่จะต้องพึ่ง Log ในการแก้ปัญหากัน และต้องใช้ความเข้าใจในระบบที่ดีบั้กสูงมาก

สรุป

ถ้าเราสรุปคุณสมบัติต่างๆของเทสต์แต่ละชนิด เราจะได้ตารางด้านล่าง

ชนิด System Verification Automation Speed Flakiness Brittleness Failure Isolation
Functional Acceptance Testing สูง ยาก ช้ามาก แย่มาก แย่ แย่มาก
System Integration Testing สูง ยาก ช้า แย่ กลาง แย่
Component Testing กลาง กลาง กลาง ดี กลาง กลาง
Unit Testing ต่ำ ง่าย เร็วมาก ดี ดี ดีมาก

ตารางนี้แค่ให้เห็นภาพรวมคร่าวๆ เวลาเขียนจริง ขึ้นอยู่กับการออกแบบเทสต์มาก อย่างผมเองเคยเห็นระบบที่มี Unit Tests ที่โคตร Brittle แต่ System Integration เสถียรมากมาแล้ว

ส่วนคุณสมบัติแต่ละอย่าง จะส่งผลกระทบต่อทีมต่างกัน

คุณสมบัติ ผลกระทบ
System Verification ความมั่นใจว่าระบบทำงานได้ถูกต้อง
Automation ความเร็วในการพัฒนา, Effort ในการเทสต์
Speed ความเร็วในการพัฒนา, โค้ดขึ้น Production ได้เร็ว (CD)
Flakiness Effort ในการสร้างและดูแลเทสต์, โค้ดขึ้น Production ได้เร็ว (CD)
Brittleness ความเร็วในการพัฒนา, Effort ในการสร้างและดูแลเทสต์
Failure Isolation Effort ในการแก้บั๊ก

ตารางสรุปจะสอดคล้องกับเรื่องราวของสุธีและอรุช ว่า ฝั่ง Technical จะชอบเทสต์ระดับล่างๆกันมากกว่า ในขณะที่ผู้ใช้สนแต่ระดับบนๆว่าระบบทำงานที่ต้องการไหม

ถ้าไม่คิดอะไรมาก เราอาจจะยึดหลักง่ายๆว่า 10:20:70

  • Functional Acceptance ประมาณ 10% เช่น เช็คแค่กรณีที่ใส่ข้อมูลถูกและซื้อของสำเร็จ และไปตรวจกรณีที่ไม่สำเร็จในระดับล่างๆ
  • Integration/Component ประมาณ 20% ไว้ทดสอบกรณีที่ Unit Test จับไม่ได้ ๊* Unit ประมาณ 70% เทสต์ทุกกรณีและ Combination

แต่ถ้าคิดมาก ก็ต้องมานั่งดูกันจริงๆ ว่าระบบมีโอกาสผิดพลาดที่จุดไหนสูงเป็นพิเศษ แล้วเลือกเทสต์ในระดับที่ต่ำที่สุดที่จะยังสามารถตรวจพบข้อผิดพลาดนั้นได้ เช่น คิดว่าระบบจะมีปัญหากับการติดต่อระบบธนาคารบ่อยๆ เราก็ต้องทำ System Integration Testing ให้ครอบคลุมเยอะหน่อย