Android 寫測試系列 (2) — BDD 介紹和一般物件寫測試
上一篇有簡單介紹了 MockK,這篇會介紹 Kotest 的 DescribeSpec
,利用 DescribeSpec 的特性可減少在設定環境時重複的程式碼,並設定情境來對一般物件撰寫測試,希望大家能夠更加了解要怎麼撰寫測試。
BDD (Behavior-driven development)
在介紹如何寫測試之前,想先介紹什麼是 BDD,BDD 是以使用者的操作為主,用描述的方式將各個規格和情境構建出來,讓閱讀測試的人能夠很快地瞭解被測試的物件有什麼功能。與 TDD(Test-driven development) 不同,BDD 的測試由於是用描述情境的方式,可以讓各個領域像是 PM、設計師…等等的人一起將情境描述的更加完整,越加完整的情境描述就越能代表這個物件的規格,在前公司我也會把撰寫的情境同步給 PM 看,偶爾會發現部份情境與其他平台(iOS, Web) 不同,也許能幫助減少一些 bug 的產生。另外近期敏捷開發在台灣越來越流行,其中 Scrum 的 User Story 就可以拆解成許多情境,所以使用 BDD 寫測試優點相當多。
不同測試 Framework 的 BDD 結構會有些不一樣,Cucumber 的結構是
撰寫的重點有
- 描述情境
- 設定情境所需的環境
- 觸發事件
- 檢驗結果
以上面的範例看起來情境是接續發生,但實際上兩個情境是平行世界,概念上是第一個情境測試完了之後接著去測試衍生的情境,所以不要認為說情境一定要一連串的寫下去,除非你的情境是如此,整體的撰寫流程可以歸納如下
說完優點之後就必須說個缺點,我認為 BDD 的最大缺點是必須透過額外的測試 framework 才有較好的閱讀性,以 Android 原生支援的 JUnit 來開發大概會寫成這樣子:
名字可能長到難以描述當前情境之外,JUnit 測試的執行順序並不是依序執行,所以會出現各種不同功能的情境交互穿插,以及各種測試都跑完後最後才跑測試初始化的情境的情況發生,這對於一個敘述型測試來說是十分干擾的,最後還是得使用第三方的 framework 才行。
Kotest 提供的撰寫 style 中,適合寫 BDD 的有 FeatureSpec
, BehaviourSpec
和 DescribeSpec
FeatureSpec 的結構為
在 Scenario 中沒有其他關鍵字可以使用,在描述一些較複雜的情境就會稍微麻煩。
BehaviourSpec 的結構是
有豐富的 keyword 來敘述整個情境,在複雜的情境可以用 And
來做多個階層,我以前不使用的原因是因為早期只有小寫的 keyword,在寫到 When 的時候就需要寫成 `when`
,這看起來就十分不舒服,所以就沒選擇它,但現在已是滿完整的撰寫方式。
DescribeSpec 的結構是
官方文件介紹只有用到 describe
和 it
2 個 keyword,但是還有 context
可以使用,我就把 context
拿來連接情境用了。之後文章內的測試都會用 DescribeSpec 來寫,喜歡 BehaviourSpec 也可以自己轉換。
撰寫測試
那麼我們來準備個案例,公司開發的聊天軟體想要新增貼圖功能,在經過討論之後整理出了下面幾個規格:
- call api 取得可用貼圖
- 選擇貼圖後有預覽畫面,並送事件至 Firebase
- 在預覽畫面出現時再選擇相同貼圖一次就關掉預覽畫面,並送事件至 Firebase
- 在預覽畫面出現時點擊預覽畫面就關掉預覽畫面,並送事件至 Firebase
- 點擊預覽畫面的關閉就關掉預覽畫面,並送事件至 Firebase
經過開發後我們寫出了以下的程式
在寫測試時除非是做整合測試,不然會將測試目標以外的物件盡可能遮蔽掉,不然會有一些意料之外的情境發生,以上面的例子來說,每 call 一次 fetchSticker()
就會真的打一次 api,然後每 call 一次 onStickerClicked()
就會送追蹤出去,隨著時間過去就會累積不少髒資料,為了避免這種情況,我們就必須 mock StickerRepository
和 FirebaseAnalyticsHelper
這 2 個物件,上面的程式可以改寫成這樣
我們將StickerRepository
和 FirebaseAnalyticsHelper
這 2 個物件改從外面給進來,配合在上一篇 MockK 的介紹,我們可以將 mock 物件傳進來,這樣我們就可以檢驗 api 和送追蹤有沒有正確的被執行,接著我們就針對這個物件來寫測試,首先我們先來做準備動作
beforeSpec
在 Kotest 的生命週期中是最早的,所以可以把 mock 設定寫在這區,要寫成下面那樣也可以,只是就有資料產生的先後順序問題,我會把不需要使用其他資料來源的在產生 mock 物件時一併初始化,其餘的寫在 beforeSpec
中
首先我們來描述第一個規格 call api 取得可用貼圖
在執行 fetchSticker()
後我們可以檢驗預期中的動作有沒有被執行,以及最後資料的狀態是不是如我們想像的,所以我就檢驗了這些資料。
接著是選擇貼圖後有預覽畫面,並送事件至 Firebase
因為 BDD 是以行為為主,在使用者能點擊貼圖時必須先取得可用貼圖,所以需要先取得貼圖資料,但這個行為在我們的測試中並不重要,所以在描述行為時我就直接略過。此外,這次主要是測試第二個動作,所以第一個動作的結果要不要檢驗都可以,有進行檢驗的話出錯機率會更低。
剩餘的情境沒什麼特別的,所以就不一一介紹,我就將全部寫完的測試供大家參考
這次寫的測試是基於可以將外部物件注入進來的方式,那如果真的沒辦法注入的話還有辦法寫測試嗎?
可以 😎,MockK 有提供比較特殊的 mock 方式,那麼就將上面的 StickerHandler
還原成直接在內部使用外部 Singleton 物件的版本,改名為 StickerHandler2
並且來寫幾項測試
我們有 2 件事情要做
- 透過
mockkObject
來 mockStickerRepository
和FirebaseAnalyticsHelper
- mock 上面 2 個物件的 function
這 2 件事情做完後,測試看起來跟原本有 87% 像,你可能會想:
這個看起來這麼方便,我幹嘛要用注入的?
這個問題我自己有 2 個答案
- 透過
mockkObject
mock 的物件沒有 mock 的 function 會照常執行,當未來邏輯有改動時,可能會用到StickerRepository
的其他 function。現有的測試因為沒有 mock 其他 function,正常執行下也許測試不會報錯,但沒報錯未必是對的,也有可能你怎麼看動作都是對的但一直報錯,原因在於mockkObject
mock 的是 Kotlinobject
物件,也就是個 Singleton 物件,Singleton 物件有些可能持有一些資料或狀態,但在這些測試中大家都會共用遺留的資料,最後變成不同的測試會互相干擾,最好的做法還是看你測試的重點在哪邊,把其他的變因給隔絕掉。 - 降低物件之間的耦合程度採用 IoC(控制反轉),甚至專案有使用 DI(依賴注入)的話,這些 Singleton 物件用注入的方式也不是什麼難事。
這篇介紹了怎麼針對一般物件寫測試,由於情境比較簡單,所以沒有利用 context
將不同的條件串聯起來,在未來撰寫測試時會有機會使用到。在 call api 的部分是使用 Coroutine 的 suspend
function 來做,現在像是 ViewModel 之類的物件會有自己的 CoroutineScope,或是生命週期較長的 Singleton 物件有自己的 CoroutineScope 會比較方便,下一篇會介紹如何針對有 CoroutineScope 的物件做測試。
最後幫大家整理 3 個重點
參考資料:
最後順便一提,Dcard 最近正在招募 Android 團隊的新夥伴,目前 Android team 主要是以 Kotlin 開發。想知道更多的朋友,歡迎來看看之前介紹 Android 團隊的文章,也隨時歡迎來和我們聊聊!
Senior Android Developer — https://dcard.link/h58NhK
Junior Android Developer — https://dcard.link/tSpeOi
Senior Android Developer (Ads) — https://dcard.link/oEqHki