Android 寫測試系列 (2) — BDD 介紹和一般物件寫測試

黃德銘
Dcard Tech Blog
Published in
8 min readApr 26, 2022

上一篇有簡單介紹了 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 的結構是

官方文件介紹只有用到 describeit 2 個 keyword,但是還有 context 可以使用,我就把 context 拿來連接情境用了。之後文章內的測試都會用 DescribeSpec 來寫,喜歡 BehaviourSpec 也可以自己轉換。

撰寫測試

那麼我們來準備個案例,公司開發的聊天軟體想要新增貼圖功能,在經過討論之後整理出了下面幾個規格:

  • call api 取得可用貼圖
  • 選擇貼圖後有預覽畫面,並送事件至 Firebase
  • 在預覽畫面出現時再選擇相同貼圖一次就關掉預覽畫面,並送事件至 Firebase
  • 在預覽畫面出現時點擊預覽畫面就關掉預覽畫面,並送事件至 Firebase
  • 點擊預覽畫面的關閉就關掉預覽畫面,並送事件至 Firebase

經過開發後我們寫出了以下的程式

在寫測試時除非是做整合測試,不然會將測試目標以外的物件盡可能遮蔽掉,不然會有一些意料之外的情境發生,以上面的例子來說,每 call 一次 fetchSticker() 就會真的打一次 api,然後每 call 一次 onStickerClicked() 就會送追蹤出去,隨著時間過去就會累積不少髒資料,為了避免這種情況,我們就必須 mock StickerRepositoryFirebaseAnalyticsHelper 這 2 個物件,上面的程式可以改寫成這樣

我們將StickerRepositoryFirebaseAnalyticsHelper 這 2 個物件改從外面給進來,配合在上一篇 MockK 的介紹,我們可以將 mock 物件傳進來,這樣我們就可以檢驗 api 和送追蹤有沒有正確的被執行,接著我們就針對這個物件來寫測試,首先我們先來做準備動作

beforeSpec 在 Kotest 的生命週期中是最早的,所以可以把 mock 設定寫在這區,要寫成下面那樣也可以,只是就有資料產生的先後順序問題,我會把不需要使用其他資料來源的在產生 mock 物件時一併初始化,其餘的寫在 beforeSpec

首先我們來描述第一個規格 call api 取得可用貼圖

在執行 fetchSticker() 後我們可以檢驗預期中的動作有沒有被執行,以及最後資料的狀態是不是如我們想像的,所以我就檢驗了這些資料。

接著是選擇貼圖後有預覽畫面,並送事件至 Firebase

因為 BDD 是以行為為主,在使用者能點擊貼圖時必須先取得可用貼圖,所以需要先取得貼圖資料,但這個行為在我們的測試中並不重要,所以在描述行為時我就直接略過。此外,這次主要是測試第二個動作,所以第一個動作的結果要不要檢驗都可以,有進行檢驗的話出錯機率會更低。

剩餘的情境沒什麼特別的,所以就不一一介紹,我就將全部寫完的測試供大家參考

這次寫的測試是基於可以將外部物件注入進來的方式,那如果真的沒辦法注入的話還有辦法寫測試嗎?

可以 😎,MockK 有提供比較特殊的 mock 方式,那麼就將上面的 StickerHandler 還原成直接在內部使用外部 Singleton 物件的版本,改名為 StickerHandler2 並且來寫幾項測試

我們有 2 件事情要做

  1. 透過 mockkObject 來 mock StickerRepositoryFirebaseAnalyticsHelper
  2. mock 上面 2 個物件的 function

這 2 件事情做完後,測試看起來跟原本有 87% 像,你可能會想:

這個看起來這麼方便,我幹嘛要用注入的?

這個問題我自己有 2 個答案

  1. 透過 mockkObject mock 的物件沒有 mock 的 function 會照常執行,當未來邏輯有改動時,可能會用到 StickerRepository 的其他 function。現有的測試因為沒有 mock 其他 function,正常執行下也許測試不會報錯,但沒報錯未必是對的,也有可能你怎麼看動作都是對的但一直報錯,原因在於 mockkObject mock 的是 Kotlin object 物件,也就是個 Singleton 物件,Singleton 物件有些可能持有一些資料或狀態,但在這些測試中大家都會共用遺留的資料,最後變成不同的測試會互相干擾,最好的做法還是看你測試的重點在哪邊,把其他的變因給隔絕掉。
  2. 降低物件之間的耦合程度採用 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

--

--

黃德銘
Dcard Tech Blog

愛東玩西玩的 Android 工程師,玩過 Ktor, Jetpack Compose for Desktop 和 iOS