5 Type of Test Double with GO

Pawut Jingjit
5 min readDec 12, 2022

--

เมื่อ Code ขนาดใหญ่ขึ้นเรื่อยๆ เพื่อนๆทุกคนคงเคยเจอ

  • เขียน Function A แล้ว Function B พัง
  • แก้ Functoin B เสร็จ Business มาขอให้แก้ Function C
  • Function C เขียนเสร็จ Function A พังอีก รู้ตัวอีกทีก็ Production เสียแล้ว

เวลาจะเพิ่ม 1 Function จะ Mannual ใหม่ทั้งระบบ คงจะเป็นเรื่องที่เสียเวลามากเกินไป ทีมของเพื่อนๆจึงเริ่มที่จะเขียน Unit Test กัน ซึ่งจะนำไปสู่ปัญหาใหม่

  • ต่อกับ Database จริง หลังจากการ Test ข้อมูลที่ Mock ขึ้นไปโผล่ใน Database
  • ต่อกับ Internet ถ้า Internet ต่อไม่ติด คือ Test ไม่ได้
  • ตอนที่พัง รู้แค่พัง แต่ไม่รู้ว่าพังที่ Function ไหน

เพื่อนๆเริ่มคิดว่า เราไม่ควรจะใช้ Dependency จริงในการ Test อีกแล้ว นี่จึงเป็นจุดเริ่มต้นของ Test Double

TL;NR

  • F.I.R.S.T Principles คือ คุณสมบัติที่ดี 5 ข้อของการทำ Unit Test มี
  • Test Double คือ สิ่งใดๆที่แทนที่ Production Object เพื่อจุดประสงค์ในการ Test แบ่งเป็น 5 ประเภท
  • Dummy คือ Test Double ที่ถูก Pass เข้าไปแต่ไม่ได้ใช้เลย ปกติใช้เพียงแค่เพื่อให้ Parameter ครบ
  • Stub คือ Test Double เข้าไปเช่นเดียวกับ Dummy แต่แทนที่จะไม่ได้ใช้อะไร จะ return ค่า ที่กำหนดไว้ใน Contractor ใน Test Function แทน
  • Spy คือ Stub ที่ทราบได้ว่าตัว Spy เองถูกเรียกใช้ (Called) หรือไม่ (รวมไปถึงทราบว่าถูก Called กี่ครั้ง)
  • Fake คือ Stub ที่ Implement Logic ในการ return ค่า (แทนที่จะ return ได้ค่าเดียว) ตัวอย่างที่ชัดที่สุดคือการใช้ InMemoryDatabase แทน DataBase
  • Mock ยังเป็นที่ถกเถียงกันถึงคำนิยาม แต่เบื้องต้นคือมีพฤติกรรมของ Test Double ใดๆรวมกัน และมี Verify Function ที่ทำงานใกล้เคียงกับ Spy

F.I.R.S.T Principles

คุณสมบัติที่ดีของการทำ Unit Test ประกอบด้วย

  • Fast(ทำงานได้เร็ว) : ระบบใหญ่ๆ Unit Test หลักพัน จะทำงาน Unit ละ 1 วินาทีคงไม่ได้
  • Independent(เป็นอิสระต่อกัน) : แต่ละ Unit Test ต้องไม่มีผลกับ Unit Test ตัวอื่น ถ้า Test A ต้องทำงานก่อน Test B วันดีคืนดี Test A หาย Test B จะพังไปด้วย
  • Repeatable(ทำซ้ำได้) : บน Local, บนเครื่องเพื่อน, บน Docker ต้องทำงานได้เหมือนกันหมด
  • Self-Checking(ตรวจสอบตัวเองได้) : return true เมื่อ test ผ่าน , return false เมื่อ test ไม่ผ่าน
  • Timely(ทันเวลา) : เขียนหลังจากเอา Product ขึ้นไปแล้ว คงไม่มีประโยชน์อะไร (เพราะคงเจอบัคบน Production ไปแล้ว)

สังเกตว่า การที่เราต่อกับ Dependency จริง จะขัดกับ F.I.R.S.T Principles ในหลายๆข้อเลย

Test Double

is a generic term for any case where you replace a production object for testing purposes — Gerard Meszaros

สิ่งใดๆที่แทนที่ Production Object เพื่อจุดประสงค์ในการ Test

