สรุปสิ่งที่ได้เรียนรู้ใหม่จาก course Testable Architecture
ช่วงที่ผ่านมามีโอกาสได้เรียน course Testable Architecture แล้วรู้สึกว่ามันเป็นแนวคิดที่น่าสนใจ หลายทีมมักจะคิดว่า คือการมี architecture ที่ดี คือต้องเป็นไปตาม pattern ที่หนังสือหรืออาจารย์ใด ๆ บอกไว้ ต้องเรียบง่ายไม่ซับซ้อน และแน่นอนต้อง “ทดสอบง่าย” แต่การจะทำให้อย่างหลังมันได้มันก็ยากอยู่ เพราะสิ่งที่ทำให้ระบบทดสอบง่าย ไม่ได้อยู่ที่ test framework หรือจำนวน test case — แต่อยู่ที่ “การแบ่งระบบออกเป็นส่วนเล็ก ๆ (unit) ที่สามารถทดสอบแยกกันได้” ต่างหาก
การมองต่างมุมระหว่าง Tester หลาย ๆ คน กับ Developer
ในการพัฒนา software ที่เป็นทีม ถ้าเรามองจากฝั่ง Tester เขามีแนวโน้มที่จะเน้นการทดสอบไปในมุมมองของ user เช่น
- ระบบทำงานตรงตาม spec ไหม
- User พอใจไหม
โดยที่บางครั้งก็ไม่ได้สนใจ implementation หรือ code ภายในเลย มันคือ black box testing กล่าวคือมองระบบเหมือนกล่องดำบนเครื่องบิน ไม่รู้ว่าข้างในมันทำงานยังไง สนใจว่า input เข้าไปได้ output อะไรออกมาเป็นพอ
แต่ฝั่ง Developer มองอีกแบบ
- สนใจว่าแต่ละส่วน (unit) ของระบบทำงานถูกต้องไหม
- ถ้าระบบพัง จะหาจุดพังได้อย่างไร
- Code ของเราทำงานตรงกับที่ module อื่นคาดหวังหรือเปล่า
ความต่างตรงนี้ทำให้เราเห็นว่า การทดสอบของ Developer ไม่ได้มีจุดประสงค์เดียวกัน Tester อยากรู้ว่าระบบโดยรวมทำงานไหม ส่วน Developer อยากรู้ว่า “ตรงไหนพัง” เพราะแน่นอนว่าหากเกิดปัญหา Developer ก็ต้องเป็นคนแก้ ดังนั้นถ้าหาไม่เจอว่ามันพังตรงไหนกันแน่ Developer จะพัฒนาดูแลระบบได้ยากมาก ค่าใช้จ่ายในการทดสอบก็จะสูงมากเช่นกัน
ความต่างของมุมมองนำไปสู่ “Testable Architecture”
ถ้าจะพูดให้ง่ายที่สุด Testable Architecture คือการออกแบบระบบให้ สามารถเข้าใจ แยกส่วน และทดสอบได้
หลักการสำคัญมีแค่ 3 ข้อ:
- แบ่งระบบออกเป็นหลาย ๆ component (unit)
- แต่ละ component มีขอบเขตและ spec ที่ชัดเจน
- สามารถทดสอบ component นั้นได้อย่างอิสระ
ถ้า architecture ของเราทำแบบนี้ได้ เราจะไม่ต้อง run ระบบทั้งก้อนทุกครั้งแค่เพื่อดูว่า “มันยังทำงานถูกต้องอยู่ไหม” เราจะสามารถชี้ได้ว่าปัญหาอยู่ตรงไหนภายในไม่กี่นาที
ความท้าทายของการออกแบบให้ testable
แต่แน่นอนว่าในชีวิตจริง มันไม่ได้ง่ายขนาดนั้น เวลาพยายามออกแบบระบบให้ testable เรามักเจอปัญหาอย่าง
- Unit ที่เจอมันใหญ่เกินหรือไม่เพียงพอ ต้องแตกเป็นย่อย ๆ อีก
- ยิ่งแตกมาก โครงสร้างยิ่งซับซ้อน
ซึ่งเป็นสิ่งปกติที่ทีมพัฒนาต้องเจอ และปรับ unit ของระบบตามการเปลี่ยนแปลงของระบบ
ตรงนี้เองที่หลายคนเข้าใจว่า unit test คือการ test ทุก function หรือ method ซึ่งจริง ๆ แล้วไม่ถูกซะทีเดียว “Unit” ในที่นี้หมายถึง ส่วนของระบบที่เรานิยามไว้เอง — จะใหญ่หรือเล็กแค่ไหนขึ้นอยู่กับ context ของระบบนั้น ๆ ปัญหาคือหลายทีมมักนิยาม unit เล็กเกินไป (เช่น class เดียว) จน test เยอะโดยไม่จำเป็น ปัญหา classic ที่ตามมาคือ unit test เยอะเกินไปจนจัดการไม่ไหว ทุกครั้งที่ requirement เปลี่ยน test case พังเป็นสิบ ๆ หรือร้อยเคส Developer ก็เริ่มหมดแรง
ตรงนี้เองที่ การทดสอบระดับ integration test เข้ามาช่วยได้ โดยที่ เราไม่จำเป็นต้อง test ทุก unit แยกกันหมด แต่เลือก sampling case เพื่อยืนยันว่าเมื่อนำ unit ที่สนใจมารวมกันแล้วทำงานถูกต้องจริง ซึ่งมันครอบคลุมไปมากกว่าแค่ “ยิง API” แล้วจบ
พอกลับมาที่จำนวนของ test case หลายคนบอกว่าควรมี integration test เยอะกว่า unit test ซึ่งฟังดูดี แต่ไม่ครบทั้งหมด เพราะสิ่งที่เราต้องเข้าใจคือค่าใช้จ่ายในการทดสอบแต่ละ level อกจริง ๆ คือ
- Unit test: เร็วกว่า, debug ง่าย
- Integration test: มั่นใจมากกว่า, เขียนยากกว่า
จำนวนของ test case จึงขึ้นอยู่กับว่าทีมของเราจัดสมดุลระหว่าง efficiency กับ quality มากน้อยยังไง การเลือกว่าจะเน้นแบบไหนขึ้นอยู่กับทีมและ maturity ของระบบนั้น ๆ และสถานการณ์ในทีมของเรา เช่น บาง startup ที่ยังไม่พร้อมออกแบบละเอียดอาจเริ่มจาก integration test ก่อนก็ไม่ผิด
แนวคิดในการออกแบบ unit ใน architecture
- มี domain concept ที่ดี: ถ้าเรามี domain concept ที่สอดคล้องกับ business หรือ domain expert ระบบก็จะ align ได้ดี เช่น “Checkout” กับ “Payment” แยกกันชัดเจน แทนที่จะเป็น “PayingWorkflow” ที่รวมสองอย่างนั้นเข้าด้วยกัน ระบบจะเริ่มพังตั้งแต่ยังไม่ได้เขียน test ด้วยซ้ำ
-
แยก 3rd-party ออกจาก core system: อย่าให้ระบบหลักต้องพึ่งพา third-party ตรง ๆ เหตุผลง่าย ๆ คือเวลาเกิดปัญหา เราจะได้รู้ว่ามันพังเพราะ “ของเรา” หรือ “ของเขา” ในวงการ software มักจะแยก dependency เหล่านี้ด้วย layer บาง ๆ เช่น
- Database -> Data / Repository
- Queue หรือ Cache -> Provider / Client
- Third-party API -> Adapter / Facade
- HTTP handler -> Controller
พอแยกแบบนี้ เราจะสามารถ test เฉพาะส่วนได้ เช่น integration test ที่ระดับ adapter ก็เพียงพอแล้ว ไม่ต้องทดสอบทั้งระบบ
- unit ไม่ควรใหญ่จนเข้าใจยาก หรือเล็กจนกระจัดกระจาย: แนวคิดที่ใช้สังเกตคือ
- Code ต่อ unit มีประมาณไม่เกิน 500 บรรทัด (แล้วแต่ภาษา)
- ถ้ามี test case เกิน 100 case ต่อ unit ให้เริ่มสงสัยว่า design มันใหญ่เกินไป
- Developer คนอื่นอ่านแล้วเข้าใจได้ไหม เพราะถ้าคนในทีมยังไม่เข้าใจ unit นั้น ก็ยากที่จะ test หรือ maintain ต่อ
Bonus
ในภาษาอย่าง C#, Java, Kotlin เรามักใช้ IoC Container หรือ Dependency Injection (DI) เพื่อช่วยจัดการ dependency ให้ระบบ test ได้ง่ายขึ้น ไม่ต้องมานั่งแก้ constructor ทุกครั้งที่เพิ่ม dependency ใหม่ แต่ไม่ได้หมายความว่าถ้าไม่มี IoC จะทดสอบไม่ได้
ปิดท้าย
สุดท้ายแล้ว Testable Architecture ไม่ได้อยู่ที่ “จำนวน test case” หรือ “framework ที่ใช้” แต่มันอยู่ที่ “การออกแบบระบบให้ตรวจสอบได้ เข้าใจได้ และเปลี่ยนแปลงได้อย่างมั่นใจ” โดย Architecture ที่ดี คือ architecture ที่ test ได้ — และ test ที่ดี ก็จะสะท้อน architecture ที่ดี