June 18, 2016

เริ่มเขียน Unit Testing ด้วย Swift

เท่าที่เคยสนทนากับหลายคนเกี่ยวกับการเขียน Unit Testing มา พบว่ามีคนอยู่ 2 กลุ่ม คือ

กลุ่มที่ 1 ไม่พูดถึง :D
กลุ่มที่ 2 คือ คนที่เห็นคุณค่าของการเขียน test อยากเขียน test แต่ยังไม่เขียน เพราะไม่รู้จะเริ่มต้นยังไงดี บางคนเคยลองเริ่มต้นเองดูแล้วแต่ไม่สามารถจับทิศทางหรือเทคนิคได้ ก็เลยเลิกล้มไปในที่สุด

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

สำหรับการเขียน Test ให้กับ function ที่ไม่ได้มี dependency เลย นั้นทุกคนมักเขียนได้ เพราะมันค่อนข้างตรงไปตรงมา เช่น function ที่มีการรับค่าเข้าไปคำนวนได้ด้วยตัวเอง และ return กลับมาก็จบ แต่ที่เคยรู้มาคือปัญหาจะเกิดขึ้นตอนที่ function ที่ต้องการทดสอบนั้นมี dependency นี่แหละที่หลายคนมักจะงงกันว่าเราจะทดสอบมันยังไง

นี่คือ 4 เรื่องหลัก ที่เราต้องคิดถึงมันตลอดเวลาที่เราเขียนเทส และออกแบบ production code ของเรา

  1. static dependencies ควรถูก inject เข้าไปตอน initialize object
  2. ทุกอย่างคือ protocol และ class ที่เราสร้างขึ้นมา mock ไม่จำเป็นต้องรู้อะไรเกี่ยวกับ concrete class เลย
  3. การ mock ก็คือการ implement protocol
  4. ใช้ Equatable object เพื่อให้เราสามารถใช้ XCTAssertEqual() ได้อย่างสวยงาม

ก่อนเข้าเรื่องของเรา สมมติว่าเราออก service layer object ไว้ 4 class แบบดังนี้

BeerService - ดึงข้อมูลของ beer จาก id ที่ได้รับเข้ามาโดยมีขั้นตอนการทำงานดังนี้
1. ส่ง id ต่อให้ BeerAPI ให้สร้าง path ของ service ที่จะเรียก
2. BeerAPI สร้าง path เสร็จแล้วก ็จะดึง JSON มาจาก server จาก path ที่สร้างได้
3. จากนั้นพอได้ JSON มาแล้วก็ส่งต่อให้ BeerParser สร้าง Beer instance และส่งกลับไปให้ BeerService
4. BeerService return Beer instance กลับออกไป ให้ user

BeerAPI - ทำหน้าที่ดึง JSON จาก server ด้วย path ที่ตัวเองสร้างขึ้นมา
BeerParser - ทำหน้าที่สร้าง instance ของ Beer จาก JSON ที่ได้รับ
Beer - ก็คือ data model ของเบียร์

เร่ิมจาก Inject Static Dependencies
แนวคิดของ Dependency Injection เบื้องต้นก็คือ object ไม่จำเป็นต้องสร้าง instance variable ของตัวเอง แต่มันจะถูกสร้างจากที่ไหนสักที่ แล้วโยนมาให้ object ใช้งาน

ถ้าเราเขียนโค้ดใน BeerService ไว้แบบนี้


เวลาเราจะเขียน Test เราจะเขียนได้โค้ดแบบนี้


จากโค้ดเราพบว่าการทำงานใน function beer() -> Beer นั้น เรียกใช้ beerAPI โดยการกำหนด parameter ลงไป ซึ่งเวลาเราเขียน Test เราจะไม่สามารถควบควมการทำงานของ beerAPI และ beerParser ได้เลย เพราะทั้ง 2 ถูกสร้าง และใช้งานใน function BeerService เสร็จสรรพ

ปัญหาก็คือ ถ้าเราทดสอบ func Beer() จะเกิดคำถามว่า...

ถ้าหาก Test fail ขึ้นมา เราจะรู้ได้อย่างไรว่าการทำงานใน func beer() ผิด หรือการทำงานของ beerAPI ผิด หรือการทำงานของ beerParser ผิดกันแน่ ทั้ง 2 (beerAPI, beerParser) เป็น dependency ของ BeerService ที่เราไม่สามารถควบคุมมันได้เลยตอนเราเขียน Test
และถ้าเราอยากตรวจสอบว่า beer ที่ถูกสร้างขึ้นถูกต้องตาม JSON ที่ได้รับมาจาก beerAPI หรือไม่ จะทำอย่างไร? เราไม่สามารถควบคุมอะไรได้เลยใช่ไหม?

เหตุการณ์นี้ทำให้เราสูญเสียความควบคุม อะไรที่เราควบคุมมันไม่ได้ เรามักทดสอบมันได้ยาก (หรือทดสอบไม่ได้เลย)

คราวนี้ ลอง inject beerAPI เข้าไปดูแบบนี้


