--- title: 'Fake 與 Stub & Mock' disqus: kyleAlien --- Fake 與 Stub & Mock === ## OverView of Content [TOC] ## 測試遇到的問題 ### 外部依賴 * **外部依賴**:一般我們在類與類之間會有一個依賴,讓每個類有獨立功能(用來互動),而常常我們的依賴會使用具體物件依賴; **如果這個具體物件是不可控制的,那就無法進行測試** ```kotlin= // External dependency 範例 class Weather { fun isSunny() : Boolean { TODO("網路請求...") // 網路請求,其結果不穩定,無法測試 } } // 依賴 Weather class CampingDependencyWeather constructor(private val weather: Weather) { fun canBookingPlace() : Boolean { if (!weather.isSunny()) { return false } return true } } ``` :::warning 從上面我們可以找到在測試目標(Camping)中的依賴(Weather),我們可以稱這裡為界面(並非 interface 的意思) ::: ### Seam 間隙 & Refactor 重構 * 首先我們先來了解何謂 Seam 間隙 & Refactor 重構 | 詞語 | 定義 | | - | - | | Seam | 是指程式碼可以 **抽換功能的地方** (遵循了 OOP 六大規則中的 "依賴倒置")| | Refactor | 不改變程式碼(函數原有功能,類原本的責任)功能的前提下進行優化,其中包括了可讀性重構,結構重構... 等等 | :::danger * **重構先決條件** 在進行重構之前,請先務必確保你的程式有自動測試的保護,或是你能夠 **清楚的知道這段程式的 ++所有++ 需求結果**! 這樣你才能確保你重構出的程式能夠嵌入舊程式而沒有問題 ::: * **Seam** 縫隙:測試中要解除對外部的不穩定因素依賴,就必須 ^1.^ **先發現外部依賴然後**,然後再 ^2.^ **製作 Seam**,透過這個 Seam 來達成假物件的替換 > 當然這 **假物件也有分類,下面會在提及** ```kotlin= // 2. 製作 Seam (準備重構) class Weather { fun isSunny() : Boolean { TODO("網路請求...") } } // 1. 外部依賴 Weather 實體類 class CampingDependencyWeather constructor(private val weather: Weather) { fun canBookingPlace() : Boolean { if (!weather.isSunny()) { return false } return true } } ``` * **Refactor**:重構分為兩個重點步驟 1. **整個外部依賴類的改變**:將原先的具體類抽換成接口(interface)、委託代理(delegates) * 擷取接口,替換底層實做 ```kotlin= interface IWeather { fun isSunny() : Boolean } class RefactorWeather : IWeather { override fun isSunny() : Boolean { TODO("網路請求...") } } ``` 2. **將原本相依的實體類,替換為依賴抽象**:將偽 interface、delegates 注入實做類 > Fake & Stub 差異下面會說明 * 在測試中注入 Stub(虛設常式)物件 * 屬性注入 Fake 物件 * 方法注入 Fake 物件 * 建構函數中注入 Fake 物件 ```kotlin= // 建構函數注入 class ReactorCampingDependencyWeather constructor( // 依賴抽象 private val weather: IWeather) { fun canBookingPlace() : Boolean { // 使用假物件 if (!weather.isSunny()) { return false } return true } } ``` 以下測試方案使用 Stub `IWeather` 接口 ```kotlin= @Test fun canBookingPlace_notSunny_false() { val stubNotSunny = object : IWeather { override fun isSunny(): Boolean { return false } } assertFalse { ReactorCampingDependencyWeather(stubNotSunny).canBookingPlace() } } ``` ## 假物件 - 分類 在做單元測試時,我們總會需要有一些假設狀況,為了這些假設而製造出來的物件,我們就可以稱其為假物件,分清這些假物件可以讓我們以後更輕鬆的一看名稱就知道其功能 > 建構在相同的認知下去寫測試會更快速、更好維護 其中假物件又可分為下表 | 假物件分類 | 責任 | | -------- | -------- | | Stub(虛設常式) | 模擬當前測試時,所需的假設狀況,並且該狀況是不可變動的(這樣才符合常式) | | Mock(模擬物件) | 驗證當前測試時,與物件的互動性 | | Fake (廣義假物件) | 可操控的假物件 (它可被當作 `Stub` or `Mock` 物件) | :::info * 名詞補充,SUT (System under test): SUT 就是被測試的物件,也有人稱 CUT (Class under test) ::: ### 虛設常式 Stub * **虛設常式 Stub**:就是描述了一個假的情況,並請該物件 **在任何狀況下都不允許更改其內部設置**(狀況需保持不變,才能確保測試的正確性) ```kotlin= interface ICost { fun getCost() : Int } interface IWeather { fun isSunny() : Boolean } class Camping { enum class Type { NON, INNER_TENT, BIG_TENT, NORMAL_TENT, } // 待測試函數 fun getBookingTentType(cost : ICost, weather : IWeather) : Type { val userCost = cost.getCost() val isSunny = weather.isSunny() if(!isSunny) { return Type.INNER_TENT } return when(userCost) { in 1000..2000 -> Type.NORMAL_TENT in 2001 .. Int.MAX_VALUE -> Type.BIG_TENT else -> Type.NON } } } ``` * 從上面程式中我們可以觀察到在呼叫 `getBookingTentType` 函數時,需要帶入兩個接口,這個兩接口就可以做 Stub (照測試情況假設) ```kotlin= // 測試非 isSunny 回傳 false 應該要有的回復情況 @Test fun test_getBookingTentType_NotSunnyDay() { // stub 物件 val stubWeather = object : IWeather { override fun isSunny(): Boolean { return false // 固定返回 } } // stub 物件 val stubCost = object : ICost { override fun getCost(): Int { return 1000 // 固定返回 } } // 待測物件 val sut = Camping() assertEquals(Camping.Type.INNER_TENT, sut.getBookingTentType(stubCost, stubWeather)) } ``` 測試程式與 SUT 與 Stub 假物件的關係如下 > ![](https://i.imgur.com/D3dpn8b.png) :::warning * **要搞清楚測試目的,才能做正確假設** ! ::: ### 模擬物件 Mock * **模擬物件 Mock**:用來驗證目標物件 (SUT),與其相依物件 (Interface) 之間的互動情況;這種情況是你可能需要驗證 相依物件 是否會被呼叫,或是傳入 相依物件 的參數... 等等情況 ```kotlin= interface ICost { fun getCost() : Int } interface IWeather { fun isSunny() : Boolean } interface IEmail { fun sendEmail(type : Camping.Type) } class Camping { enum class Type { NON, INNER_TENT, BIG_TENT, NORMAL_TENT, } fun getBookingTentTypeWithMail(cost : ICost, weather : IWeather, email : IEmail) : Type { val userCost = cost.getCost() val isSunny = weather.isSunny() val result: Type = if(!isSunny) { Type.INNER_TENT } else { when(userCost) { in 1000..2000 -> Type.NORMAL_TENT in 2001 .. Int.MAX_VALUE -> Type.BIG_TENT else -> Type.NON } } // 通知使用者 email.sendEmail(type = result) return result } } ``` 這裡我們驗證 IEmail#`sendEmail` 函數是否收到正確參數;以下我們就來 **手刻** Mock 物件,Mock 物件的重點在於儲存狀態,最終要驗證狀態 ```kotlin= class FakeEmail : IEmail { // 儲存狀態 var getType : Camping.Type? = null override fun sendEmail(type: Camping.Type) { getType = type } } @Test fun test_getBookingTentTypeWithMail() { val exceptType : Camping.Type = Camping.Type.INNER_TENT var getType : Camping.Type? = null // 匿名 Mock 物件 val mockEmail = FakeEmail()} val stubWeather = object : IWeather { override fun isSunny(): Boolean { return false } } val stubCost = object : ICost { override fun getCost(): Int { return 1000 } } val sut = Camping() sut.getBookingTentTypeWithMail(stubCost, stubWeather, mockEmail) // 驗證狀態 assertEquals(exceptType, mockEmail.getType) } ``` > ![](https://i.imgur.com/B3QVBz3.png) 測試程式與 SUT 與 Mock 假物件的關係如下 (忽略 Stub),跟 Stub 的差異是 Mock 是驗證 **互動後的結果 !** > ![](https://i.imgur.com/9MhTkKv.png) :::warning * **要搞清楚測試目的,當前的目的是為了驗證 Mock 的結果** ! * 驗證的 assert 請勿寫在 Mock 物件中 > 未來閱讀程式也不方便知道最初測試的用意、目的 ::: :::danger * 請保持一個測試中只有一個 Mock 物件,因為我們要保持一個測試只測一個結果 ::: ### Stub & Mock 差異 * Stub & Mock 差異如下表 | 假物件類型 | 特點 | 測定目標 | | -------- | -------- | -------- | | Stub | Stub 固定要測試的狀況 | SUT 回傳的結果 | | Mock | 驗證與 Mock 之間的運作 | Mock 回傳的結果 | * Mock 這種互動測試會讓測試情況稍加複雜,如果可以的話盡量使用 Stub 測試;**Mock 代表了一個不確定性,所以一個單元測試只允許存在 ==一個 Mock 假物件==** :::warning * 兩者最根本的差異在於 虛設常式物件 (Stub) 不會讓測試失敗(它就是假設一個情景):測試最終驗證的是 SUT 而不是 Stub 而模擬物件 (Mock) 可以讓測試失敗:測試最終驗證的是 Mock 物件,而不是 SUT ::: ### 廣義假物件 Fake * Fake 可作為 Stub & Mock 所以它有著可變性,所以如果使用 fake 物件請清楚命名為 fake 物件,錯誤的命名可能會導致你對測試的閱讀時間拉長 :::info Fake 在模凌兩可之間,所以使用它要特別 **注意命名** (宣告出的變數的命名) ::: * 同樣用上面的例子 (訂露營區範例:天氣、花費、通知)來建立 fake 物件,並且我們的命名也不詳加定義它是 Stub 還是 Mock ```kotlin= class FakeIWeather constructor(private val isSunnyRes : Boolean): IWeather { override fun isSunny(): Boolean { return isSunnyRes } } class FakeICost constructor(private val costRes : Int): ICost { override fun getCost(): Int { return costRes } } class FakeIEmail : IEmail { private var getType: Camping.Type? = null override fun sendEmail(type: Camping.Type) { getType = type } fun getExpectType(cb: (Camping.Type?) -> Unit) { cb.invoke(getType) } } ``` **最終 Fake 物件是屬於 Stub 還是 Mock 則需要由測試程式來定義!** ```kotlin= @Test fun test_getBookingTentTypeWithMail() { val exceptType : Camping.Type = Camping.Type.INNER_TENT val stubWeather = FakeIWeather(false) val stubCost = FakeICost(1000) val mockEmail = FakeIEmail() // SUT Camping().also { sut -> sut.getBookingTentTypeWithMail(stubCost, stubWeather, mockEmail) } mockEmail.getExpectType { realType -> assertEquals(exceptType, realType) } } ``` > ![](https://i.imgur.com/u1wChw7.png) ## 依賴注入 基於接口的 Seam 有很多種方法可以實做,像是從建構函數注入、方法注入...等等 ### 建構函數注入 ```kotlin= // 待測試程式如下 class ReactorCampingDependencyWeather constructor( private val weather: IWeather) { fun canBookingPlace() : Boolean { if (!weather.isSunny()) { return false } return true } } ``` * 從建構函數注入虛擬常式 Stub,這時就可以發現測試命名相當重要 ```kotlin= // 虛擬常式 Stub class StubNotSunny : IWeather { override fun isSunny(): Boolean { return false } } @Test fun canBookingPlace_notSunny_false() { val stubNotSunny = StubNotSunny() assertFalse { // 建構函數注入 ReactorCampingDependencyWeather(stubNotSunny).canBookingPlace() } } ``` > ![](https://i.imgur.com/myMMyaP.png) * **從建構函數注入時,會衍生出幾個問題、注意點** 1. **建構函數的依賴越多,在測試時就越需要注入更多類別** ```kotlin= // 建構函數隨著時間的更迭會越依賴越多 class DependMultiObjectOnConstruct constructor( private val cost: ICost, private val weather: IWeather, private val email: IEmail) { ... } ``` 這時可以透過兩個方案解決 * 建立一個中間界面,來處理對應的依賴 ```kotlin= // 在測試時只需要做需要的假界面即可 interface DependObj { fun getCost() : ICost fun getWeather() : IWeather fun getEmail() : IEmail } // 依賴中間接口層 class DependMultiObjectOnConstruct2 constructor( private val obj: DependObj) ``` * 使用 IoC(Inversion of Control)容器來達到依賴反轉的功能,透過需求方自己去獲取依賴方的物件來用,就是 IoC 的基本概念 :::warning 以我來說 IoC 如果用不習慣的話,可讀性會變差;應該想想自己的專案是否適合使用,或者思考是否需要修改設計 ::: 2. **何時才是使用建構函數注入的最好時機?** * 建構函數的參數概念就是:對於這個類的必須依賴!如果是非必須依賴則可以改用 setter/getter 來替換注入方案 * 或是你不希望該依賴被外部透過其他方法替換(eg. 不想對外暴露 setter/getter 方法... 等等),也可以在建構函數時注入 * 使用 IoC 套件框架 ### 方法 / 屬性注入 ```kotlin= // 待測試程式如下 class CampingDIMethod { // 屬性注入 lateinit var weather: IWeather // 待測方法 fun canBookingPlace() : Boolean { if (!weather.isSunny()) { return false } return true } } ``` * 透過方法注入可以讓程式更加易讀,也更簡單可以做出測試 (它不會像建構函數的代價這樣大,但是它仍有它必須注意的點) ```kotlin= class NotSunny : IWeather { override fun isSunny(): Boolean { return false } } @Test fun canBookingPlace_DIMethod_False() { val stubNotSunny = NotSunny() val sut = CampingDIMethod().apply { // 呼叫測試目標之前注入 weather = stubNotSunny } assertFalse { // 測試目標 sut.canBookingPlace() } } ``` :::info * 何時才是使用方法 / 屬性注入的最好時機? 使用方法 / 屬性注入時,代表了這是可被替換的物件,它甚至可能不是必須物件,這時就要思考對於設計而言,它是否是 **可以被替換 or 非必須的** ::: > ![](https://i.imgur.com/92dvqrZ.png) * 這裡可以使用一個工廠模式來包裝 IWeather,並讓 SUT 物件依賴工廠,這時我們就可以透過在工廠下創建測試所需的一切狀態,而不用修改到 SUT 物件 以下是一個靜態工廠的測試重構 ```kotlin= // 相依物件使用 CampingCompose 隔開 data class CampingCompose(val weather : IWeather) // 透過設定 Factory 來改變物件特性 object DependencyStaticFactory { private var custom : CampingCompose? = null fun get() : CampingCompose { return custom ?: throw Exception("`setCampingCompose` first") } fun setCampingCompose(compose : CampingCompose?) { custom = compose } } class CampingDependencyFactory { // 依賴靜態工廠所產生出來的元素 private val compose : CampingCompose = DependencyStaticFactory.get() fun canBookingPlace() : Boolean { // 使用靜態工廠所產出的元素 if (!compose.weather.isSunny()) { return false } return true } } ``` :::danger * **注意**: 由於是使用靜態工廠,所以會保留靜態物件,要在測試完畢後立刻釋放物件,否則會導致下一個測試錯誤(拿到上一個測試的假物件) ::: ```kotlin= @AfterEach fun tearDown() { // 釋放測試 DependencyStaticFactory.setCampingCompose(null) } @Test fun canBookingPlace_SunnyDay_True() { val stubWeather = object : IWeather { override fun isSunny(): Boolean { return true } } // 測試前先設定工廠 DependencyStaticFactory.setCampingCompose(CampingCompose(stubWeather)) val sut = CampingDependencyFactory() assertTrue { sut.canBookingPlace() } } ``` > ![](https://i.imgur.com/A7Hejs5.png) ## 偽造深度 我們要知道偽造深度越深,你對測試的程式的控制能力就越強,相對的你的程式會變得越難理解(抽象過重)、並且測試程式會變長;所以這裡我們要討論一下偽造深度 ### 深度一 | 偽造深度 | 說明 | 操作 | e.g | | -------- | - | -------- | -------- | | 1 | 使用建構函數、setter 方式注入假物件 | 保持 SUT 其他成員都是真的,只有一個成員是偽造的 | DI | * 要考慮到添加這個方法、建構函數是否合理?除非有很好的理由,不然建議還是少用(雖然很簡單) ### 深度二 | 偽造深度 | 說明 | 操作 | e.g | | -------- | - | -------- | -------- | | 2 | 製造一個 中間類 並將 SUT 依賴工廠類 | 透過偽造工廠產出的物件來達到 SUT 類不須改變 | 中間類 + 工廠設計 | * 基本上是改變了 SUT 相依物件的關係,透過一層工廠進行隔離,使用起來不難 :::info 但這裡的重點就會變成是誰會使用這個工廠,這個工廠的使用時機點 ::: ### 深度三 | 偽造深度 | 說明 | 操作 | e.g | | -------- | - | -------- | -------- | | 3 | 製造一個 中間類 並將 SUT 依賴抽象工廠類 | 依賴假工廠,也就是連工廠類都可以隨意替換 | 中間類 + 抽象工廠設計 | * 簡單來說:讓 SUT 依賴在一個抽象接口類別,而這個抽象接口又依賴於令一個抽象接口(抽象依賴抽象),相對來講自由度高,不過卻不易理解;可以考量後再使用 ## 其他 ### 測試手段 - Extract & Override * 在上面我們所提出的方案都是發現 Seam、注入假物件、製造中間類、工廠方法... 等等,然而越接近 SUT 本身,我們所需要模擬的物件就相對來說減少 * 控制被測試類(SUT):透過 **繼承 SUT 物件,重寫他的依賴類別**,同樣可以達到注入假物件的功能 ```kotlin= class WeatherManager : IWeather { override fun isSunny(): Boolean { TODO("耗時網路請求") } } // 使用 open 代表可以被繼承 open class CampingExtractOverride { // open 代表可被 Override open fun getWeather(): WeatherManager { return WeatherManager() } fun canBookingPlace() : Boolean { if (!getWeather().isSunny()) { return false } return true } } ``` 測試時只須繼承 `CampingExtractOverride` 並複寫其相依 `getWeather()` 方法即可注入假物件 ```kotlin= class SunnyDay : IWeather { override fun isSunny(): Boolean { return true } } class CampingWithStubWeather : CampingExtractOverride() { override fun getWeather(): IWeather { return SunnyDay() } } @Test fun canBookingPlace_SunnyDay_True() { val sut = CampingWithStubWeather() assertTrue { sut.canBookingPlace() } } ``` > ![](https://i.imgur.com/PufuRzz.png) ### 封裝問題 * 往往學過程式設計者會關心這個類別是否為了 DI 暴露了更多的細節,亦或是該類別是否繼承可被繼承、私有類(方法)不可被繼承... 等等問題 :::success * **物件導向的原則**: **為了限制 API (類別) 的最終使用者的行為**,避免被誤用 * **過度保護設計**: 不許修改、私有建構函數(方法)、不可繼承、不可複寫... 等等都是過度保護設計的特徵 ::: ### 鏈式呼叫的牽扯 * 以往我們在設計程式時,常常會碰到鏈式呼叫(在程式中很好用);但我們要思考鏈式呼叫在測試中就需要作多個假設的情況,對於測試是不太健康的 ```kotlin= // 典型鏈式調用就是 Builder 模式 open class CPU { var core: Int = 2 var hzG: Int = 200 } class Power { enum class PowerCooling { WATER, FAN, OIL, } var type: PowerCooling = PowerCooling.FAN var w: Int = 300 } class Memory { var count: Int = 1 var sizeG: Int = 1 } class Computer(val cpu: CPU, val power: Power, val memory: Memory) { open class Builder { open fun create() : Computer { return Computer(CPU(), Power(), Memory()) } } } class MyComputer { // 測試目標 fun isNormalComputerCanHold(computer: Computer) : Boolean{ // 鏈式調用 val cpuInfo = Computer.Builder().create().cpu return cpuInfo.hzG > computer.cpu.hzG } } ``` 如果上面程式要進行測試,就必須鏈式模擬 ```kotlin= class FakeCpu(private val hz: Int) : CPU() { override var hzG: Int get() = hz set(value) { throw Exception() } } class FakeBuilder(private val cpu: CPU): Computer.Builder() { override fun create(): Computer { return Computer(cpu, Power(), Memory()) } } @Test fun isComputerCanHold_300HzCannotHold() { // 鏈式模擬 val fakeBuilder = FakeBuilder(FakeCpu(300)) val sut = MyComputer() assertFalse { sut.isNormalComputerCanHold(fakeBuilder.create()) } } ``` > ![](https://i.imgur.com/alsr6l5.png) * 上面範例中的鏈式模擬,其實對於測試不太友好,甚至你可以思考一下是否真的有需要使用鏈式調用,或考慮改用 `Extract & Override` 的方案 ```kotlin= open class Computer(val cpu: CPU, val power: Power, val memory: Memory) { open class Builder { open fun create() : Computer { return Computer(CPU(), Power(), Memory()) } } open fun getCpuHz() : Int{ return cpu.hzG } } ``` 測試就可以更簡單的透過 Override `getCpuHz` 來做一個假數據 ```kotlin= class FakeComputer(private val hz: Int): Computer(CPU(), Power(), Memory()) { // Override 目標函數 override fun getCpuHz(): Int { return hz } } @Test fun isComputerCanHold2_300HzCannotHold() { val fakeComputer = FakeComputer(300) val sut = MyComputer() assertFalse { sut.isNormalComputerCanHold(fakeComputer) } } ``` > ![](https://i.imgur.com/r8Z0WlS.png) ### 手刻物件的問題 * 手刻物件其實最大的問題就是耗時、較少能重複使用,要針對每個測試案例狀況去寫物件,並且該物件如果重複使用的話也會造成不同測試之間的依賴 * By the way, 如果一個模擬物件帶有回傳值,那這個模擬物件就同時有 `Stub` and `Mock` 的特性,這時你要更清楚的知道你要測試的目標倒底是倒底是哪個 ```kotlin= // 模擬物件同時含有兩種特性 interface MyNotify { boolean sendNotifyMsg(str: String) } ``` ## Appendix & FAQ :::info ::: ###### tags: `Test`