Unit Testing using MockK in Kotlin

ณาฌา หิรัญญการ
Gofive
3 min readJan 8, 2024

--

ทำไมต้องเขียน Unit test

การเขียน Unit test เป็นส่วนสำคัญของกระบวนการพัฒนาซอฟต์แวร์ (software development)

1. Reliability
Unit test ช่วยในการยืนยันว่าโค้ดทำงานได้ตามที่คาดหวัง สามารถใช้ Unit test เพื่อตรวจสอบว่าการเปลี่ยนแปลงใดๆ ที่ทำในโค้ดไม่ได้ทำให้ function ทำงานผิดพลาด

2. Maintainability
การเขียน Unit test ทำให้โค้ดมีโครงสร้างที่ดีและ modularity ที่สามารถทำให้ง่ายต่อการบำรุงรักษา สามารถทดสอบโค้ดที่ตนเองหรือคนอื่นได้โดยไม่ต้องเข้าใจโค้ดทั้งหมด

3. Bug Prevention
Unit test ช่วยลดโอกาสที่จะเกิดข้อผิดพลาดในโค้ด เมื่อมีการเปลี่ยนแปลงโค้ดหรือเพิ่มความสามารถใหม่

4. Development Convenience
การทำ Unit test ช่วยในการทดสอบโค้ดได้ทันทีโดยไม่ต้องรันทั้งโปรแกรมสามารถรัน Unit test เพื่อทดสอบเพียง function หรือ module ที่สนใจ

MockK

MockK เป็น testing library ที่ถูกพัฒนาขึ้นสำหรับภาษา Kotlin เพื่อช่วยทดสอบโปรแกรมอย่างมีประสิทธิภาพและสะดวกสบาย โดยเน้นไปที่ mocking วัตถุที่ใช้ในการทดสอบ เช่น การจำลองการทำงานของ class หรือ object เพื่อให้สามารถทดสอบโค้ดได้อย่างที่คาดหวัง

ยกตัวอย่างเช่น หากต้องการเขียน Unit test สำหรับ test file repository ของ Recipe application

📍RecipeRepository
ทำหน้าที่ในการดึงข้อมูลจาก RecipeService และส่ง result ในรูปแบบของ Flow ของ Result<List<Category>>

interface RecipeRepository {
fun getCategories(): Flow<Result<List<Category>>>
}

class RecipeRepositoryImpl(private val recipeService: RecipeService) : RecipeRepository {
override fun getCategories(): Flow<Result<List<Category>>> = flow {
try {
val response = recipeService.getCategories()

if (response.isSuccessful) {
response.body()?.let { body ->
emit(Result.success(body.categories))
} ?: run {
emit(Result.failure(IllegalStateException("Response body or contents is null")))
}
} else {
emit(Result.failure(IllegalStateException(response.code().toString())))
}
} catch (e: Exception) {
emit(Result.failure(e))
}
}
}

📍RecipeRepositoryUnitTest

เวลาสร้าง mock instance ของ dependencies หมายความว่ากำลังสร้าง fake หรือ dummy version ของ function หรือ method ที่สามารถทำงานเหมือนกับตัวจริง

ดังนั้นการเขียน Unit test เพื่อ test RecipeRepository จะต้อง mock ตัว Repository และ Apiโดยมีวิธีเขียนดังนี้

internal class MovementRepositoryTest {
private val api = mockk<RecipeService>()
private lateinit var repository: RecipeRepository

@Before
fun setup() {
repository = RecipeRepositoryImpl(api)
}

}

@Before คือ annotation ที่ใช้กำหนดว่า method ที่ถูกประกาศด้วย annotation นี้จะถูกเรียกใช้ก่อนทุกครั้งที่เรียกใช้ test method หรือใน class ที่มี annotation @Test

มาเริ่มเขียน Unit test กัน!

    override fun getCategories(): Flow<Result<List<Category>>> = flow {
try {
val response = recipeService.getCategories()

if (response.isSuccessful) {
response.body()?.let { body ->
emit(Result.success(body.categories))
} ?: run {
emit(Result.failure(IllegalStateException("Response body or contents is null")))
}
} else {
emit(Result.failure(IllegalStateException(response.code().toString())))
}
} catch (e: Exception) {
emit(Result.failure(e))
}
}

getCategories() จะทำการดึงข้อมูลจาก recipeService.getCategories() แล้วก็ทำการตรวจสอบว่า response ที่ได้รับมา
Successful (HTTP status code 2xx)
• ถ้าหากสำเร็จ emit Result.success พร้อมกับ list ของ categories ที่ได้รับจาก response body
• ถ้าหาก response body เป็นค่า null ก็ emit Result.failure พร้อม IllegalStateException ที่ระบุว่าเนื้อหาเป็น null
Unsuccessful (HTTP status code 4xx)
หากไม่สำเร็จ emit Result.failure พร้อม IllegalStateException ที่มี response code

