我們講了這麼多天的單元測試,今天就來小結一下,除了回顧之前提到的一些原則之外,也順便簡單介紹一下一些漏網之魚,讓觀眾朋友們對單元測試原則的有基本了解。
在寫測試的第一天中,我們介紹由 Bill Wake 提出的 3A 原則,在測試中運用 3A 原則將測試分成三個部分 Arrange、Act、Assert,與 Gerard Meszaros 提出的四階段測試的原理 (Setup、Exercise、Verify、Teardown) 是共通的。
出處:http://xunitpatterns.com/FourPhase Test.html
Arrange / Setup 階段
在第一個階段中,我們會準備 SUT 與各式各樣的測試替身,並且設定測試替身的假回傳。
Act / Exercise 階段
在第二個階段中,測試會執行 SUT 的方法,並傳入相關參數
Assert / Verify 階段
在驗證階段中,我們會驗證結果,有時候使用 SUT 身上的狀態或回傳值來做狀態驗證,有時候則是用 Mock 物件來做行為驗證。
Teardown 階段
當測試結束後,我們會清除測試過程中產生的資料,避免這些資料干擾其他測試的結果,確保測試之間的獨立性。
在重構測試的文章中,我們也提到可以使用 Given-When-Then 風格來為每一階段中的方法命名,提升測試的可讀性。
在無瑕的程式碼中,Uncle Bob 有提到單元測試的 FIRST 原則,這個原則是有五個單字的字首組合而成:Fast、Independent、Repeatable、Self-Validating、Timely,我們在過去某幾天的文章中有提到其中的幾個概念。
Fast 如同字面上的意思一樣,我們也在文章中談到,單元測試必須執行快速,讓開發人員可以快速檢查程式是不是有問題。當我們修改一段程式碼,馬上執行一下相關的單元測試,如果測試每次都要等個十幾分鐘,就會降低開發人員的執行意願。
單元測試之間必須有獨立性,也就是 A 測試與 B 測試之間不能互相影響,也不能有順序性,必須保證 A 測試先執行或 B 測試先執行都沒有差別。那為什麼我們需要有這條呢?原因其實也很簡單,如果 A 與 B 測試之間有相依關係,那 A 測試錯了 B 測試很可能也跟著出錯,導致兩個測試都錯了,導致開發人員誤判兩個地方都有問題。
main() {
Calculator calculator = Calculator();
test("0 + 2 = 2", () {
calculator.add(2);
expect(calculator.result, 2);
});
test("2 - 2 = 0", () {
calculator.add(-2);
expect(calculator.result, 0);
});
}
在上面這個測試中,第一個測試 0 + 2 執行完後,又使用同一個物件繼續執行 2 - 2,雖然兩個測試都能正常通過,但是測試之間有相依性,必須先執行 0 + 2 才能執行 2 - 2 才會通過,反之則會發生錯誤。在 Dart 測試中,如果我們沒有特別設定參數,測試是會依照我們定義的順序執行的,即使如此,我們還是要避免貪圖方便而讓測試之間有關聯,畢竟將來轉換到其他語言或框架時,可能就沒有保證這個順序了。
Repeatable 也就是可重複的,我們在前面的文章有介紹到可重複的重要性,在程式碼沒有錯誤的情況下,測試無論執行幾次,測試都正確通過,開發人員對於測試才會有足夠的信心,
在這個原則中,我們希望單元測試可以自我驗證,成功或失敗,都必須要出明確的訊息,不需要開發人員手動的去檢查失敗原因。
現在 IDE 很多都有整合測試 GUI,當測試成功時,我們可以在畫面上看到綠燈,相反的,測試失敗時,畫面上要出現紅燈,開發人員可以快速確認測試是成功或失敗。
有時候我們會不自覺在測試中驗證了很多場景,比如說:在測試中同時測試儲值與扣款的行為,當測試失敗時,很難直接看出到底是儲值失敗,還是扣款失敗,這時開發人員就要手動介入檢查。
當測試失敗時,畫面上除了顯示紅燈之外,還會有錯誤訊息也要明確,明確指出是哪個值不符合預期,在訊息中顯示實際值與預期值的差別。不同的測試套件,針對錯誤訊息的顯示方式也可能會不相同,觀眾朋友可以多多比較。
在這個原則中,寫正式程式碼之前,我們就應該寫好我們的測試。那為什麼我們要先寫測試呢?
有時候,我們如果先寫完正式程式碼再來測試,有時候會發現程式碼很難測試,此時我們就得回頭改程式碼,形成浪費。不如一開始就寫設計,從使用端來思考物件的介面該長什麼樣子,也讓物件在設計之初就具備可測試性。
那好奇的觀眾朋友可能會問,正式程式碼都還沒有,那我要測試什麼呢?其實會有這個疑問是因為我們習慣先寫程式,然後依照寫好的程式來設計測試。當我們漏做了某個需求時,我們也跟著忘了測試。但是我們應該反過來,先思考測試,想清楚我們馬上要完成的功能具備什麼行為,把行為寫成測試,然後才寫程式碼來通過測試。當我們熟悉這套流程之後,最後我們也開始往 TDD 的開發方式前進了。
在本系列文章中,由於希望是偏新手向的文章,所以還是先寫程式再來寫測試,就像攀岩的三點不動一點動口訣一樣,如果學習的過程新的知識點太多,對於學習的幫助可能有反效果,所以我們在文章中還是維持大多數開發者的習慣,先寫完程式再來測試。
測試替身這個詞是由 Gerard Meszaros **提出,從電影替身這個詞衍生而來。在單元測試中,我們會用各種不同的測試替身取代真實物件,測試替身根據測試情境需要,提供不同資料給 SUT,我們就能驗證 SUT 走到各種不同的情境後的結果。在前面的文章中我們介紹過,Stub、Mock 與 Fake,就不在講述一次,這邊就來簡單補充一下 Spy 與 Dummy 吧。
有些時候,我們的呼叫 SUT 身上的方法後,SUT 做完事情沒有回傳值,也不改變自己身上的狀態,而是去改變依賴物件身上的狀態,在這種情況下,我們或許就可以使用 Spy 來測試。假設
出處:http://xunitpatterns.com/Test%20Spy.html
若我們修改一下 MyFavorites 的例子,改成使用 Spy 來測試,用起來其實與 Mock 有一點相似,但不同的是,Mock 在驗證方法是否被呼叫,怎麼被呼叫,而 Spy 則是提供身上的狀態給測試驗證。
main() {
test("add favorite", () {
var spySharedPreferences = SpySharedPreferences();
var myFavorites = MyFavorites(spySharedPreferences);
myFavorites.add(const Product(1));
expect(spySharedPreferences.fake, ['1']);
});
}
class SpySharedPreferences implements SharedPreferences {
List<String> fake = [];
@override
Future<bool> setStringList(String key, List<String> value) async {
fake = value;
return true;
}
@override
List<String>? getStringList(String key) {
return fake;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
在製作 SUT 的過程中,我們會建立許多測試替身來幫忙測試,無論給假資料或者驗證結果。但有時候是 SUT 的建構子或方法的參數需要這個依賴,實際上這個有給或沒給都不影響測試行為時,我們就可以建立 Dummy 給 SUT,讓程式編譯通過即可,最簡單的方式可能就是給一個 null 或一個空物件。
main() {
test("test something", (){
...
var purchaseProductService = PurchaseProductService(mockProductRepository, null);
...
});
}
有興趣的朋友可以參考,http://xunitpatterns.com/DummyObject.html
單元測試我們差不多就介紹到這邊,介紹的議題不多,但是其實單元測試還是有許多有趣的議題,有些應該也會在未來文章討論到。接下來我們準備進入 Widget Test 的部分了,歡迎有興趣的朋友繼續追蹤收看。