in Technology

เมื่อ Unit Test ต้องทดสอบร่วมกับระบบอื่นๆ จะแก้ปัญหาอย่างไร?.. มารู้จัก Test Doubles กัน

หลังจากเขียนเรื่อง Unit Test ไปก็หลายบล็อก ไล่ตั้งแต่เรื่องพื้นฐานอย่าง การออกแบบระบบให้รองรับการทดสอบ (Testable) มาจนถึง วิธีการคิดและลงมือทำ Automated Test ด้วย Unit Test และ รูปแบบการทดสอบของ Unit Test ก็ยังเหลือเรื่องหนึ่งที่สำคัญมากและหลายคนอาจจะสงสัยตอนที่ทำ Unit Test นั่นก็คือ “จะทำอย่างไร เมื่อสิ่งที่เราทดสอบ อย่าง Class, Function มันต้องไปทำงานร่วมกับสิ่งอื่นๆ เช่น Class อื่นๆ, Function อื่นๆ, Database, API ตัวอื่นๆ ของเรา หรือ API ภายนอกระบบ” ..

ทบทวน “5 คุณสมบัติของ Unit Test ที่ดี” อีกครั้ง

ก่อนจะไปต่อ ขอทบทวนคุณสมบัติที่ดีของ Unit Test อีกรอบ ที่ควรจะต้องเป็นไปตามหลักการของ FIRST คือ

  • Fast: ทำงานได้เร็ว
  • Isolated: เป็นอิสระจากกัน
  • Repeatable: ทำซ้ำได้ ไม่ขึ้นกับระบบอื่นๆ เช่น API ภายนอก, Database, File System
  • Self-Verify: แสดงให้เห็นผลการทดสอบ ผ่าน หรือไม่ผ่านได้อย่างชัดเจน
  • Timely: เขียนให้ถูกเวลา คือ ควรมี Test ก่อน เพื่อให้ได้เข้าใจปัญหา และจึงเขียนโค้ดเข้ามาแก้ปัญหานั้น

คราวนี้ การที่ Unit Test ของเราต้องไปทำงานร่วมกับสิ่งอื่นๆ มันมักทำให้ขัดแย้งกับ 5 ข้อ ข้างต้น เช่น

  • เมื่อเราต้องการทดสอบ Function ที่ต้องต่อกับ Database มันก็จะทำงานไม่เร็ว เพราะ Query นาน, มันทำซ้ำไม่ได้ เพราะต้องเพิ่มลบข้อมูลจาก Database ตลอด แล้วไหนจะกระทบตัวอื่นๆที่อาจจะใช้ Database เดียวกันด้วย
  • เมื่อเราต้องการทดสอบ Function ที่ต้องต่อกับ API ภายในของเราเอง หรือภายนอก มันก็ทำงานไม่เร็ว เพราะมีเรื่องของความเร็ว Network/Internet ซึ่งถ้า Internet ล่ม ก็ทำซ้ำไม่ได้ หรือถ้าส่งข้อมูลไปให้แล้วบาง API ก็ส่งข้อมูลซ้ำไม่ได้ เช่น ส่งข้อมูลไป API สมัครสมาชิกที่มีเงื่อนไขสมัครด้วยอีเมลเดียวเท่านั้น
  • เมื่อเราต้องการทดสอบ Function ที่ต้องไปทำงานร่วมกับ Function อื่น แต่มันยังทำไม่เสร็จหรือไม่ได้ทำ เราก็ทดสอบไม่ได้อยู่ดี

ดังนั้น ถ้าต้องการให้ Unit Test ของเราทำงานได้ตามคุณสมบัติดังกล่าว เราจะต้องสร้างการทำงานสิ่งอื่นๆที่เกี่ยวข้องขึ้นมา ซึ่ง จะมีอยู่  5 ประเภท เรียกรวมๆ ว่า Test Doubles

Test Doubles คืออะไร

Image from: https://blogs.sap.com/

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

คำว่า Test Doubles มาจากหนังสือที่ลุง Gerard Meszaros เขาเขียนมันขึ้นมา โดยแปลงมาจากคำว่า Stunt Double ที่ใช้ในการทำหนัง ซึ่ง Stunt Double คือ สตั๊นแมนหรือนักแสดงที่มีหน้าตา รูปร่าง แต่งตัวคล้ายกับพระเอก แล้วออกไปแสดงแทนพระเอกในบางฉาก

คราวนี้พอมาเป็น Test Doubles โดยอธิบายมุมเดียวกับ Stunt Double มันก็คือ อะไรบางอย่างที่เราทำมันขึ้นมาเพื่อให้มันทำงานแทนของจริง ในขณะที่เราไม่ได้ต้องการทดสอบมัน อย่างเช่น API ของคนอื่น, Class/Function ของคนอื่น, Database  เป็นต้น

