Unit Test: The Hammer and the Scalpel

"Unit" ရဲ့ အဓိပ္ပာယ်ဖွင့်ဆိုချက်ပေါ်မူတည်ပြီး ခင်ဗျားရဲ့ Testing Strategy က ကွဲသွားနိုင်တယ်။ အဓိကအားဖြင့်တော့​ Strategy နှစ်မျိုးရှိတယ်..

Unit Test: The Hammer and the Scalpel
Photo by Jametlene Reskp / Unsplash

ကျွန်တော် Unit Testing ကိုစလေ့လာတုန်းက Mocking Framework တွေနဲ့ စခဲ့တယ်။ နောက်​ Thoughtworks ရောက်တော့မှ Testing Pyramid တွေ၊ Testing Strategy တွေဘယ်လို ဖန်တီးရလဲဆိုတာကို သေချာနားလည်လာတယ်။ ရယ်ရတာ အဲမှာလုပ်တော့ ကျွန်တော့် ပထမဆုံးတွဲလုပ်ရတဲ့ client/partner က JavaScript နဲ့။ JS Dev တွေရဲ့ Testing Strategy က ကျွန်တော်အရင်လုပ်ခဲ့တာ၊ သိခဲ့တာတွေနဲ့ ကွဲပြားနေတယ်။ အဓိက သူတို့က Testing Pyramid မှာပြောထားတဲ့ Unit Test များများ၊ Integration test နည်းနည်းဆိုတာရဲ့ ပြောင်းပြန် Integration test ကိုပိုရေးကြတယ်။ React Testing Library ကိုရေးခဲ့တဲ့ Kent C Dodd ရဲ့ လွှမ်းမိုးမှုတွေလည်းပါမယ်။ သူက Testing trophy ဆိုတဲ့ Concept ကို စပြောခဲ့တဲ့သူလည်းဖြစ်တယ်။ ဒီအကြောင်းဆက်မသွားခင် ဒီ Term တွေကို အရင်ရှင်းပြမယ်။

Testing Pyramid

Ref: https://martinfowler.com/articles/2021-test-shapes.html

Testing Pyramid ဆိုတာက အပေါ်မှာ ပြထာတဲ့ ပုံထဲမှာလိုပဲ 2009 ခုနှစ်တုန်းက Mike Cohn ထွင်ခဲ့ပြီး Thoughtworks ကြောင့် လူသိများခဲ့တဲ့ Test Strategy ဖြစ်တယ်။ သူ့ concept က ရှင်းတယ်၊ ပိရမစ်အောက်ဘက်က Test တွေက မြန်မယ်၊ ပိုက်ဆံ/အချိန်ကုန်တာ နည်းတယ်။ အပေါ်မှာရှိတဲ့ UI Test, Manual Test တွေက​ ရေးရတာကော၊ run ရတာကော ကြာတယ်၊ Resource ပိုကုန်မယ်။ ဒီတော့ အောက်မှာရှိတဲ့ Unit Test နဲ့ Service/Integration Test တွေကိုများများရေးရမယ်။ ဒါမှကိုယ့် test တွေက ကုန်ကျစရိတ်နည်းနည်းနဲ့ မြန်မြန် feedback loop ကိုရမယ်ဆိုပြီး ပြောထားတယ်။ ဒီနည်းက အခုမှ Test စလေ့လာမဲ့သူတွေအတွက် Default လုပ်ရမဲ့ Framework တစ်ခုလိုဖြစ်နေတယ်။

Testing Trophy

Testing Trophy by Kent C. Dodds

2018 တုန်းက ဟိုလေးတစ်ကြော်ဖြစ်သွားတဲ့ Kent C. Dodds နဲ့ Tweet။ Testing Pyramid ကို စိန်ခေါ်ထားတာပေါ့။ သူ့အမြင်မှာတော့ Unit Test များများထက် Integration Test ကိုပိုရေးတာက Scalable ပိုဖြစ်သလို အချိန်ပေးရတာနဲ့တန်အောင်လည်း အကျိုးအမြတ်ပြန်ရတယ်ဆိုပြီးရှင်းထားတယ်။