จากเหตุผลที่เกริ่น เพื่อนๆคงจะเข้าใจแล้วว่า เราไม่ควรจะให้ “สิ่งที่เราต้องการทดสอบ” นั้นใช้ Dependency จริง (Production Object) ในการทดสอบ เราจึงต้องสร้าง Object ปลอมๆขึ้นมาเพื่อจำลอง Dependency นั้น

Dependency ที่เราจำลองมานั้น มีชื่อว่า Test Double

5 Type of Test Double

Test Double นั้นสามารถแบ่งได้เป็น 5 ประเภทด้วยกัน อย่างไรก็ดี ในหลายๆตำรานั้นนิยาม Test Double แต่ละประเภทไว้แตกต่างกัน โดยในบทความนี้ จะขอใช้คำนิยามของ Gerard Meszaros เป็นหลัก

  • Dummy
  • Stub
  • Spy
  • Fake
  • Mocks

Example Service

ในบทความนี้ เราจะใช้ Test Double แบบต่างๆเพื่อสร้าง Unit Test ของ FindProductByID Service

โดย FindProductByID เจ้าของบทความ Implement ตาม Repository pattern ซึ่งเพื่อนๆที่เคยใช้ Spring น่าจะคุ้นเคยกันดี

อนึ่ง ถ้าไม่คุ้นเคย สามารถเข้าใจได้เลยว่า Repository เป็น Dependency ที่ใช้สำหรับการติดต่อกับ Database ส่วน Service เป็นส่วนของ Business Logic

นิยาม Product
Struct ใดๆที่มี FindById นั้นถือว่าเป็น IProductRepository
FindByID Service รับ ID มา แล้วเรียก FindById ใน Repository ตรงๆเลย ถ้า ID เป็น “” จะ Return Error

ซึ่งใน Case นี้ Business Logic นั้นค่อนข้างง่าย คือ FindByID ซึ่งตรงกับส่วนของ Programming Logic พอดี จึงสามารถนำค่าที่ Return จาก Repository ไปใช้ได้เลย (ถึงกระนั้น ยังต้องจัดการ Error นะ)

ซึ่งการทำงานจริง Business Logic ย่อมซับซ้อนกว่านี้มาก เป็นเหตุผลว่า ทำไมถึงเราต้องทดสอบส่วนของ Business Logic ด้วย (และหวังว่าจะเป็นการตอบคำถามของเพื่อนๆว่า จะสร้าง Service มาเพียงเพื่อส่งค่าจาก Repository ทำไม )

Dummy

objects are passed around but never actually used. Usually they are just used to fill parameter lists. — Gerard Meszaros

Object ที่ถูก Pass เข้าไปแต่ไม่ได้ใช้เลย , มักใช้เพื่อทำให้ Parameter ครบ

DummyProductRepository คือ Dummy ที่เราใส่เข้าไป เพื่อให้ Parameter ครบ

ใน Case นี้ เราจะพิจารณาว่า ถ้า Parameter ID เป็น “” FindByID ควรจะ Return Error ออกมา (Line 11–13 , service.go)

ซึ่งสังเกตว่า เราไม่ได้สนใจเลยว่า ProductRepository จะคืนค่าอะไรกลับมา (เพราะมัน Return ตั้งแต่ Line 11–13 )

แน่นอนว่า เราไม่ใช้ ProductRepository ซึ่งเป็น Production Object เป็น Parameter แน่ๆด้วยเหตุผลทาง F.I.R.S.T Principles ครั้งจะใช้ Nil เป็น Parameter ก็ไม่ได้ เพราะ Nil ไม่ได้ Implement FindByID มา จึงไม่ถือว่าเป็น IRepository

ด้วยเหตุนี้ เราจึงสร้าง DummyProductRepository ขึ้นมา ให้มี FindByID Method จึงสามารถใช้ Dummy ในฐานะ IRepository ได้

ตรงตามคำนิยามว่า “เรา Pass DummyProductRepository เข้าไป แต่ไม่ได้ใช้เลย” (คือมัน Error ก่อนเข้า FindByID ด้วยซ้ำไป)

Stub

provide canned answers to calls made during the test, usually not responding at all to anything outside what’s programmed in for the test. — Gerard Meszaros