Test Doubles 5 ประเภท มีอะไรบ้าง

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

ปล. ตัวอย่างที่จะใช้ต่อไปนี้ เขียนด้วย .Net Core 2

1. Dummy

 

แปลตรงตัวคือ หุ่นเชิด, ของหลอกตา, คนโง่ เหมือนกับที่เคยมีข่าวว่ากล้องดัมมี่ คือมีแต่กล่องแปะไว้หลอกตาก็อุ่นใจ เช่นกัน ใน Test Doubles มันคือ อะไรก็ได้ ที่ใส่ไปแล้วระบบเราสามารถทำงานได้ หรือคอมไพล์ผ่าน

ถามว่าโง่ได้ขนาดไหน ก็เช่น สร้าง Class เปล่าๆ หรือ Function ที่คืนค่า null หรือ throw exception ก็ยังได้ โดยไม่ต้องสนใจว่ามันทำงานอย่างไร เราสนแค่ว่า ตัวที่เราจะทดสอบสามารถเรียกมันแล้วผ่านไปได้โดยไม่ติดขัด

ตัวอย่าง Dummy

ผมมีโค้ดสำหรับการ Login โดย Constructor Member() จะเป็นส่ง Username/Password ไปตรวจสอบ ซึ่งถ้าถูกต้อง ก็จะทำงานต่างๆ ได้ เช่น แสดงข้อความ “Welcome to member area” ใน method Profile()

ผมสร้าง Interface ขึ้นมาชื่อ IAuthorize และคลาสที่ถูกนำไปใช้งานคือ Authorize โดยภายในนั้นมี CheckAuthorize() ซึ่งจะทำการตรวจสอบว่า Login ถูกต้องหรือไม่

และผมสร้างคลาสอีกตัวชื่อ DummyAuthorize เพื่อที่จะใช้ทดสอบ โดยผมไม่ได้ return อะไรออกไปว่าเป็น true/false มีเพียงส่งค่าว่างเปล่าประเภท Boolean ออกไป

มาดูในไฟล์ Unit Test ซึ่งผมต้องการตรวจสอบว่าสามารถสร้าง Object ของ Member ได้หรือไม่ แต่ติดว่า มันต้องใส่ Username/Password ใน Constructor ด้วยทุกครั้ง ดังนั้น ผมจึงใช้ DummyAuthorize แทน เพื่อให้มันทำงานทะลุผ่านไปได้

นี่แหละครับ การทำงานของ Dummy ใส่อะไรไปแล้วใช้แทน Dependency ตัวอื่นๆ ได้ เพื่อทะลุไปทำงานอย่างอื่นต่อตามที่ต้องการ ซึ่งในตัวอย่างนี้ ผมลองเช็ค Type Object  Member ดู

2. Stub

Stub คือการกำหนดสถานะหรือสิ่งต่างๆให้เป็นไปตามที่เราต้องการ (State verification) โดยหน้าตาจะคล้ายกับ Dummy แต่ Stub เราจะต้องกำหนดค่าที่จะใช้ด้วย เพื่อให้ได้ผลลัพธ์ออกมาตามที่เราต้องการ

เช่น Test Case ของสมการ 1 + X จะต้องได้เท่ากับ 2 โดยที่ X คือ Function ภายนอกที่เราไปเรียกใช้ และผลลัพธ์จะต้องออกมาเป็น 2 เสมอ ดังนั้น เราจะต้องกำหนดค่า X ว่าต้องเป็น 1 เท่านั้น (ซึ่งต่างจาก Dummy ที่ไม่สนใจการระบุค่า)

และที่เราชอบเรียกกันว่า Mock ส่วนมากคือการทำ Stub นี่เอง เพราะเราเขียนโค้ดขึ้นมาเพื่อให้ได้ค่าอย่างใดอย่างหนึ่งนำไปใช้งานต่อ และแสดงผลลัพธ์ปลายทางที่เราต้องการ

ตัวอย่าง Stub

ผมอ้างอิงจากโค้ดชุดเก่าของ Dummy แต่เปลี่ยน Unit Test ใหม่ โดยผมต้องการตรวจสอบว่า เมื่อเรียกใช้ Profile() จะต้องแสดงข้อความ “Welcome to member area” ขึ้นมา

และเช่นกัน ผมสร้าง Class ชื่อ StubAuthorize จาก IAuthorize และใน CheckAuthorize() จะระบุเป็น true เสมอ เพื่อให้ Unit Test ผมแสดงข้อความ Welcome to member area” ขึ้นเสมอ