Write tests. Not too many. Mostly integration.
[Guillermo Rauch](https://twitter.com/rauchg) [tweeted](https://twitter.com/rauchg/status/807626710350839808) this a while back. Let’s take a dive into what it means.

Software Industry ထဲက ဆရာကြီးတွေကလည်း တစ်ချို့ကထောက်ခံတယ်၊ တစ်ချို့ကငြင်းကြတာပေါ့ဗျာ။ Martin Fowler ကလည်း 2021မှာ ဒီကိစ္စကို အကျယ်တပွင့်ပြန်ရှင်းပြထားသေးတယ်။

On the Diverse And Fantastical Shapes of Testing
My #2 problem with arguing testing pyramids vs honeycombs is the disparate definitions of unit test

Kent C Dodds ကလည်း တစ်ဖန် စာအရှည်ကြီးပြန်ရေးပေါ့နော်။ 😆

The Testing Trophy and Testing Classifications
How to interpret the testing trophy for optimal clarity

ထားပါတော့၊ ဒါတွေလိုက်ဖတ်ကြည့်တော့ အဓိကပြဿနာက Testing Strategy တစ်ခုရေးဆွဲတဲ့အခါမှာ "Unit" ဆိုတဲ့ ခက်ဆစ်အဓိပ္ပာယ်ကို သတ်မှတ်ရခက်တာပဲ။

Unit?

"Unit" ရဲ့ အဓိပ္ပာယ်ဖွင့်ဆိုချက်ပေါ်မူတည်ပြီး ခင်ဗျားရဲ့ Testing Strategy က ကွဲသွားနိုင်တယ်။ အဓိကအားဖြင့်တော့​ Strategy နှစ်မျိုးရှိတယ်။ London School Strategy လို့ခေါ်တဲ့ Mockist Testing နဲ့ Detroit School strategy လို့ခေါ်တဲ့ Classicist Testing ဆိုပြီး ခေါ်တယ်။ ဒီနည်းနှစ်နည်းက အမြဲတမ်းလိုလို အငြင်းပွားဖွယ်ရာဖြစ်တဲ့ နည်းနှစ်ခုပဲ။

Mockist Testing

Mockist ဆိုတာ Low Level Unit Test ဖြစ်တာများတယ်။ သူကအသေးစိတ် function တစ်ခုခြင်းစီကိုစစ်တယ်။ Mockist အတွက် "Unit" ဆိုတဲ့ အဓိပ္ပာယ်က function တစ်ခုကို ဆိုလိုတာပဲ။ နောက်ပြီး နာမည်မှာ "Mockist" ဆိုတဲ့အတိုင်း Mock ကိုအဓိကအားကိုးပြီးရေးတယ်။

သေချာမြင်အောင်ပြမယ်ဆို ဥပမာအနေနဲ့ အောက်က Code ကို တစ်ချက်ကြည့်

class DataSource(
    private val userDao: UserDao
) {
    fun doesUserExist(username: String): Boolean {
        val user = userDao.selectByName(username)
        return user != null
    }
}

Simple database fetching code

ဒါကို Mockist စတိုင် Low Level Testing ရေးမယ်ဆိုရင် ကျွန်တော်တို့က doesUserExist function ကို isolate လုပ်ပြီးစစ်ရမယ်။ Test Driven Development (TDD) နဲ့ရေးမယ်ဆို production code မရေးခင်ကတည်းက Test ကိုအရင်ရေးရတာဖြစ်လို့ Database/Data Access Object (DAO) ကတကယ်မရှိသေးဘူး။ ဒီတော့ DAO အတွက်ကို mock နဲ့အစားထိုးပြီး သူ့ကိုသုံးထားတဲ့ နည်းမှန်ရဲ့လား စစ်ပေးရမယ်။​

@Test
fun `return true when user is not null`() {
    // Mock return value
    val mockDao = mockk<UserDao>()
    every {
        mockDao.selectByName("aung")
    } returns mockk<User>()
    
    // Setup data source with mock database
    val dataSource = DataSource(mockDb)

    val actual = dataSource.doesUserExist("aung")

    assertTrue(actual)
}

Testing with Mockist Strategy

Mockist နည်းက ထွက်လာတဲ့ output/state ထက် behavior ကို စစ်တာဖြစ်တယ်။ ဒီမှာဆိုလည်း ကျွန်တော်တို့က database ကို ကိုယ်ပေးလိုက်တဲ့ name နဲ့သွားခေါ်ရဲ့လားဆိုပြီး စစ်တာပဲ။ aung ဆိုတဲ့ parameter နဲ့ခေါ်တာမဟုတ်ရင် ကျွန်တော်တို့ mock က error ပြပေးလိမ့်မယ်။ ရလာတဲ့အဖြေ output ကလည်း တကယ့် value မဟုတ်ပဲ mock ကပြန်လာတာကို စစ်တာမလို့ output ကိုစစ်တယ်လို့ ပြောမရဘူး။ Database ကို သွားခေါ်တဲ့ "behavior" ကမှန်လားဆိုတာကိုစစ်နေတာဖြစ်တယ်။ ဒါကြောင့်မလို့ Mockist နည်းကို Behavioral Testing လို့လဲခေါ်ကြသေးတယ်။

Classicist Testing

Classicist နည်းက High Level Unit Test တွေဖြစ်တယ်။​ သူ့အတွက် "Unit" ဆိုတာက Flow တစ်ခုလုံးကို ပြောတာဖြစ်တယ်။ Integration Test လို့လည်းခေါ်ကြပေမဲ့ ကျွန်တော်ကတော့ High Level Unit Test လို့ပဲမြင်တယ်။ ကိုယ့် System ထဲက Flow ကိုပဲစစ်တာဆိုတော့ "Integrate" လုပ်နေတယ်လို့ ကျွန်တော်က မပြောချင်ဘူး။ ဒီလို High Level Unit Test တွေ ရေးဖို့အတွက် Mock အစား Test Double တွေကိုသုံးတယ်။ Test Double ဆိုတာ တကယ့် production နည်းနည်းတူအောင် ရေးထားတဲ့ fake implementation တွေဖြစ်တယ်။ ဥပမာ Database ဆိုရင် တကယ့် production database အစား Test တစ်ခုစာပဲ အသက်ရှိမှာဖြစ်တဲ့ In-Memory database ပြောင်းသုံးတာမျိုး၊ ဒါမှမဟုတ် Array ထဲခဏသိမ်းထားတာမျိုး အစရှိသဖြင့် ရေးကြတယ်။ ဥပမာအနေနဲ့ အပေါ်က Database code ကို Test Double ရေးမယ်ဆို ဒီလိုရေးလို့ရတယ်။

internal class FakeUserDao : UserDao() {

  private val users = mutableListOf<User>()

  fun selectByName(username: String): User = users.find {
    it.name == username
  }

  fun insert(user: User) {
    user.add(user)
  }
}

Fake implementation using List

ဒီလို Production နဲ့ အလားသဏ္ဏန်တူတဲ့ Test Double တွေကိုသုံးပြီး Test ကိုအောက်မှာပြထားသလိုရေးကြတယ်။

@Test
fun `return true when user is not null`() {
    // It's important to tyope as base child type
    // This prevents leaking fake implementations
    val fakeDao : UserDao = FakeUserDao()
    
    // Setup data source with mock database
    val dataSource = DataSource(fakeDao)

    fakeDao.insert(User(name="aung"))

    val actual = dataSource.doesUserExist("aung")

    assertTrue(actual)
}

Testing DataSource with test double

ဒီမှာဆိုတစ်ကယ် List ထဲမှာသိမ်းထားတာ ရှိလားမရှိလားစစ်တာဖြစ်လို့ behavior ထက်စာရင် Output သို့မဟုတ် State ကိုစစ်တာလို့ပြောလို့ရတယ်။ ဒီဥပမာမှာ သိပ်မြင်မှာမဟုတ်ဘူး၊ ဘာလို့ဆို Classicist နည်းက High Level Unit Test ကို အဓိကရေးတာမလို့ ဒီလို function တစ်ကြောင်းတည်းစစ်တဲ့ Low Level Unit Test တွေမှာဆိုအသုံးမဝင်ဘူး။​ ဒီတော့ တစ်ဆင့်တက်ကြည့်ပြီး​​ User flow ကိုစစ်မယ်ဆိုပါဆို့။

val dataSource = DataSource(FakeUserDao())
val userInputRobot = UserInputRobot(dataSource)    

@Test
fun `add user and verify it exists`() {
    userInputRobot.enterUserName("aung")
    userInputRobot.pressAdd()

    userInputRobot.pressCheckExists()
    userInputRobot.assertUserExistsDialogShown()
}

Testing UI with test double

မြင်ပြီလား၊ high level test ရေးရတာဘယ်လောက်လွယ်သွားလဲဆိုတာ။ အပေါ်ဆုံး နှစ်လိုင်းနဲ့တင် Test မှာလိုတာကို setup လုပ်သွားလို့ရတယ်။ User flow တစ်ခုလုံးကိုလဲစစ်လို့ရတယ်။ ဒီလို high level မျိုးကျ Mockist နဲ့ရေးမယ်ဆို​ တော်တော်ရေးယူရတယ်။

val dataSource = mockk<DataSource>()
val userInputRobot = UserInputRobot(dataSource)    

@Test
fun `enter username and add calls addUser with given parameters`() {
  every {
    dataSource.addUser(any)
  } answers {
    // Do Nothing
  }

  userInputRobot.enterUserName("aung")
  userInputRobot.pressAdd()

  verify {
    dataSource.addUser("aung")
  }
}

@Test
fun `press check exists shows dialog if user exists`() {
  every {
    dataSource.doesUserExist(any)
  } returns true

  userInputRobot.enterUserName("aung")
  userInputRobot.pressCheckExists()

  verify {
    dataSource.doesUserExist("aung")
  }
  userInputRobot.assertUserExistsDialogShown()
}

Testing UI with mocks

မြင်တဲ့အတိုင်း Mockist မှာကျ User Flow တစ်ခုလုံးစစ်လို့မရပဲ Low Level Function သေးသေးလေးတွေရဲ့ Behavior တစ်ခုချင်းစီကိုစစ်ရတယ်။ ဒီတော့ Classicist က High Level Flow တွေရဲ့ နောက်ဆုံးထွက်လာတဲ့ State ကိုစစ်တာဖြစ်လို့ State Testing လို့လဲ ခေါ်ဝေါ်သမုတ်ကျတယ်။

Classicist vs Mockist

Classicist နဲ့ Mockist ဘယ်ဟာကောင်းလဲ ဆိုတာကိုဖြေဖို့ ဘာတွေကွဲလဲဆိုတာသိမှာ ဆုံးဖြတ်လို့ရမယ်။

အပေါ်မှာတစ်ခုသိထားတာက Mockist က Low Level function သေးသေးလေးတွေကို စစ်တဲ့နေရာမှာ အဆင်ပြေတယ်၊ လိုချင်တဲ့ behavior ဟုတ်လားဆိုတာကို စစ်လို့ရတယ်။ Classicist ကတော့ Low Level ထပ်စာရင် High Level Flow တွေကိုစစ်တဲ့အခါမှာ ပိုလွယ်စေတယ်၊ သူက နောက်ဆုံးရောက်နေတဲ့ State ကိုဦးစားပေးပြီးစစ်တယ်။

နောက်တစ်ခု Test Setup လုပ်တဲ့နေရမှာကျတော့ Mockist က လွယ်တယ်။ တကယ့် prod code က ဘယ်လိုရှိလဲ သိစရာမလိုပဲ Mock နဲ့ လွယ်လွယ်ကူကူ အစားထိုးလိုက်လို့ ရတယ်။ Classicist ကကျတော့ Test Doubles/Fake တွေကို production code နဲ့ အတတ်နိုင်ဆုံး စင်တူရေးရတော့ အချိန်ပေးရတယ်။ အဲမှာပိုဆိုးတာက Codebase က Fake ရေးလို့ လွယ်အောင် interface တွေ၊ abstraction တွေ၊ layer တွေခွဲမထားရင် Fake ရေးလို့ရတဲ့အထိ code ကို refactor လုပ်ရတာ အချိန်ကြာစေတယ်။ Mockist နည်းက ဘယ်လောက်ပဲ Codeက ရှုပ်နေနေ Mock နဲ့အစားထိုးလိုက်လို့ရတယ်။

ဒါပေမဲ့ နောက်ပိုင်း refactor လုပ်ဖို့ လိုလာရင်တော့ regression test အနေနဲ့က Classicist ရဲ့ High level Test တွေက ပိုပြီးအသုံးဝင်တယ်။ Mock က တကယ့် Code ကို စစ်တာမဟုတ်လို့ function signature (parameter နဲ့ return type) တွေမပြောင်းသရွေ့ သူကမှန်တယ်ပဲပြောနေမှာပဲ။ Classicist ကကျတော့ production code ကိုများများသုံးထားတာမလို့ refactor လုပ်ပြီး ထွက်လာတဲ့ output က မှားသွားရင် ချက်ချင်းသိနိုင်တယ်။ နောက်တစ်ချက်က Classicist မှာက အောက် layer က function signature တွေပြာင်းသွားလဲ Test ကိုပြန်ပြင်စရာမလိုဘူး၊ Mock မှာဆိုရင်တော့ Test တွေပါလိုက်ပြင်ရတယ်။​ Refactor လုပ်တာမှန်မမှန်စစ်ချင်ပါတယ်ဆို Test ပါဝင်ပြင်ရတော့ Mock သုံးထားတဲ့ Test တွေက Refactoring အတွက် သိပ်အားကိုးလို့မရဘူး။

Scalability အရကြည့်မယ်ဆိုရင်တော့ Classicist နည်းလမ်းက Scale ပိုဖြစ်တယ်။ Integration ပိုင်းတွေ (ဥပမာ Database, Third party API) တွေကို Fake Implementation တွေသေချာရေးထားပြီးပြီဆို အဲဒီ Fakes တွေကို Test တိုင်းနည်းပါးမှာ ပြန်သုံးလို့ရတယ်။ Mockist လိုမျိုး Mock တွေကို ထပ်ခါတစ်လဲလဲ ဆောက်နေစရာမလိုဘူး။ ရေးပြီးသား Fake တွေနဲ့ပဲ System တစ်ခုလုံးရဲ့ Flow အစအဆုံး end-to-end ကိုလည်း စစ်လို့ရတယ်။ Scalability အပိုင်းမှာတော့ Classicist ကအသာကြီးပဲ။

Test Speed အနေနဲ့ကြတော့ Mock တွေကမြန်တယ်၊ Classicist မှာက Fake Implementation တစ်ခုလုံးကြီး (In-memory database လိုမျိုး) ကို သုံးရတာဆိုတော့ ပိုကြာတယ်။ Test Pyramid မှာပြခဲ့သလိုပဲ high level test တွေက low level test ထပ်ကိုကြာတာကတော့ သဘာဝပဲ။

ပြန်ခြုံငုံကြည့်လိုက်ရင်

Mockist Classicist
Ease of Setup
Refactoring
Scalability
Test Speed
Robustness
Test Target Behavior State

Verdict

ကျွန်တော်အမြင်မှာတော့ နှစ်ခုလုံးက အသုံးဝင်တဲ့နေရာကိုယ်စီရှိတယ်။ အရင်ကတော့ Mockist နည်းကိုတအားသုံးဖြစ်ပေမဲ့ နောက်ပိုင်း Classicist နည်းကိုပိုသဘောကျလာတယ်။ App သေးသေးလေးတွေမဟုတ်တော့ပဲ Super App တွေရေးရတာလည်းပါလာလိမ့်မယ်။ Scalability ကို ဦးစားပေးလာတယ်။ တကယ်ကောင်းတဲ့နည်းကတော့ နှစ်ခုလုံးရောသုံးတာပဲ။ ဘာမှစမရေးခင် အရင်ဆုံး Flow တစ်ခုလုံးကိုစစ်မဲ့ High level test တစ်ခုစရေး၊ Frontend ဆိုရင် Flow တစ်ခုရဲ့ UI​ Interaction အစအဆုံးပေါ့၊ Backend ဆိုရင်တော့ Service Test လိုမျိုး endpoint ကို Frontend တစ်ခုကနေ စခေါ်သလိုမျိုး request ပို့မယ်၊ ပြီးရင် ပြန်လာတဲ့ response က လိုချင်တဲ့ပုံစံဟုတ်ရဲ့လားဆိုတာကို စစ်မယ်။ အဲဒါပြီးသွားပြီဆိုတော့မှ အသေးစိတ် ချိတ်ဆက်ရမဲ့ class/function တစ်ခုချင်းကို mockist နဲ့ ရေးမယ်။ Mockist က တစ်ခုကောင်းတာက Code Design ချတဲ့နေရာမှာ အထောက်အကူပြုတယ်။ လိုအပ်တဲ့ function parameter တွေ return type/data ကဘယ်လိုဆိုတာတွေကို mock သုံပြီး အရင်ဆောက်ကြည့်လို့ရတယ်။ ဒီတော့ အသေးစိတ်လေးတွေကိုတော့ Mockist TDD နဲ့ design ကို ဆောက်ပြီးရေး၊​ နောက်ဆုံးပြီးပြီလို့ထင်ရင် အစကရေးခဲ့တဲ့ High Level Test နဲ့ Flow တစ်ခုလုံးမှန်ရဲ့လားဆိုပြီး ပြန်စစ်ကြည့်။​ ဒီနည်းနှစ်ခုလုံးက သူမှန်တယ်၊ ကိုယ်မှန်တယ်ဆိုတာထက် ကိုယ်က ကျွမ်းကျင်ထားတဲ့အခါကျမှ လိုတဲ့နေရာမှာ အသုံးတည့်အောင်သုံးလို့ရတဲ့ Tool တွေဖြစ်လာမယ်။​

It needs people who can be the scalpel and the hammer.
- Six

ဒါဖတ်ပြီးပြီဆိုရင် Exercise အနေနဲ့ ကိုယ့် Development Team ထဲမှာ "Unit" ဆိုတာကို ဘယ်လိုမြင်လဲဆိုပြီး အဓိပ္ပာယ်လိုက်ဖွင့်ခိုင်းကြည့်၊ တစ်ယောက်တစ်မျိုးပြောလိမ့်မယ်။​ ဒါတွေကိုမှတ်ထား၊ ပြီးရင် အချိန် ၁နာရီလောက်ပေးပြီး ဆွေးနွေးခိုင်း (ငြင်းခိုင်း 😆) လိုက်။ ဆွေးနွေးရင်းနဲ့မှ ငါတို့ Testing ကလိုချင်တဲ့ Value တွေက ဘာဆိုတာမြင်လာပြီး နောက်ဆုံးမှာ အားလုံးလက်ခဲ့တဲ့ "Unit" ရဲ့အဓိပ္ပာယ်တစ်ခုရလာလိမ့်မယ်။ ဒီလိုသိပြီဆိုတော့မှ အားလုံးနားလည်တဲ့ Testing Strategy တစ်ခုကိုကောင်းကောင်းချမှတ်ထားလို့ရမယ်။

ခုမှစလေ့လာမယ်သူတွေကကြတော့ Mockist နဲ့စဖို့အကြံပေးချင်တယ်။ သူကရေးရတာလည်းပိုလွယ်တယ်။ အဲကနေမှ Mock တွေလျှော့သုံးပြီး Fake Implementation လေးတွေ စရေးဖို့ကျင့်။​ ကျွမ်းကျင်လာပြီဆိုရင် High Level Test တွေကိုရေးပြီး Classicist Test တွေလည်း ရေးတတ်အောင်ကျင့်ဖို့အကြံပေးချင်ပါတယ်။


ဒီလို နည်းပညာအကြောင်းတွေကို သဘောကျတယ်ဆိုရင် တစ်လ Baht 1၀၀ နဲ့ အားပေးလို့ရနေပြီနော်။ Supporter တွေအနေနဲ့ Comment မှာလည်း သိချင်တာတွေရှိရင်မေးလို့ရပါတယ်။ ကျွန်တော်အကုန်လုံးကို ဖြေပေးသွားပါမယ်။