Test Doubles คือวัตถุทดแทนที่ใช้ในการทดสอบหน่วยแทนการพึ่งพาจริง คำนี้ถูกนำเสนอโดย Gerard Meszaros ในหนังสือ “xUnit Test Patterns” (2007) เป็นแนวคิดทั่วไปสำหรับ Mock, Stub, Fake, Spy และ Dummy ตามข้อมูลจาก Martin Fowler (2024) Test Doubles ช่วยให้แยกคอมโพเนนต์ที่กำลังทดสอบออกจากสภาพแวดล้อม ทำให้การทดสอบเป็นแบบกำหนดได้ รวดเร็ว และเป็นอิสระจากบริการภายนอก
ประเด็นสำคัญ
Test Doubles เป็นคำที่มาจากอุตสาหกรรมยานยนต์ (สตันต์ดับเบิล) ถูกนำมาใช้ในการพัฒนาซอฟต์แวร์ เช่นเดียวกับสตันต์ดับเบิลที่แทนที่นักแสดงในฉากอันตราย Test Double ก็แทนที่คอมโพเนนต์จริงในสถานการณ์ทดสอบ ซึ่งจำเป็นเมื่อการพึ่งพาจริงไม่พร้อมใช้งาน ช้า ไม่แน่นอน หรือมีผลข้างเคียง
แนวคิดของ Test Double ครอบคลุม ห้าประเภทเฉพาะ แต่ละประเภทแก้ไขงานของตัวเอง การจำแนกประเภทของ Meszaros เป็นมาตรฐานและใช้ในคู่มือการทดสอบสมัยใหม่ทั้งหมด ความแตกต่างระหว่างประเภทอยู่ที่ระดับการควบคุมและการตรวจสอบ: ตั้งแต่การเติมพารามิเตอร์อย่างง่าย (Dummy) ไปจนถึงการตรวจสอบลำดับการเรียกอย่างสมบูรณ์ (Mock)
วัตถุประสงค์หลักของ Test Doubles คือการแยกโมดูลที่กำลังทดสอบ ในการพัฒนาแอปมือถือ การพึ่งพาจริงประกอบด้วยเซิร์ฟเวอร์ API ฐานข้อมูล ระบบไฟล์ เซนเซอร์อุปกรณ์ และบริการระบบ (LocationManager, Camera, Bluetooth) การใช้คอมโพเนนต์เหล่านี้โดยตรงทำให้การทดสอบช้า เปราะบาง และขึ้นอยู่กับสภาพแวดล้อม ตาม Google Testing Blog (2023) การทดสอบหน่วยที่แยกได้ดีจะทำงานภายใน มิลลิวินาที ในขณะที่การทดสอบแบบบูรณาการจะทำงานภายในวินาทีและนาที
การจำแนกประเภทของ Gerard Meszaros ประกอบด้วย Test Doubles ห้าประเภท ซึ่งแตกต่างกันในพฤติกรรมและวัตถุประสงค์ การเข้าใจความแตกต่างระหว่างประเภทเหล่านี้เป็นพื้นฐานของการทดสอบหน่วยที่มีประสิทธิภาพ
Dummy คือวัตถุที่ถูกส่งไปยังเมธอดที่กำลังทดสอบแต่ไม่เคยถูกใช้ Dummy จำเป็นเพียงเพื่อให้ตรงกับลายเซ็นของเมธอด ใน Kotlin ซึ่งมักจะเป็น null, emptyList() หรือวัตถุที่มีสตับ Dummy ไม่ควรมีตรรกะใดๆ — หากถูกเรียก การทดสอบควรล้มเหลว
Fake คือการทำงานที่เรียบง่ายแต่ใช้งานได้ของอินเทอร์เฟซ ต่างจาก Mock และ Stub ตรงที่ Fake มีตรรกะทางธุรกิจจริง แต่อยู่ในรูปแบบที่เรียบง่าย ตัวอย่างคลาสสิกคือ InMemoryUserRepository ซึ่งเก็บข้อมูลใน HashMap แทนฐานข้อมูล Fake ใช้เมื่อคุณต้องการทดสอบตรรกะที่ขึ้นอยู่กับสถานะ แต่ไม่มีค่าใช้จ่ายของโครงสร้างพื้นฐานจริง
| ประเภท | วัตถุประสงค์ | ตัวอย่าง |
|---|---|---|
| Dummy | เติมพารามิเตอร์ | null, วัตถุว่าง |
| Fake | การทำงานที่เรียบง่ายที่ใช้งานได้ | InMemoryRepository |
| Stub | คืนค่าคงที่ | when(api.getUser()).thenReturn(user) |
| Spy | บันทึกการเรียกเพื่อตรวจสอบ | verify(spy).save(user) |
| Mock | ตรวจสอบปฏิสัมพันธ์ | verify(mock).sendEmail(email) |
Stub คืนค่าที่กำหนดไว้ล่วงหน้าสำหรับการเรียกเฉพาะ Stub ไม่ตรวจสอบว่าถูกเรียกหรือไม่ — มันเพียงให้ข้อมูล ใน Mockito Stub ถูกสร้างผ่าน when(method).thenReturn(value) Stub เหมาะสำหรับการทดสอบเมื่อคุณต้องการให้การพึ่งพาคืนค่าเฉพาะ แต่การเรียกนั้นไม่สำคัญ
Spy คือตัวห่อรอบวัตถุจริงที่บันทึกการเรียกทั้งหมดเพื่อตรวจสอบภายหลัง ต่างจาก Mock ตรงที่ Spy ส่งต่อการเรียกไปยังวัตถุจริงแต่อนุญาตให้ตรวจสอบว่าเกิดขึ้นหรือไม่ ใน Mockito Spy ถูกสร้างผ่าน spy(realObject) Spy มีประโยชน์สำหรับการ mock บางส่วน เมื่อคุณต้องการใช้วัตถุจริงแต่ตรวจสอบการเรียกบางส่วน
Mock คือวัตถุที่มีความคาดหวังการเรียกที่กำหนดไว้ล่วงหน้า Mock ตรวจสอบว่าเมธอดเฉพาะถูกเรียกด้วยอาร์กิวเมนต์เฉพาะและในลำดับเฉพาะ ต่างจาก Stub ตรงที่ Mock มุ่งเน้นที่ การตรวจสอบพฤติกรรม มากกว่าการคืนข้อมูล Mock เป็นประเภท Test Double ที่ทรงพลังที่สุดและใช้บ่อยที่สุดในการพัฒนาแอปมือถือ
ความแตกต่างระหว่าง Mock และ Stub มักทำให้เกิดความสับสนแม้ในหมู่นักพัฒนาที่มีประสบการณ์ ความแตกต่างหลักอยู่ที่วัตถุประสงค์: Stub ตรวจสอบสถานะ (state verification) Mock ตรวจสอบพฤติกรรม (behavior verification)
Stub ตอบคำถาม: “โค้ดคืนผลลัพธ์ที่ถูกต้องหรือไม่?” Mock ตอบคำถาม: “โค้ดเรียกเมธอดที่ถูกต้องด้วยอาร์กิวเมนต์ที่ถูกต้องหรือไม่?” ในการพัฒนาแอปมือถือ Stub ใช้เมื่อผลลัพธ์สำคัญ (เช่น ข้อมูลจากที่เก็บ) ในขณะที่ Mock ใช้เมื่อผลข้างเคียงสำคัญ (เช่น การส่งอีเมล การเขียนฐานข้อมูล)
// Stub: การตรวจสอบสถานะ
every { repository.getUsers() } returns listOf(user)
val result = useCase.getUsers()
assertEquals(1, result.size)
// Mock: การตรวจสอบพฤติกรรม
every { analytics.logEvent("purchase") } returns Unit
useCase.purchase(item)
verify { analytics.logEvent("purchase") }
ตัวอย่างเชิงปฏิบัติของ Test Doubles ทั้งห้าประเภทใน Kotlin โดยใช้ MockK — ไลบรารี mocking ที่ได้รับความนิยมมากที่สุดสำหรับโปรเจกต์ Android
class InMemoryUserRepository : UserRepository {
private val store = mutableMapOf<String, User>()
override fun save(user: User) {
store[user.email] = user
}
override fun findByEmail(email: String): User? {
return store[email]
}
}
class RegisterUseCaseTest {
private val api = mockk<AuthApi>()
private val repo = spyk(InMemoryUserRepository())
private val useCase = RegisterUseCase(api, repo)
fun `register user successfully`() = runTest {
// Stub: คืนการตอบสนอง API คงที่
coEvery { api.register("test@test.com") } returns AuthResult.Success("token123")
val result = useCase.execute("test@test.com")
// Verify: ตรวจสอบว่าผู้ใช้ถูกบันทึกแล้ว
verify { repo.save(any()) }
assertTrue(result.isSuccess())
}
}
data class Logger(val appContext: Context, val format: FormatType)
fun `test logger with dummy context`() {
// Dummy: Context ไม่ได้ใช้ภายใน Logger
val dummyContext = mockk<Context>()
val logger = Logger(dummyContext, FormatType.JSON)
assertEquals(FormatType.JSON, logger.format)
}
การเลือกประเภท Test Double ขึ้นอยู่กับสิ่งที่กำลังทดสอบ: สถานะ พฤติกรรม หรือการบูรณาการ ในการพัฒนาแอปมือถือ Android และ iOS ได้มีการกำหนดคำแนะนำดังต่อไปนี้
เมื่อทดสอบ ViewModel ให้ใช้ Mock สำหรับการพึ่งพาที่สร้างผลข้างเคียง (ที่เก็บข้อมูล, การวิเคราะห์, การนำทาง) และ Stub สำหรับการพึ่งพาที่คืนข้อมูล (ไคลเอ็นต์ API, ContentProvider) ซึ่งช่วยให้ตรวจสอบว่า ViewModel จัดการทั้งสถานการณ์สำเร็จและข้อผิดพลาดได้อย่างถูกต้อง
ในระดับ Repository ให้เลือกใช้ Fake (การใช้งานฐานข้อมูลในหน่วยความจำ) และ Stub (การตอบสนอง API คงที่) Fake ช่วยให้ทดสอบตรรกะแคชและโหมดออฟไลน์โดยไม่ต้องตั้งค่า SQLite Stub จำลองสถานะ HTTP ต่างๆ: 200, 404, 500, หมดเวลา
การใช้ Test Doubles อย่างไม่ถูกต้องเป็นหนึ่งในสาเหตุที่พบบ่อยที่สุดของการทดสอบที่เปราะบางซึ่งพังทุกครั้งที่มีการปรับโครงสร้างโค้ด
ข้อผิดพลาดที่พบบ่อยที่สุดคือการ mock ทุกอย่าง หากทุกการพึ่งพาในการทดสอบถูกแทนที่ด้วย Mock การทดสอบจะหยุดตรวจสอบพฤติกรรมจริง Mock ควรใช้สำหรับการพึ่งพาภายนอกเท่านั้น (เครือข่าย, ฐานข้อมูล, ระบบไฟล์, บริการระบบ) คอมโพเนนต์ภายในแอปพลิเคชัน (Value Object, data class, ยูทิลิตี้ธรรมดา) ไม่ควรถูกแทนที่
ข้อผิดพลาดที่สองคือการสร้าง Mock โดยไม่กำหนดความคาดหวัง หากเมธอดถูกเรียกโดยไม่มี every / when Mock จะคืนค่าเริ่มต้น (null, 0, false) ซึ่งอาจนำไปสู่การทดสอบผลบวกลวง ที่ Mock คืนค่า null อย่างเงียบๆ และการทดสอบตีความว่าเป็นพฤติกรรมที่ถูกต้อง
ข้อผิดพลาดที่สามคือการตรวจสอบทุกการเรียกของทุก Mock Verify ควรใช้เฉพาะสำหรับการเรียกที่สำคัญอย่างยิ่งจากมุมมองของตรรกะทางธุรกิจ การตรวจสอบมากเกินไปทำให้การทดสอบเปราะบาง: การเปลี่ยนลำดับการเรียกในโค้ดการผลิตทำให้การทดสอบพังโดยไม่เปลี่ยนพฤติกรรม
คำถามที่พบบ่อย
Stub คืนข้อมูลและตรวจสอบสถานะ (สิ่งที่ถูกคืน) ในขณะที่ Mock ตรวจสอบพฤติกรรม (เมธอดใดถูกเรียก) Stub = “คืน X”, Mock = “ตรวจสอบว่า Y ถูกเรียกด้วยอาร์กิวเมนต์ Z” ในการทดสอบจริง วัตถุหนึ่งๆ มักทำหน้าที่เป็นทั้ง Stub และ Mock พร้อมกัน
Fake ดีกว่า Mock เมื่อทดสอบตรรกะที่ขึ้นอยู่กับสถานะ: แคช, โหมดออฟไลน์, ธุรกรรม Fake (การทำงานในหน่วยความจำ) ช่วยให้ทดสอบสถานการณ์เหล่านี้ได้โดยไม่ต้องใช้การเรียก verify ที่เปราะบาง Mock เหมาะสมกว่าสำหรับตรวจสอบการส่งข้อมูล: การวิเคราะห์, push, อีเมล
สำหรับโปรเจกต์ Android ใน Kotlin แนะนำ MockK รองรับคอร์รูทีน, ฟังก์ชัน suspend, คลาส sealed และฟังก์ชันส่วนขยายโดยไม่ต้องกำหนดค่าเพิ่มเติม สำหรับโปรเจกต์ Java Mockito ยังคงเป็นมาตรฐาน — ไลบรารียอดนิยมที่มีเอกสารครอบคลุม
ในการทดสอบ Kotlin Flow ให้ใช้ไลบรารี Turbine ร่วมกับ MockK Turbine ช่วยให้การตรวจสอบการปล่อย Flow ง่ายขึ้น: คุณสามารถตรวจสอบลำดับของค่า การสิ้นสุดสตรีม และข้อยกเว้น Stub สำหรับ Flow คืน flowOf(value) Mock ตรวจสอบว่า Flow ถูกรวบรวม
ได้ แต่ในระดับ การตอบสนอง API ไม่ใช่คอมโพเนนต์ UI ไลบรารี MockWebServer (OkHttp) และ WireMock ช่วยให้จำลองการตอบสนอง HTTP ในการทดสอบ UI คอมโพเนนต์ UI เอง (Compose, SwiftUI Views) ไม่ควรถูกแทนที่ — พฤติกรรมของพวกมันถูกทดสอบผ่านการทดสอบภาพหน้าจอและ Espresso
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