อาจจะมีความสงสัยว่า ถ้าเราใส่ Username/Password เข้าไปตอนสร้าง object Member เลยได้ไหม อย่างไรก็คืนค่าเป็น  true เสมอ ตอบเลยว่าได้ แต่มีจุดให้คิดเพิ่ม คื อ

  1. เราต้องการตรสวจสอบการเรียกใช้ Profile() ว่าแสดงผลถูกต้องหรือไม่ ในสถานะที่เราต้องการให้มันทำงานถูกต้อง ดังนั้นเราไม่ได้ต้องการตรวจสอบ CheckAuthorize() ว่าทำงานถูกต้องหรือไม่ อันนี้จะเขียนเป็นอีก Unit Test หนึ่ง (แต่ก็ไม่ผิดนะครับ เพราะเป็นรูปแบบ Unit Test – Sociable ซึ่งลองอ่านได้ที่ รูปแบบการทดสอบของ Unit Test)
  2. แน่ใจหรือไม่ว่า Username/Password ที่ใส่ค้างไว้ จะไม่เปลี่ยนในภายหลัง

3. Mock

ชื่อนี้สร้างความสับสนกันทั้งโลก ซึ่ง Mock ใน Test Doubles จะหมายถึง การทดสอบพฤติกรรมการทำงานของสิ่งๆหนึ่ง (Behavior Verification) อย่าง Class/Function เป็นต้น ว่าทำงานตามที่เราต้องการหรือไม่

กล่าวคือ Mock ไม่ได้สนใจผลลัพธ์ปลายทาง แต่สนใจการทำงานของสิ่งที่ต้องการทดสอบ เช่น ส่งค่าเข้าไปใน process แล้วค่านั้นมีการเปลี่ยนแปลงไปตามที่เราคาดหวังหรือไม่

ตัวอย่าง Mock

ระบบ Login ที่ผมสร้างไว้ จะระบุ Username/Password ผ่าน Constructure ชื่อ Member() จากนั้นจะทำการส่งต่อไปให้ CheckAuthorize() เพื่อตรวจสอบว่าถูกต้องหรือไม่ โดยถ้า ถูกต้อง มันจะแสดงผลออกมาที่ Profile() ว่า “Welcome to member area”

ดังนั้น Unit Test ผมจะมีหน้าตาประมาณนี้ ซึ่งผมจะ Assert ไปสองค่าว่า มีการใช้งาน CheckAuthorize() และผลลัพธ์ที่ได้คือ “Welcome to member area”

จากนั้นผมสร้าง Class ชื่อ MockAuthorize จาก IAuthorize โดยผมสร้างตัวแปร checkAuthorizeWasCalled ขึ้นมา เพื่อใช้ตรวจสอบการทำงาน

ซึ่งถ้าพฤติกรรมการทำงานของโค้ดผมถูกต้อง จะต้องมีขั้นตอนคือ

  1. ส่ง Username/Password ผ่าน Constructure ชื่อ Member()
  2. จากนั้นส่งต่อไปให้ CheckAuthorize()
  3. เปลี่ยนค่าตัวแปร checkAuthorizeWasCalled จากค่า  false เป็น true
  4. โยนกลับมาที่ Profile() ให้แสดงผลลัพธ์

เป็นอันว่าพฤติกรรมของ Login ของผมมีการเรียกใช้ CheckAuthorize() เพื่อตรวจสอบ Username/Password ถูกต้องนะ

ตัวอย่าง Mock โดยการใช้ Moq4

คราวนี้เมื่อเราเข้าใจวิธีการปกติแล้ว ลองดูตัวอย่างโค้ดของการใช้ Library ชื่อ Moq4  ซึ่งจะทำให้ง่ายขึ้น เพราะสร้างตัว Mock CheckAuthorize() ไว้ใน Unit Test ได้เลย จากนั้นผม Create Object ขึ้นมา และทำการ Verify ว่ามีการเรียกใช้ CheckAuthorize() เกิดขึ้นหรือไม่

4. Spy

ชื่อนี้แปลตรงตัวคือ สายลับ โดยเราจะส่งสายลับไปดูว่า ระบบของเรามีการเรียกใช้ Function ที่เราต้องการทดสอบจริงๆไหม ซึ่งคล้ายกับ Mock แต่แค่ไม่ได้สนใจพฤติกรรมว่าทำอะไรไปบ้าง ต้องได้ค่าอะไร (Exclusive Behavior Verification)

เช่น เรามีระบบรายชื่อที่จะต้องส่งอีเมลออกไป 100 รายการ ระบบของเราจะต้องมีการเรียก Function การส่งอีเมลเป็นจำนวน 100 รอบ ตามที่ต้องการจริงๆ

ตัวอย่าง Spy