จัด “คำตอบ” ไว้ให้ในระหว่างทดสอบ , มักจะไม่ทำสิ่งอื่น นอกจากสิ่งที่ถูกป้อนเอาไว้

สังเกตว่า Code พัฒนามาจาก Dummy โดยแทนที่จะไม่สนใจว่าจะคืนค่าอะไรไป เป็นสามารถ Set ค่าที่คืนได้ผ่าน Constructor

ใน Case นี้ เราจะพิจารณาว่า ถ้า ProductRepository คืนค่าเป็น Product A แล้ว ProductService จะคืนค่าเป็น Product A หรือไม่

ซึ่งเราไม่ได้สนใจเลยว่า ProductRepository จะทำงานถูกหรือไม่ เราจะสนใจเพียงแค่ “ถ้า ProductRepository คืนค่า A แล้ว ProductService ต้องคืนค่า A เท่านั้น”

เราจึงสร้าง StubProductRepository ขึ้นมา ให้มี FindByID Method ซึ่งจะคืนค่าเป็นค่าที่กำหนดไว้เสมอ โดยที่ไม่สนเลยว่าจะได้ ID เป็น ID อะไร (Line 8–10 , stub-test.go)

โดยเราจะกำหนดค่าที่ StubProductRepository จะ Return ด้วย(Line 14 , stub-test.go)

ซึ่งตรงกับคำนิยามว่า “จัด ‘คำตอบ’ ไว้ให้ระหว่างการทดสอบ มักจะไม่ทำสิ่งอื่น นอกจากสิ่งที่ถูกป้อนเอาไว้” (แน่สิ Return ได้แค่ค่าที่กำหนดใน Line 14)

Spy

are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent. — Gerard Meszaros

stubs ที่ “จดจำ” บางข้อมูลว่าพวกมันถูกเรียกอย่างใด , อาจจะเป็น Email Service ที่ “จดจำ” ว่าส่งข้อความไปเท่าใดแล้ว

สังเกตว่า Code พัฒนามาจาก Stub โดยมี FindWasCalled Bool เพื่อจดจำว่า ตัวเองถูกเรียกไปหรือยัง

ใน Case นี้ เราจะสนใจว่า ProductService ได้เรียก FindByID ของ Repository จริงหรือไม่

ในตัวอย่างนี้ Spy จะต่างจาก Stub ตรงที่นอกจากจะกำหนดได้ว่าจะคืนค่าอะไร จะรู้ด้วยว่า Spy ถูกเรียกใช้หรือไม่

ใน SpyProductRepository (Line 6 , spy_test.go) สังเกตว่า มี Field findWasCalled ถูกเพิ่มเข้ามา โดยถ้า FindByID ถูกเรียก จะเปลี่ยนค่า findWassCalled เป็น True (Line 10, spy_test.go)

เพิ่มเติม Spy มักต้องกำหนดตลอดว่าจะคืนค่าอะไร เพราะ Spy หวังให้ตัว Spy เองถูกเรียกเสมอ (ต่างจาก Dummy ซึ่งหวังว่า Dummy จะไม่ถูกเรียก)

Fake

objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example). — Gerard Meszaros

Object ที่ Implement ให้ทำงานได้ แต่มักทำให้ “ง่าย” จึงไม่เหมาะกับ ระดับproduction (InMemoryTestDatabase เป็นตัวอย่างที่ดี)

Code อย่างยาว แต่สังเกตว่า Fake ใกล้เคียงกับ Stub แต่แทนที่จะ Return ค่าได้เพียงอย่างเดียว เราจะ Implement ส่วนของการ Return ค่าเอาไว้ (อนึ่ง จะ Implement InmemoryDB ใน Example ก็กระไรอยู่ if ไปเลยน่าจะเห็นภาพชัดเจน)

บางครั้ง Stub อาจจะไม่พอกับความต้องการ Test เราจึง Implement การทำงานให้กับ FakeProductRepository (Line 6–13 , fake_test.go)

สังเกตว่า ในทั้ง 2 Test เราไม่จำเป็นต้องกำหนดค่าที่จะ Return ในส่วนของ Constructor แบบ Stub อีกแล้ว (แต่ไป Implement ใน Fake Method แทน)

โดยปกติจะมักไม่ใช้ Fake กัน เพราะใช้ Effort ในการ Implement

Mock

Mocks are objects that register calls they receive.
In test assertion we can verify on Mocks that all expected actions were performed.

