---
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 假物件的關係如下
> 
:::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)
}
```
> 
測試程式與 SUT 與 Mock 假物件的關係如下 (忽略 Stub),跟 Stub 的差異是 Mock 是驗證 **互動後的結果 !**
> 
:::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)
}
}
```
> 
## 依賴注入
基於接口的 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()
}
}
```
> 
* **從建構函數注入時,會衍生出幾個問題、注意點**
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 非必須的**
:::
> 
* 這裡可以使用一個工廠模式來包裝 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()
}
}
```
> 
## 偽造深度
我們要知道偽造深度越深,你對測試的程式的控制能力就越強,相對的你的程式會變得越難理解(抽象過重)、並且測試程式會變長;所以這裡我們要討論一下偽造深度
### 深度一
| 偽造深度 | 說明 | 操作 | 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()
}
}
```
> 
### 封裝問題
* 往往學過程式設計者會關心這個類別是否為了 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())
}
}
```
> 
* 上面範例中的鏈式模擬,其實對於測試不太友好,甚至你可以思考一下是否真的有需要使用鏈式調用,或考慮改用 `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)
}
}
```
> 
### 手刻物件的問題
* 手刻物件其實最大的問題就是耗時、較少能重複使用,要針對每個測試案例狀況去寫物件,並且該物件如果重複使用的話也會造成不同測試之間的依賴
* By the way, 如果一個模擬物件帶有回傳值,那這個模擬物件就同時有 `Stub` and `Mock` 的特性,這時你要更清楚的知道你要測試的目標倒底是倒底是哪個
```kotlin=
// 模擬物件同時含有兩種特性
interface MyNotify {
boolean sendNotifyMsg(str: String)
}
```
## Appendix & FAQ
:::info
:::
###### tags: `Test`