ผมทำการสร้าง object Member ขึ้นมาสองครั้ง โดยมันจะต้องไปเรียกใช้งาน CheckAuthorize() จำนวนสองครั้งเช่นกัน ดังนั้นผมจึงทดสอบว่ามีการเรียกใช้งานจำนวนสองครั้งจริงหรือไม่

จากนั้นผมสร้าง Class ชื่อ SpyAuthorize จาก IAuthorize โดยผมสร้างตัวแปร checkAuthorizeWasCalled ขึ้นมา เพื่อใช้นับจำนวนการเรียกใช้งาน

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

5. Fake

ชื่อก็บอกอยู่ว่าคือการโกหกหลอกลวงนะฮะ ฮ่าๆ มันคือการจำลองอะไรบางอย่างให้เสมือนจริง เหมือนมากจนเป็นข้อเสียหนึ่งที่เราอาจจะสับสนเอง โดยเหมาะกับใช้ในกรณีที่ไม่สามารถทำบน Production หรือระบบที่เราไม่ต้องการให้เกิดผลกระทบ

โดยกรณีนี้ถูกยกตัวอย่างขึ้นมาชัดเจน ก็คือ การใช้ In-Memory เก็บข้อมูลสำหรับทดสอบ แทนการใช้ Database จริงๆ  เป็นต้น

เช่น ระบบมีการสร้าง Order ไว้ใน Database แต่ไม่อยากสร้างจริงใน Database เพราะจะทำให้เลข Order ID ถูกเพิ่มจำนวนเข้าไป และก็ห้ามลบออกด้วย เพราะ Order ID จะขาดช่วง, ดังนั้นเราจะต้องสร้าง Database จำลองขึ้นมา ซึ่งถ้าให้ทุกเครื่องต้องทดสอบได้ และมีความเร็วมากๆ เราก็ต้องใช้ In-Memory นั่นเอง

ตัวอย่าง Fake

ผมต้องการทดสอบว่า Function ชื่อ Get_Member_Information_By_ID() สามารถส่ง Member ID เข้าไป และคืนค่าจาก Database เป็นไปตามที่ผมกำหนดหรือไม่ ดังนั้น แทนที่ผมจะเรียกใช้ Database จริง ผมจึงสร้าง In-Memory Database ขึ้นมาก้อนหนึ่งชื่อ get_by_id_members และทำการใส่ข้อมูลเข้าไปก่อน จากนั้นส่ง Member ID 1 เข้าไปให้ Get_Member_Information_By_ID และทำการตรวจสอบว่า ได้ชื่อ เบอร์โทร วันเดือนปีเกิด ตามใน Database จริงๆหรือไม่

สรุป

Test Doubles มี 5 ประเภท ที่จะนำไปเลือกใช้ได้ตามสมควร

  1. Dummy: ใช้อะไรก็ได้เพื่อ By Pass การทำงาน แทนการใช้ Dependency จริง
  2. Stub: กำหนดค่าบางอย่าง เพื่อให้ได้ผลลัพธ์เป็นค่าที่เราต้องการเสมอ
  3. Mock: เพื่อ verify ว่า process ที่เรียกใช้ มีการส่งค่าไปหา Dependency ตรงตามที่เราคาดหวังหรือเปล่า
  4. Fake: จำลองบางอย่างขึ้นมาเพื่อใช้แทนของจริง
  5. Spy: เพื่อ verify ให้แน่ชัดในการใช้ process เช่น โดนเรียกใช้ไปกี่ครั้ง หรือพารามิเตอร์อะไรถูกส่งไปบ้าง

หากต้องการเขียน Unit Test เราเลี่ยงไม่ได้ที่จะต้องเรียนรู้เรื่อง Test Doubles เพื่อตัดการเกี่ยวข้องกับสิ่งต่างๆ ทรี่ไม่จำเป็นออก (Dependency) เพราะว่า เราต้องการเจาะจงทดสอบเพียงอย่างเดียวเท่านั้น อย่างอื่นที่ไม่ทดสอบ เราก็จำลองขึ้นมาโดยใช้ Dummy, Stub, Fake ตามแต่จะเลือกใช้ และเราก็ใช้ Mock, Spy เพื่อทดสอบว่าโค้ดที่เขียน มันได้ทำงานถูกต้องจริงๆใช่ไหม ซึ่งวิธีการนี้ นำไปใช้ได้กับทุกภาษาโปรแกรม

และถ้าสังเกตให้ดี จะใช้ Test Double ได้ จำเป็นต้องรู้เรื่อง Unit Test,  Dependency-Injection และการเขียนโปรแกรมแบบ  Object-Oriented ด้วยนะฮะ ไม่เช่นนั้น เข้าใจยากเลยทีเดียว

อ้างอิง

มาคุยกัน

Comment