Mock เป็น Object ที่ Register ได้ ว่า (Method) ต้องถูก Calls , ใน Test assertion เราสามารถ Verify(ตรวจสอบ) ว่าทุก Actionได้ถูกเรียก

Unlike fakes, mocks don’t have working implementations. Instead, they have pre-programmed expectations about how they will be used in the code.

ต่างกับ fake , mock ไม่ได้ implementation แต่จะใช้วิธี pre-programmed ว่าจะถูกใช้อย่างใดใน Code แทน

ต้องอธิบายว่า Mock เป็นที่ถกเถียงกันมากถึงคำนิยามของมัน บ้างก็ว่า เป็นคำรวมๆที่บ่งบอกถึง Test Double ที่มีคุณสมบัติของ Dummy , Spy , Stub (Fake อาจจะใช่หรือไม่ ขึ้นกับคำนิยาม)

ใครที่คุ้นชินกับ Spring ต้องเคยใช้ Mock Annotation ใน Line 26 สังเกตว่าใช้ When… ส่วนนี้เอง ที่เป็นคำนิยามว่า “pre-programmed expectations”

เนื่องจาก GO นั้นไม่นิยมการใช้ When แบบ Spring เราจึงมา Focus ส่วนของการ Verify ตามคำนิยามแทน

สังเกตว่า ใกล้เคียงกับ Spy แต่มี Method Verify เพื่อ Check ตัวเองได้ว่าถูกเรียก Method ตามที่ Register ครบไหม ไม่จำเป็นต้องเช็คเองใน Test Function (Line 21 — mock_test.go)

ถ้าใช้ในฐานะของการ Verify ว่า Method ถูกเรียกจริงหรือไม่ Spy จะสามารถทดแทนได้(Replaceable)

อย่างไรก็ตาม ถ้าเป็น Spy จำเป็นต้องกำหนด Method ที่ต้องการ Verify ใน Struct เลย แต่ Mock สามารถกำหนดได้ใน Test Function ทำให้มีความยืนหยุ่นมากกว่า

อีกทั้งลดความซ้ำซ้อนในส่วนของ Assertion(ตรวจสอบ) ของ Test Function โดยสร้าง Method Verify ขึ้นมาใช้แทน

ยกตัวอย่างมี Test 10 Test แต่ละ Test ตรวจสอบ 2–3 Method จะสังเกตว่า จำนวน If ที่ใช้ใน Spy จะเยอะกว่า Mocks มาก

บทส่งท้าย

https://www.kbtgkampus.tech/classnest/go-software-engineering-bootcamp

เจ้าของบทความต้องแจ้งกับเพื่อนๆว่า บทความนี้เป็นเนื้อหาของบทเรียน Test Double ใน KBTG Kampus ที่สอนโดย อาจารย์อนุชิต ประเสริฐสังข์ หรือพี่หน่องนั่นเองครับ

ซึ่งบทความนี้ เป็นเพียงบทเรียนบทเดียว (จากประมาณ 50 บทเรียน ) จะเห็นว่า มีเนื้อหาที่อัดแน่นด้วยความรู้มากครับ

ส่วนตัว อาจารย์หน่องที่สอน ส่วนตัวคิดว่าสอนได้สนุกมากครับ มีมุกขำ(เอ้ะ หรือแป้ก) ทุก 3 ประโยคเลย ถือว่าเรียนได้ไม่มีเบื่อเลยครับ

ถ้าเพื่อนๆมีโอกาส อยากจะให้ลองมาเรียนในค่ายนี้ดูครับ รับประกันได้ว่ารู้อะไรเพิ่มเยอะเลย

สุดท้ายก็ขอขอบคุณเพื่อนๆที่อ่านบทความนี้จนจบ อาจารย์หน่องที่สอนวิชานี้ รวมไปถึงทาง KBTG ที่มอบโอกาสดีๆนี้ให้ครับ

ถ้าเจ้าของบทความผิดพลาดประการใด(ซึ่งก็น่าจะหนักอยู่ TT ) สามารถมาสอนมวยได้ทาง DM หรือทาง Page ของเจ้าของบทความได้นะครับ

Code ในบทความนี้ เพื่อนๆสามารถดูได้ที่ https://github.com/pawutj/go_double นะครับ

--

--