พอเราเปลี่ยนโค้ดเป็นแบบนี้ ทำให้เราสามารถส่ง beerAPI เข้าไปข้างในได้ตอนเขียน Test ได้ แบบนี้


พอเราใช้วิธีส่ง beerAPI เข้าไปแบบนี้ ก็ทำให้เราสามารถสร้าง BeerAPI ยังไงก็ได้ตามใจเราเพื่อทดสอบสถานการณ์ต่างๆ ได้เองได้แล้ว เช่นเราอาจสร้าง subclass ของ BeerAPI แล้วไป override method json() ให้ return สิ่งที่เราคาดหวังได้ หรือ attribute บางตัวของ BeerAPI เพื่อลองกำหนดค่าตั้งต้นต่างๆ เองตามต้องการ ก่อนส่งไปให้ BeerService ใช้งาน แล้วดูผลลัพธ์ว่าพฤติกรรมของ BeerService จะเปลี่ยนไปอย่างไร

วิธีที่นิยมอีกอันก็คือการ mock object โดยการทำให้ BeerAPI conform to protocol ที่เราออกแบบนั่นเอง กรณีนี้เราต้องการให้ BeerAPI conform กับ protocol ชื่อ API แบบนี้


พอทำแบบนี้แล้ว ทางฝั่ง BeerService ก็ไม่ต้องสนใจแล้ว ว่า object ที่ถูก inject เข้ามานั้นเป็น class อะไร จะมองแค่ในมุมของ protocol อย่างเดียวก็พอแล้ว แบบนี้


ทีนี้ หากเราต้องการ mock การทำงานของ func json() เราก็สามารถทำได้โดยการสร้างคลาสที่ conform protocol API มาเท่านั้น ทำให้เราสามารถควบคุมการทำงานของ func json() ได้เองจากภายนอก BeerService แบบนี้


Set Default Initialized Parameters

จากที่อธิบายมา จะเห็นว่าทุกๆ ครั้งที่เราสร้าง object ของ BeerService เราจำเป็นต้องส่ง dependency เข้าไปเสมอ ซึ่งถ้าหากเขียนโค้ดนี้ตอนเขียน Test ก็ยังโอเคดีอยู่ เพราะเราต้องการ control dependency จากภายนอก ให้เป็นไปตามที่เราต้องการ แต่สำหรับ production code แล้ว การทำแบบนี้เสมอๆ มันไม่สนุกเลย และโค้ดก็ไม่สวยงามด้วย

เราจะแก้ปัญหานี้ได้โดยการกำหนด default parameter ให้ตอนสร้าง BeerService ซึ่งก็คือการบอกว่าแม้เราไม่จำเป็นว่าใส่ parameter เป็นอะไรตอน initialize ก็ให้มันสร้างขึ้นมาใช้เองได้เลยนะ แบบนี้


Make all value types Equatable

XCTest ได้จัดชุด function สำหรับใช้ทดสอบต่างๆ มาให้เราใช้มากมาย หนึ่งในนั้นที่เราน่าจะใช้กันบ่อยมากๆ ก็คือ XCTAssertEqual() ซึ่งถ้าเราใช้กับ primitive type นั้นจะไม่มีปัญหาเลย สามารถใช้ได้เลย แต่เมื่อไหร่ก็ตามที่เราต้องการเปรียบเทียบ object 2 ตัวว่ามีค่าเท่ากันหรือไม่จะไม่สามารถทำได้ หากจะทำได้นั้น เราต้องกำหนดให้ทั้ง 2 object นั้น conform to Equatable protocol ซะก่อน

Apple เองก็เคยบอกเอาไว้ว่า Swift value type ทุกตัวควร conform to Equatable ( https://developer.apple.com/videos/play/wwdc2015/414/)

การ conform to Equatable นั้นไม่ได้ยากอย่างที่คิด โดยเฉพาะใน Swift แล้วยิ่งง่ายเข้าไปใหญ่ แค่เรา implement the == function ยกตัวอย่างเช่นหากเราต้องการจะให้ object ของ Beer 2 object สามารถเปรียบเทียบกันได้ ก็เขียนโค้ดแบบนี้


ตอนเราเขียนเทส เราก็สามารถเอา beer ที่ได้จาก api มาเปรียบเทียบกับ beer ที่เราต้องการให้มันเป็นได้เลยตรงๆ แบบนี้


สรุป

ถ้ามี dependency ให้ inject มันเข้าไปตอน initialize object และให้มองโลกเป็น protocol ไว้ก่อน เพื่อให้เราสามารถ mock object ได้ และเพื่อความสวยงามตอนเปรียบเทียบ object ก็ให้ implement Equatable ไว้ซะก่อน

บทความตอนนี้ได้เขียนถึงขั้นตอนวิธีคิดเริ่มแรกเท่านั้น แท้จริงแล้วยังมีท่าต่างๆ ให้ทำมากกว่านี้ ซึ่งก็หวังว่าผมจะมีเวลาเขียนถึงมันในตอนถัดๆ ไปนะครับ ;)