ดังนั้น getCategories() คือ function สำหรับเรียก recipeService เพื่อรับ categories data โดยจะ success ก็ต่อเมื่อ data ไม่เป็น null

1. เขียน Unit test กรณีที่ ได้รับ Successful data

    @Test
fun `Given response success When getCategories Then result success`() = runBlocking {
coEvery {
api.getCategories()
} returns Response.success(
CategoriesResponse(categories)
)

repository.getCategories()
.collect {
TestCase.assertTrue(it.isSuccess)
}
}

โดย Set categories data ไว้ดังนี้

private val categories = listOf(
Category("1", "Category1", "thumb1.jpg", "Description1"),
Category("2", "Category2", "thumb2.jpg", "Description2")
)

1. Mocking the API Response:
•ใช้ coEvery เพื่อระบุว่าเมื่อเรียก api.getCategories() จะต้อง return ค่าเป็น Response.success ที่ประกอบด้วยข้อมูลที่ระบุใน CategoriesResponse(categories)

2. Calling the Test Subject:
• เรียก repository.getCategories() เพื่อเรียก function ที่ต้องการทดสอบ
• ใช้ .collect เพื่อรอรับ result ที่ถูก return ผ่าน Flow

3. Result Verification:
• ใน block .collect มีการใช้ TestCase.assertTrue(it.isSuccess) เพื่อตรวจสอบว่าผลลัพธ์ที่ได้จาก collect เป็น Result.success หรือไม่

2. เขียน Unit test กรณีที่ ได้รับ Successful แต่ data เป็น null

    @Test
fun `Given response success When getCategories Then result null`() = runBlocking {
coEvery {
api.getCategories()
} returns Response.success(null)

repository.getCategories()
.collect {
it.fold(
onFailure = { throwable ->
TestCase.assertTrue(throwable.message == "Response body or contents is null")
},
onSuccess = {
}
)
}
}

1. Mocking the API Response:
• ใช้ coEvery เพื่อระบุว่าเมื่อเรียก api.getCategories() จะต้อง return ค่าเป็น Response.success โดยมีค่าเป็น null (Response.success(null))

2. Calling the Test Subject:
• เรียก repository.getCategories() เพื่อเรียก function ที่ต้องการทดสอบ
• ใช้ .collect เพื่อรอรับ result ที่ถูก return ผ่าน Flow

3. Result Verification:
• Block .collect ใช้ it.fold เพื่อจัดการกับ result และทำการตรวจสอบว่าสำเร็จหรือไม่
• Block onFailure ทำการตรวจสอบว่า exception message คือ "Response body or contents is null" เพื่อยืนยันว่า exception ที่ถูก return เป็นตามที่คาดหวังเมื่อ response body เป็น null
• Block onSuccess ว่างเนื่องจากสิ่งที่ต้องการในทดสอบนี้คือการทำงานไม่สำเร็จ

3. เขียน Unit test กรณีที่ ได้รับ Unsuccessful data

    @Test
fun `Given response error When getCategories Then result error`() = runBlocking {
coEvery {
api.getCategories()
} returns Response.error(404, "".toResponseBody())

repository.getCategories()
.collect {
it.fold(
onFailure = { throwable ->
TestCase.assertTrue(throwable.message == "404")
},
onSuccess = {
}
)
}
}

1. Mocking the API Response:
• ใช้ coEvery เพื่อระบุว่าเมื่อเรียก api.getCategories() จะต้อง return ค่าเป็น Response.error โดยมี status code เป็น 404 และ response body เป็นค่าว่าง (Response.error(404, "".toResponseBody()))

2. Calling the Test Subject:
• เรียก repository.getCategories() เพื่อเรียก function ที่ต้องการทดสอบ
•ใช้ .collect เพื่อรอรับ result ที่ถูก return ผ่าน Flow

3. Result Verification:
• Block .collect ใช้ it.fold เพื่อจัดการกับ result และทำการตรวจสอบว่าสำเร็จหรือไม่
• Block onFailure ทำการตรวจสอบว่า exception message คือ "404" เพื่อยืนยันว่า exception ที่ return เป็นตามที่คาดหวังเมื่อ API ตอบกลับด้วย status code 404
• Block onSuccess ว่างเนื่องจากสิ่งที่ต้องการในทดสอบนี้คือการทำงานไม่สำเร็จ

ได้เห็นตัวอย่างง่ายๆเกี่ยวกับการเขียนเทสกันไปแล้ว ก็ลองนำไปเป็นแนวทางในการเขียน unit test ของตัวเองกันดูนะคะ เพราะการเขียน unit tests เป็นสิ่งที่ควรจะต้องมีสำหรับการพัฒนา application เพื่อลดข้อผิดพลาดที่ส่งผลให้ประสบการณ์ใช้งานแย่ลงหรือทำให้เกิดปัญหาตามมา รวมถึงการเขียน unit test มันจะส่งผลให้คุณภาพของโค้ดโดยรวมดีขึ้นตามไปด้วย ดังนั้นเรามาเขียน unit test กันเถอะ!

--

--