前言
BDD 是基於 TDD 衍生而來的一種開發方式,如果你還不瞭解 TDD 的話,可以先看我上一篇講 TDD 的文章。
接下來的內容會分為 BDD 入門和 BDD 示例兩個部分進行講解。
-
BDD 入門
1.1 BDD 由來
1.2 軟體開發流程
1.3 BDD 過程
1.4 Cucumber
-
BDD 示例
2.1 建立特性檔案
2.2 編寫生產程式碼
2.3 執行測試
2.4 模擬登陸請求
2.5 Spek
2.6 測試覆蓋率
文中的程式碼在文章的最下方會有 GitHub 連結。
1. BDD 入門
BDD(行為驅動開發,Behavior-Driven Development)是一個軟體開發實踐集合,能幫助我們更快速地構建和交付業務價值更高的軟體。
1.1 BDD 由來
BDD 是由 Dan North 於 2000 年創造的,在這之前,他一直在教授 TDD 開發方法。
North 發現 TDD 有時會讓開發者陷入一種過於關注細節的情況,讓開發者在無意中,忽略了系統應滿足的業務需求。
比如用 TDD 開發一個銀行 App ,這個 App 需要轉賬和存款的功能,那麼我們首先會建立這樣的測試。
這樣的測試比沒有測試要強,但這樣的測試存在的問題就是預期輸出不明確。
這樣的測試,有可能出現測試程式碼與生產程式碼強關聯,而與業務需求無關的情況。
這樣如果想重構生產程式碼的實現時,也要重構測試程式碼。
而 North 在教授 TDD 時發現,以句子命名的單元測試可以幫助開發者寫出意義明確的測試。
意義明確的測試,能讓開發者更高效地寫出高質量的測試和生產程式碼,比如下面這樣的。
以這種方式命名的測試,和單元測試比起來與業務需求的描述更接近。
這樣的測試專注於系統應用的行為,這些測試的目的,則是表達與驗證這些行為。
North 還發現,以這種意義明確的方式編寫的測試,維護起來也更方便。
由於這些優點,North 就用一種新的名字命名這種開發方式:行為驅動開發(BDD)。
1.2 BDD 開發流程
1. 一般開發流程
一般開發流程是下面這樣的,使用者告訴產品經理自己想要的功能,然後產品經理編寫文件,開發人員和測試人員則把需求文件轉化為程式碼和測試用例。
使用這種流程進行開發,經常會出現開發人員或測試人員誤解需求的情況。
誤解需求的結果就是專案不斷返工,不斷重新開發、重新測試,導致專案延期。
2. BDD 開發流程
BDD 則是讓大家通過圍繞著具體例項進行溝通,以一種能輕易轉化為自動化測試的語言,描述需求的具體例項。
從而減少一般的開發流程,在需求理解、轉換過程中出現的資訊丟失和誤解的情況。
1.3. BDD 過程
上圖包含了 BDD 過程中的各個要素,BDD 的第一步是識別業務目標,然後是找出能滿足該業務目標的功能。
BDD 需要與使用者合作,使用具體例項描述功能,例項是以自動化的可執行說明(Executable Specifications)描述的。
例項和可執行說明都可以驗證軟體的期望行為,並提供自動更新的技術文件和功能文件。
在程式碼層面,轉化為低階說明(Low-Level Specification)的可執行說明,能幫助開發者寫出高質量、易於測試、文件健全、易於使用且易於維護的程式碼。
每一個特性的實現,都會經歷這個 BDD 過程。
1. 具體例項
實踐 BDD 的第一步是明確業務目標,比如一個銀行 App 的業務目標可能是:通過簡單且方便的賬戶管理功能吸引更多客戶。
實踐 BDD 時,當需要實現一個功能,團隊成員需要與使用者一起定義功能的各個使用場景,從而明確使用者的具體需要。
使用者則需要幫助建立起具體例項(Concrete Examples),這些例項則是功能在各個場景下應有的輸出,比如下面這樣的。
下面的程式碼是用 Gherkin 寫的,關於 Gherkin 的用法後面會有更多介紹。
一般的需求文件則充斥著歧義、對讀者知識背景的假設,從而導致讀者,如開發人員和測試人員對需求的誤解。
而 BDD 中的例項能清晰、準確且無歧義的需求說明,是一種高效溝通方式,因此具體例項在 BDD 中扮演者非常重要的角色。
例項還是一種探索和擴充套件知識的方式,當使用者提出一個功能的具體例項時,專案成員可以提出邊界示例,避免在開發時才發現沒有討論到邊界值。
這些邊界情況往往會是測試人員提出的,通過團隊成員一起討論討功能可能遇到的多個場景,就能減少測試人員在產品開發完畢後,才提出並把邊界問題拋給產品人員,導致專案返工、延期。
2. 可執行說明
可執行說明(Executable Specifications)是一些自動化測試,這些測試能描述應用的業務需求,並驗證應用是否滿足了業務需求。
可執行說明將由 Cucumber 執行,在後面會有關於 Cucumber 的更多介紹。
低階說明(Low-level Specifications)是一些單元測試,這些測試能描述應用內部函式的行為,並驗證這些函式是否符合預期的行為,是開發人員可以參考的一份良好的技術文件。
低階說明可以用 Spek 編寫,關於 Spek 的使用後面會講到。
可執行說明不只是給開發人員看的技術報告,而且還是一份可以讓團隊所有人都能看懂的產品說明文件。
這份文件與普通的需求文件不同,這份文件必須要隨著需求的變化實時更新,否則測試就會失敗。
而普通的需求文件往往是編寫完畢後,哪怕需求有變化,也不會有對應的更新。
通過可執行說明執行後的測試報告,開發人員可以瞭解已有功能的工作情況,測試人員可以瞭解哪些功能已經實現了。
產品經理或專案經理,則可以通過這份文件瞭解專案的當前進展,並決定哪些功能可以釋出到生產環境。
使用者或業務人員,則可以通過這份文件瞭解如何使用這個產品。
這份文件將隨著需求的變化實時更新,且易於維護,包含了示例以及對應的程式碼背後的期望行為。
在本文的第二部分 BDD 示例,將會進一步展示可執行說明和低階說明的具體形式。
新加入的開發人員,往往一定的時間瞭解現有的程式碼,而使用低階說明,則可以縮短這個時間。
1.4 Cucumber
Cucumber 是一個驗收測試工具、一個協作工具,最初只有 Ruby 版的。
隨著時間的推移,Cucumber 已經用很多種不同的程式語言實現了。
Cucumber 測試直接與開發人員的程式碼關聯,同時還是用一種以產品人員、業務專家能夠理解中間語言編寫的。
通過一起編寫測試,團隊成員不僅能確定下一步要實現的功能,也讓大家對於要開發的軟體功能有一致的概念。
Cucumber 測試和一般的需求文件一樣,能被相關人員閱讀和修改。
和普通需求文件不同的是,Cucumber 編寫的驗收測試能通過計算機進行驗證。
而且使用 Cucumber 編寫的驗收測試,必須要與需求的變化對應上,要是最新的,否則測試就會失敗,這樣就避免了一般的需求文件容易過時的問題。
1.4.1 Gherkin
Gherkin 是一門用於編寫 Cucumber 驗收測試的語言,用 Gherkin 寫的特性檔案的副檔名為 .feature 。
特性檔案包含了功能的簡短描述、該功能下的多個場景,以及場景下的多個例子。
Gherin 支援多種語言,如果想使用中文編寫特性檔案,只需要在特性檔案的頭部新增 #language: zh-CN。
1. 中英文對照表
Gherkin 支援的關鍵字中英文對照表如下:
英文關鍵字 | 中文關鍵字 |
---|---|
feature | "功能" |
background | "背景" |
scenario | "場景", "劇本" |
scenario_outline | "場景大綱", "劇本大綱" |
examples | "例子" |
given | "* ", "假如", "假設", "假定" |
when | "* ", "當" |
then | "* ", "那麼" |
and | "* ", "而且", "並且", "同時" |
but | "* ", "但是" |
given (code) | "假如", "假設", "假定" |
when (code) | "當" |
then (code) | "那麼" |
and (code) | "而且", "並且", "同時" |
but (code) | "但是" |
2. 功能
每一個 Gherkin 特性檔案都是從 Feature(功能)關鍵字開始的,Feature 下面可以新增這幾個關鍵字:
- Scenario
- Background
- Scenario Outline
我們可以給 Feature 新增描述,一般是用使用者故事的形式來編寫的。
使用者故事的格式為:
作為 ...(利益相關人,如使用者)
我想 ...(做什麼事情)
以便 ...(達到什麼目的)
2. 場景
場景大綱和場景的區別就在於場景大綱是可以新增例子(Examples)的,例子中可以放置一些我們想要輸入給程式的資料。
如果有多個例子,Cucumber 就會執行多次,比如有兩個例子的話,Cucumber 就會用兩個例子中的不同資料來執行測試。
為了描述我們期望的軟體行為,每個功能下都要包含一些場景。
每個場景是特定情況下,系統應有行為的具體例項,這些場景彙總起來,就是期望的功能行為。
通常一個特性會有 5 到 20 個場景,我們可以用多個場景探索邊界值、正常和異常執行路徑。
定義場景的模式通常是下面這樣的:
- 狀態準備(Given,比如跳轉到登入頁)
- 觸發變化(When,比如點選登入按鈕)
- 驗證輸出(Then,比如提示“登入成功”)
在 Gherkin 中,Given、When、Then 用於確定場景中三個不同的部分。
Given 用於建立場景發生的上下文,When 表示使用者以某種方式與系統互動,Then 則用於檢查互動的結果是否符合預期。
場景中的每一行稱為一個步驟(Step),我們可以用 And 和 But 為 Given、When、Then 加入更多步驟。
Cucumber 實際上並不關心你用的是什麼關鍵字,這些關鍵字的目的是提升特性檔案的可讀性。
3. 例子
在前面提到的場景大綱下中有例子(Example),例子中放的是步驟定義程式碼中將讀取到的資料。
例子可以定義多個,比如下面這樣的。
4. 獨立
在編寫場景時,要注意場景必須是能獨立執行的,不依賴於其他場景。
比如賬戶資訊頁下的場景,與登入頁的場景要是無關的,不能說用上一個登入頁場景登入的結果來驗證賬戶資訊頁的功能。
否則在維護上會變得困難,比如登入頁場景的例子變了之後,還要修改賬戶資訊頁的場景。
還有就是在可讀性上變差,比如想理解賬戶資訊頁的場景,還要先理解登入頁的場景。
1.4.3 Cucumber 層次結構
具體例項能幫助團隊成員在系統構建前,把系統形象化,從而提升溝通的有效性。
團隊成員在閱讀這樣的測試時,能與自己的理解進行對比,而且這樣的測試能激發更進一步的思想,設想出更多的場景。
用這種風格編寫的驗收測試不僅是測試描述,而且還是可自動化執行的需求描述。
我們用 Cucumber 執行測試時,Cucumber 會從用普通語言編寫的特性檔案中,讀取需求說明,並解析需要測試的場景(Scenario),然後執行這些場景,以達到測試的目的。
每個場景都是由一系列的步驟(Step)組成的,Cucumber 會一步步執行這些步驟。
為了讓 Cucumber 理解特性檔案,特性檔案按必須遵循 Gherkin 語法規則。
除了特性檔案,我們還要給 Cucumber 提供一組步驟定義(Step Definition),步驟定義的程式碼將寫在 Android 測試類中,對應的是特性檔案中的每個步驟。
在一個成熟的測試集中,步驟定義本身可能只包含一兩行測試程式碼,具體的工作都交給了支援程式碼(Support Code)完成。
領域特定的支援程式碼庫知道如何執行軟體的常見任務,一般會用一個自動化庫(Automation Library),比如Espresso,用於與待測試系統進行互動。
Cucumber 支援四十多種語言,可以使用標籤(Tag)對場景歸類。
Cucumber 從特性到自動化庫的層次結構如下圖所示:
2. BDD 示例
下面我們通過一個簡單的示例,來看一個完整的 BDD 流程。
2.1 建立特性檔案
首先我們看下怎麼建立 Cucumber 特性檔案。
1. 新增 Gherkin 外掛
第一步是新增 Gherkin 外掛,這樣 AS 就能對使用 Gherkin 寫的特性檔案中的程式碼進行語法高亮。
2. 新增檔案模板
右鍵專案目錄中的任意一個檔案,選中 New 標籤,可以看到一個 Edit File Templates 的選項。
點選 Edit File Templates 後,點選加號,並把檔案模板的名稱改為 Cucumber feature file ,並把 Extension 字尾改為 feature 。
新增後模板後,可以看到這個檔案模板的圖示是一個綠色的,這就是 Gherkin 的 Logo。
點選 OK 儲存模板,再次點選 New 選項,可以看到現在多了一個 Cucumber feature file 模板。
3. 新增 Cucumber 依賴
4. 設定 Cucumber 選項
在 andoridTest 中建立一個 test 包,並在裡面建立一個 CucumberTestCase 類。
該類不需要有具體的實現,只需要新增 CucumberOptions 註解。Cucumber 測試執行器會根據該註解中填寫的選項進行初始化。
這個註解只需要在一個類中新增,當 Cucumber 找到第一個新增了該註解的類後,就會讀取該類中的資訊,其他新增了該註解的類不會再被讀取。
如果 Cucumber 發現沒有一個類新增了該註解,就會丟擲一個異常。
CucumberOptions 註解中有很多選項,最常用的是 features、glue 和 tags。
其中的 glue 選項中的值是 Cucumber 步驟定義的程式碼的目錄,tags 選項的值是執行的 Cucumber 測試的標籤。
如果標籤 tags 只填了 e2e ,也就是端對端測試,那 Cucumber 就只會執行新增了 @e2e 標籤的測試。
我們可以給不同的測試新增不同的標籤,後續也可以根據不同的標籤篩選出想執行的測試,文章最下方有一個連結是專門講 Cucumber 選項的。
5. 新增特性檔案
我們在 androidTest 目錄下新建一個 assets 目錄,並在裡面新建一個 features 目錄用於存放特性檔案,把前面提到的的登入功能的驗收測試放到 features 目錄下。
這個目錄不一定要叫 features ,如果你想使用其他名字的話,只要把 CucumberOptions 中的 features 選項的值改為對應的包名即可。
6. 自定義測試執行器
新建 cucumber.gradle ,放置與 Cucumber 相關的配置。
在 app 的 build.gradle 中新增 cucumber.gradle ,並把預設的 InstrumentationRunner 替換為動態獲取。
如果你打算 UI 測試中只放 Cucumber 測試的話,那就不需要動態獲取了,直接填 xxx.CucumberTestRunner 就可以了。
8. 新增步驟定義
這是一個簡單的登入頁。
首先我們定義一個 EspressoExt ,用於簡化步驟定義程式碼。
然後編寫新建一個 LoginSteps,編寫 login.feature 中各個步驟對應的步驟定義程式碼。
2.2 編寫生產程式碼
1. Koin 簡介
Koin 是一個用 Kotlin 實現的依賴注入框架,你可以把它看成是傻瓜版的 Kotlin 實現的 Dagger。
之所以說是傻瓜版,是因為相比之下 Dagger 實在是太麻煩了。
之所以要用依賴注入框架,是因為我們現在要驗證的是 App 裡的邏輯,而不是完整真實的登入功能,我們不需要管後臺返回什麼。
我們要脫離真實的介面去執行測試,也就是我們要弄一個假的資料層 Model 。
如果我們的測試跟真實的介面關聯在一起,而剛好測試伺服器在部署或資料重置了。
那測試就一定會失敗,這樣建立好的測試就沒意義了,經常要去改,非常浪費時間。
Koin 用起來很方便,而且也能達到解耦的目的,下面我們來看下 Koin 怎麼用。
2. 新增依賴
首先在需要 Koin 的模組的 build.gradle 中新增 Koin 依賴。
3. 初始化 Koin
然後在 Application 中呼叫 startKoin 方法,並把我們的登入模組放到 modules() 方法中。
下面的 get() 表示自動根據引數型別,獲取在 startKoin 中宣告好的對應的例項,在這裡也就是獲取 LoginModel 例項。
4. View
Activity 的實現很簡單,只是設定了登入按鈕的點選事件。
比較特別的是,presenter 是通過 by inject 宣告的,表明 presenter 例項是由 Koin 容器注入的。
5. Presenter
怎麼通過 TDD 實現 LoginPresenter 在 TDD 裡講過了,這裡就不多說了,直接看程式碼。
6. Model
這裡的請求是假的,也就是 Presenter 的請求邏輯是走不通的,只能通過替換依賴來模擬響應。
2.3 執行測試
1. 執行測試任務
點選 AS 右側的 Gradle 標籤檢視 Gradle 提供的任務,我們選擇執行 connectedCheck ,也就是執行 UI 測試。
2. 編輯執行引數
執行後,我們可以在 AS 的上方看到 connectedCheck 任務,點選任務後彈出一個下拉框,選擇 Edit Configurations 。
我們在 Arguments 中填入 cucumber 標誌和要執行的測試的標籤型別。
除了編輯執行任務的選項,我們也可以通過命令列來執行這個任務
如果在 build.gradle 中把 testInstrumentationRunner 直接設定為 CucumberTestRunner 的話,就不用這麼麻煩了,直接點選執行 connectedCheck 就可以了。
3. 檢視測試報告
執行任務後,執行結果是測試失敗,點選 AS 下方的 Run 標籤,可以看到有一個測試報告的連結。
點選該連結,右鍵 index.html 上方檔名標籤,選擇用瀏覽器開啟。
開啟後在瀏覽器中可以看到測試結果,這裡可以看到有 3 個測試,對應的就是我們前面定義好的兩個場景,第二個場景中的兩個例子通過了。
而第一個場景是沒有通過的,因為我們 Model 的登入請求根本就沒實現。
這裡報了一個 NoMatchingRootException 異常,也就是沒有匹配到"登入成功"的 Toast 提示。
2.4 模擬登入請求
1. 鉤子方法
前面我們在 LoginSteps 中新增了登入特性的步驟定義程式碼,現在我們在這個類中新增模擬請求程式碼。
前面的測試由於 Model 中的請求登入方法沒有實現所以失敗了,這時候我們要弄一個假的實現,Koin 就派上用場了。
載入模擬物件這個步驟與業務需求無關,不適合寫在特性檔案中,所以我們使用鉤子方法來載入模擬物件。
這裡我們通過 Cucumber 提供的 @Before 和 @After 註解,這兩個方法是 Cucumber 提供的鉤子方法。
通過這兩個方法,Cucumber 會執行我們想在測試前或測試後執行的操作。
在 setUp 中還可以接收到一個 Scenario 引數,我們可以用這個引數在不同的場景執行不同的初始化操作。
在這裡的初始化操作中,呼叫了 Koin 提供的 loadKoinModules() 方法。
並通過 module 方法重新宣告一個需要用於替換真實實現的 module ,這裡的 factory() 的呼叫和 Application 中的不一樣。
在這裡,factory 中多了一個 override = true 引數,表明這是要替換原有實現的。
2. 再次執行測試
前面用 MockK 提供的 every() 方法模擬了 LoginModel 的 requestLogin() 方法,製造了一個假的響應,這樣子 LoginPresenter 就能接收到回撥,並顯示對應的提示資訊。
2.5 Spek
Spek 是 JetBrains 團隊開發的一門單元測試框架,可用於編寫我們前面提到的低階可執行說明(Low-Level Executable Specification)。
普通 JUnit 單元測試存在一個問題,就是測試程式碼往往與生產程式碼的當前實現強耦合了。
也就是生產程式碼發生變化時,往往也需要修改測試程式碼,這樣測試程式碼的維護成本就變高了。
而我們可以用 Spek 解決這個問題,Spek 編寫的測試程式碼類似於 Gherkin 語法的形式,能更清晰的描述單元測試的意圖。
下面我們來看下怎麼使用 Spek。
1. 新增外掛
在設定的 Plugins 中搜尋 Spek 就可以看到 Spek Framework 外掛。
有了這個外掛,AS 才會識別用 Spek 寫的測試,這樣我們才可以像普通的 JUnit 測試一樣執行 Spek 測試。
2. 新增依賴
這裡看看就好了,具體的程式碼到文章底部的 GitHub 倉庫中複製。
在 app 模組的 build.gradle 中新增 apply from: '../spek.gradle' ,然後在專案根目錄的 build.gradle 中新增 JUnit 5 的外掛依賴。
3. JUnit 測試
如果我們現在需要測試一個用於驗證手機號合法性的方法 isPhoneValid() ,那麼用 JUnit 寫這個測試的話是這樣的。
4. Spek 測試
如果我們用 Spek 寫的話,這個測試是下面這樣的。
這樣寫雖然程式碼變多了,但是測試的可讀性也變強了。
結語
雖然看上去很美好,但實際上真的要去做這件事情,最大的問題就是怎麼說服其他團隊成員,如產品經理、測試人員和你一起編寫 Cucumber 驗收測試。
對於這個問題,剛開始的時候只能是由我們自己寫驗收測試,然後再把這些驗收測試給產品經理和測試人員看,讓他們進行補充。
等他們熟悉了這個驗收測試後,再讓他們也參與進來驗收測試的編寫。
資料
1. 參考資料
- 《The Cucumber for Java Book》
- 《BDD in Action》
- 《The RSpec Book》
- cucumber系列(一) 如何讓cucumber識別中文
- Android BDD with Cucumber and Espresso — the full guide
- CucumberOptions
- Kotlin Spek
- Set Android BDD Style with Kotlin Spek 2
- Unified Code Coverage for Android: Revisited
2. 其他資料
-
演示專案地址