Testable design: 3rd-party integration
ผมเคยได้ต้องทำระบบที่ติดต่อเข้ากับ 3rd-party ตัวนึง ซึ่งไม่มีอะไรมากเลยคือ เมื่อระบบอัพเดท ให้ส่งข้อมูลไปที่ 3rd-party ตัวนี้ด้วย
คำถามคือเราจะออกแบบ Architect ของโค้ดอย่างไรให้สามารถทดสอบได้?
เพื่อตอบคำถามนี้ผมจะพาขุดหา Thought Process อย่างละเอียดกันครับ
โจทย์ของผมมีง่ายๆ เลยคือ
เมื่อ User สมัครใช้ซอฟต์แวร์ของเรา ให้ระบบของเราทำการส่งข้อมูลของ User ไปที่ CRM (Customer relationship management) ที่ทีมเซลล์ใช้อยู่ เพื่อเป็นข้อมูลลูกค้าให้ทีมเซลล์ติดต่อกลับไป
เพื่อให้เห็น Thought process เราก็มาเริ่มจากโค้ดดิบแบบที่ง่ายที่สุดก่อน
คำถามคือ Requirement นี้เราจะเทสอย่างไรให้มั่นใจได้ว่าระบบเราส่งไป CRM แล้วจริงๆ กันนะ
Option 1: Mock API
ทางเลือกแรกก็คือเราสามารถใช้ Stub เพื่อทดสอบว่า Signup ได้มีการเรียก API อย่างถูกต้องหรือไม่ ซึ่งปกติถ้าใน Javascript ตัวที่ผมใช้บ่อยสุดก็จะเป็น Sinon
ซึ่งมันสามารถทดสอบได้ว่า Method Signup เรียก axios.post ไปหา https://crm.io/api/v1/users ด้วย Parameter ที่เรากำหนดหรือไม่
หน้าตาและคำอธิบายโค้ดจะเป็นไปตามนี้
วิธีการนี้จะรับประกันได้ว่า Signup ของเราส่งข้อมูลไปยัง crm.io จริงๆ ซึ่งส่วนมากวิธีการนี้ก็จะ Good enough สำหรับงานทั่วไป
Option 1: Refactoring code
เราจะพบว่าโค้ดของ Signup นั้นมีความรู้มากไป และขัดกับ Single responsibility principle (SRP)
ถามว่าความรู้มากไปคืออะไร คือ ถ้าเราทิ้งโค้ดไว้แบบนี้ คนที่จะเข้ามาแก้ไข function signup ในระบบเราได้ ต้องมีความรู้อย่างน้อยสุดสอง Domain
- เข้าใจว่าการ Signup ภายในระบบของเราทำงานอย่างไร
- เข้าใจว่า crm.io API หน้าตาอย่างไร การส่ง post ไปที่ crm.io ต้องใช้ข้อมูลอะไรบ้าง อยู่ที่ URL ไหน
SRP นั้นบอกว่า Method หนึ่งควรจะมีเหตุผลเดียวเท่านั้นที่เปลี่ยน แต่ Method นี้มีอย่างน้อยสองเหตุผลที่ไม่เกี่ยวข้องเลยที่จะต้องเปลี่ยน
- วิธีการ Signup ในระบบเปลี่ยน
- ทีมเซลล์เปลี่ยนระบบ CRM หรือต้องการข้อมูลเพิ่มเติมใน CRM ของเขา
ขัดกับ SRP อย่างชัดเจน
ทีนี้ต่อให้ไม่เอาหลักการมาพูดถึง ในเชิง Practical การแบ่งแบบนี้แปลว่าโปรแกรมเมอร์ที่จะ Debug หรือแก้ไข Signup ได้ ต้องรู้จักอย่างน้อย 2 โดเมนความรู้ เขาถึงจะมั่นใจได้ว่าเขาแก้ไขแล้วระบบไม่พัง
ดังนั้น เราแยกออกมาให้เป็นระเบียบดีกว่า
พอแยกออกมาแบบนี้เราจะจัดการงานได้ง่ายกว่าเดิม
ถ้ามี Ticket เข้ามาว่า “ระบบมีปัญหา มันไม่ยอมส่ง User เข้า CRM” เราก็ให้ Developer ที่มีความรู้เกี่ยวกับ crm.io เข้าไปแก้ createCrmUser
ถ้ามี Ticket เข้ามาว่า “ระบบ Signup ไม่ได้” เราก็ให้ Developer ที่มีความรู้เกี่ยวกับ Signup flow ไปแก้ไข signup
เป็นระเบียบเรียบร้อย บริหารงานง่ายเลยทีเดียว
Option 1: Refactoring test
จะเห็นว่าพอเราจัดระเบียบแบบนี้ Test เดิมที่ Test ว่า
“Method Signup เรียก axios.post ไปหา https://crm.io/api/v1/users ด้วย Parameter ที่เรากำหนดหรือไม่”
ก็จะกลายเป็นการทดสอบ 2 Method พร้อมกัน
การเขียนเทสแบบนี้มันจะมีความไม่เหมาะสมกับ Code ชุดใหม่หลัง Refactor ในแง่ที่ว่า ถ้าเราไว้ใจให้ createCrmUser
เป็นคนจัดการการติดต่อกับระบบ CRM แล้วเนี่ย สิ่งที่ Signup
ควรจะทำจริงๆ มีแค่การเรียก createCrmUser
ส่วนการ post ไปหาระบบให้ถูก URL และถูก Parameter ควรจะเป็นเรื่องของ createCrmUser ต่างหาก ส่วนถ้า crm.io จะเปลี่ยน API เป็น v2 จะเปลี่ยน Parameter ที่ใช้ ตัว createCrmUser
ควรจะรับผิดชอบ ไม่ใช่ทำให้ test ของ signup
พัง
พอคิดแบบนี้ เราก็จะแก้โค้ดให้เป็นดังนี้
แทนที่เราจะ Stub axios เราก็ Stub ในส่วนของ SignupService แทน
จริงๆ จุดนี้เราจะพบว่ามันมี Code smell อย่างนึงคือ ทำไม SignupService ถึงต้องทดสอบการเรียกภายในของตัวเอง แล้วทำไมมันต้องมี 2 Domain ความรู้ ซึ่งตรงนี้เราก็แก้ไขเลยก็ได้ แต่ผมจะทิ้งไว้ก่อน เพราะ Smell นี้จะชัดเจนมากขึ้นเมื่อเราไปต่อ
Option 2: Cross-check
โดยปกติการทำ Stub ก็ถือว่าใช้ได้ระดับนึงแล้วในระบบทั่วๆ ไปครับ
แต่ผมเจอปัญหาที่ลึกกว่านั้น
ผมได้รับ Ticket ว่า ระบบส่งข้อมูลไม่เข้า crm.io ทั้งๆ ที่ Test ผ่าน
พอขุดลงไป crm.io เป็นระบบที่ยังไม่มีเสถียรภาพทาง Interface แล้วบางครั้งเขาก็เปลี่ยนโดยไม่ให้เรารู้ เช่น ผมเคยทดสอบว่า createCrmUser
ต้องส่งข้อมูล email, firstname, lastname แต่ตอนหลังเขาบอกว่า require id ด้วย แล้วผมไม่ได้เทสไว้ ซักพักบอกว่าเขาเปลี่ยน firstname
เป็น firstName
หรือบางครั้งระบบเขาก็ล่มเอาดื้อๆ เลย
เอาล่ะ ทีนี้เราจะทดสอบยังไงให้รับประกันว่าของเดิมเราส่งได้กันล่ะครับ ในกรณีที่ 3rd-party ของเรามันยังไม่ค่อยมีเสถียรภาพ (หมายเหตุ: มันจะมีวิธีการออกแบบเพื่อแก้เรื่องนี้อีกทีนึง ซึ่งผมจะเขียนบล็อกหน้า)
ผมตัดสินใจทำการ Cross-check คือ
การส่งข้อมูลไปที่ crm.io จะสำเร็จ ก็ต่อเมื่อเราสามารถดึง user จาก crm.io ได้
เราไม่สามารถเข้าไปเจาะดูฐานข้อมูลของ crm.io ได้ แต่เราพอจะสังเกตการณ์ (Observe) จากภายนอกได้ว่า ข้อมูลเข้าระบบมั้ย
โค้ดที่ได้ก็จะหน้าตาแบบนี้ครับ
โอเค ทีนี้ผมมั่นใจแล้วว่า ข้อมูลส่งเข้าไป เข้าจริงๆ ดึงออกมาได้นะ
Option 2: Refactoring
พอเรา Cross check แบบนี้ เราจะพบว่าโค้ดไม่มีระเบียบเป็นอย่างมาก เพราะ
createCrmUser
อยู่ใน SignupService- ความรู้เกี่ยวกับการจัดการ CRM ว่าต้องใช้ url ไหน เข้ายังไง Parameter อย่างไร กระจัดกระจายไปทั่วโค้ด ส่วนนึงอยู่ใน createCrmUser อีกส่วนอยู่ใน Test ของ
createCrmUser
ตรงนี้จะตรงกับ Code smell ที่ผมพูดถึงเมื่อครั้งที่แล้วว่า SignupService มีความรู้สองโดเมน แต่ตอนแรกยังไม่ชัดเพราะมันรู้แค่ “ทำอย่างไรถึงจะสร้าง User ใน CRM ได้” ก็พอแล้ว
แต่ตอนนี้คือกลายเป็นว่าเพื่อให้เขียนเทสได้ SignupService และ Test ของมัน ต้องรู้เกี่ยวกับ crm.io เยอะมาก ทั้งการสร้าง การดึง การลบทิ้ง
ตอนนี้ Smell ชัดมากว่าคนที่จะดูแล SignupService ได้ ต้องรู้จัก crm.io อย่างลึกซึ้ง ซึ่ง เราไม่อยากให้เป็นแบบนั้น
เราจึงมาจัดระเบียบกันเป็น 2 Service โดยที่มีกฎว่า
- SignupService จะให้โปรแกรมเมอร์ที่เชี่ยวชาญและเข้าใจ Signup flow ดูแล โดยเขาคนนี้ไม่ต้องรู้จัก crm.io ก็ได้
- CrmService จะให้โปรแกรมเมอร์ที่เชี่ยวชาญและเข้าใจ crm.io ดูแล โดยเขาคนนี้ไม่ต้องรู้ว่า Signup อย่างไรก็ได้
อันนี้แหละที่เข้าหลัก Single Responsibility Principle และเป็นเหตุผลว่าทำไมหลักการนี้จึงช่วยให้การทำงานในระดับ Team มีประสิทธิภาพมากขึ้น
“SRP ช่วยกำหนดกรอบความรู้ ทำให้เรามั่นใจได้ว่ากรอบความรู้กว้างขนาดใด จะสามารถยุ่งกับส่วนไหนของระบบได้”
ซึ่งในระบบใหญ่ กรอบความรู้ตรงนี้สำคัญมากๆ
เพราะถ้าทุกคนในทีม ต้องรู้ทุกอย่างก่อนจะแตะโค้ดบรรทัดใดๆ ได้อย่างมั่นใจ เราจะเดินไปไหนไม่ได้ ตัวทีมงานหรือผู้นำทีม Development Lead, Engineering Management ต้องเข้าใจว่า การแตะส่วนใดของระบบ ต้อง Onboard ต้องให้ความรู้ขนาดไหน ขอบเขตอยู่ตรงไหน
“และนี่คือเหตุผลนึงที่สนับสนุนว่า คนที่ทำงานทางด้านบริหารซอฟต์แวร์ควรจะเข้าใจเรื่อง Refactoring และหลักการการโค้ดดิ้งอย่างลึกซึ้ง”
เพื่อให้แยกอย่างเป็นระเบียบหลักการที่กล่าวไว้ข้างต้น ผมจึงจัดแยกทั้งโค้ดและเทส ได้ผลออกมาตาม gist ต่อไปนี้
ตอนนี้ผมก็มั่นใจแล้วว่า
- ระบบส่งข้อมูลไป crm.io จริงๆ พิสูจน์จากการที่ cross-check ระหว่างการส่งข้อมูลและการดึงข้อมูล
- ถ้าผมจะให้ใคร แม้แต่ตัวผมเอง ทำงานกับ crmService.js เขาคนนั้นแค่รู้เกี่ยวกับ crm.io ก็มากเพียงพอแล้ว
- ถ้า crm.io เปลี่ยน Interface ผมไม่ต้องยุ่งกับ SignupService ผมชี้ได้เลยว่า “น้องก็ไปแก้ crmService.js นะ” จบ
- ถ้า Flow signup เปลี่ยน ผมก็บอกได้ว่าไปยุ่งกับ SignupService.js อย่างเดียวพอ ไม่ต้องไปทำอะไรกับ crmService.js
เป็น Design ที่ทั้ง Testable และ Maintainable
สรุปโค้ดตอนนี้คือ
- เรามี SignupService ดูแลการ Signup โดยคนที่ Maintain Service นี้ต้องมีความรู้เกี่ยวกับ Signup flow และรู้ว่าการ Singup จะต้องส่งข้อมูลไปให้ CrmService ในตอนสุดท้าย
- เราทดสอบ Requirement ของ SignupService ว่า “คุณต้องส่งข้อมูลให้ระบบ CRM ด้วย” โดยการ Stub แล้วเทสว่ามันเรียก CrmService หรือไม่
- เรามี CrmService ดูแลการส่งข้อมูลไปที่ CRM โดยคนที่ Maintain Service นี้ต้องมีความรู้เกี่ยวกับ crm.io
- เราทดสอบ CrmService ด้วยการ Cross-check
Design Heuristics
ทีนี้อ่านมาถึงตรงนี้ จะเห็นว่า Thought process ก่อนหน้ามาถึงจุดสุดท้ายมันเยอะมากๆ มี โค้ด-คิด-แก้ แล้วก็ โค้ด-คิด-แก้ แล้วก็ โค้ด-คิด-แก้ หลายรอบมากๆ
หลายคนอาจจะสงสัยว่า “ถ้าพี่จะคิดเยอะขนาดนี้ก่อนโค้ด จะทำงานเสร็จทันเหรอ”
ผมใช้เวลาเขียนบทความนี้ประมาณ 3 ชั่วโมง แต่ตอนโค้ดจริง (ทั้งโค้ดและเทส) ผมจำคร่าวๆ ได้ว่าน่าจะ 15–40 นาที
ประเด็นคือในบทความ ผมพาลงลึกไปถึง Thought Process ทางเลือก ความเป็นไปได้ ข้อดีข้อเสียของมัน ซึ่งก็เหมือนการ Retrospective แต่เวลาทำงานจริง ประสบการณ์และ Thought Process ทั้งหมดนี้มันทำให้ผมสร้าง Heuristic ขึ้นมาแล้ว
ดังนั้น ตอนเขียนจริง ผมเขียนแยก CrmService, SignupService ทันทีตั้งแต่ต้น เพราะมี Heuristic หรือหลักการในใจที่เรียนรู้ผ่านประสบการณ์มาแล้ว
ผมขอแชร์หลักการ Design ระบบกับ 3rd-party ให้สามารถ Testable, Maintainable จะมีหลักการทั่วไปดังนี้
- สร้าง Thin wrapper ครอบ 3rd-party เสมอ (กรณีนี้คือ CrmService) อย่าให้ Method ที่เป็น Business Logic ของระบบภายในของเรา (กรณีนี้คือ Signup) ไปเรียก 3rd-party ตรงๆ อย่างใช้
Axios.post
ตรงๆ แบบนี้คือไม่ถูก - เพื่อยืนยันการเชื่อมต่อกับ 3rd party ตอนเขียนเทส ให้เทสว่าตัว Business Logic ของระบบภายในของเรา ว่ามันเรียก Thin wrapper ข้างต้นหรือไม่
- Thin wrapper จะเทสก็ต่อเมื่อ 3rd party ไม่เสถียร
- การ Test ง่ายที่สุดคือ Cross-check ระหว่างหลายๆ API ของ 3rd-party
อันนี้คือวิธีการ Design Heuristic ที่ผมใช้ สำหรับการวาง Architect ของโค้ดในส่วนที่ต้องการติดต่อกับ 3rd-party ครับ
จะเห็นว่า Heuristic ง่ายๆ ก็มี Thought behind อยู่เยอะมากทีเดียว
Moral
บทความนี้ผมได้ลองขุด Thought process และ Option ทั้งหมดในการทำโค้ดติดต่อ 3rd-party ได้ทบทวน SRP และได้แชร์ Heuristic ที่ใช้ในการเขียนโค้ดติดต่อกับ 3rd-party
สิ่งที่ผมอยากให้คนอ่านได้ไปก็จะมี
- เข้าใจว่า SRP มีไว้ทำไมอย่างลึกซึ้ง
- สามารถเขียนโค้ดและเขียนเทสในระบบที่จำเป็นต้องติดต่อกับ 3rd-party ได้ และเข้าใจว่าทำไม Heuristic ถึงเป็นแบบนี้อย่างลึกซึ้ง เข้าใจไปถึงขั้นที่ว่า ถ้าไม่ทำตามจะเกิดอะไรขึ้น ข้อดีข้อเสียคืออะไร มิใช่แค่เพียงท่องจำหลักเท่านั้น
- จากที่ผมแชร์เรื่องการเลือกวิธีเทส อยากให้เข้าใจว่าระดับความลึกของการออกแบบชุดทดสอบ มันขึ้นอยู่กับความ “ไว้ใจได้” ของระบบข้างนอกด้วย มันไม่ได้มีข้อกำหนดตายตัว
- เสริมว่า อยากให้เข้าใจว่าทำไมผมชอบยืนยันว่าคนที่บริหารทีมโปรแกรมเมอร์ต้องเข้าใจโค้ด
หวังว่าจะได้เห็นภาพและเข้าใจมากขึ้นครับ