我們介紹了許多單元測試技巧,可以幫助我們處理難測試的類別、做假資料或隔離外部依賴,這些手段可以處理絕大多數的狀況。如果我們設計物件的時候,都依照 CQS 的概念來把命令與查詢方法分開,當我們針對命令方法寫測試時,會發現常常需要使用 Mock。當我們針對查詢方法寫測試,則比較常使用 Stub,但是今天既不是要講 Stub,也不是 Mock,而是另一個測試替身 Fake。
假設今天想在電商產品中新增一個我的最愛的功能,使用者可以把喜歡的商品加入我的最愛,然後可以在我的最愛的頁面,看到曾經加入的商品。根據需求,我們做了一個 MyFavorites 類別,身上有一個 add 方法,當使用者在 UI 介面上加入商品到我的最愛時,程式就會來呼叫 MyFavorites 的 add 方法,並把商品 id 放到 SharedPreference 中,以持久化 MyFavorites 的資料。
class MyFavorites {
final SharedPreferences _preferences;
MyFavorites(SharedPreferences preference) : _preferences = preference;
Future<void> add(Product product) async {
var favorites = getAll();
favorites.add(product);
await _preferences.setStringList("favorites",
favorites.map((product) => product.id.toString()).toList());
}
List<Product> getAll() {
return _preferences
.getStringList("favorites")
?.map((id) => Product(int.parse(id)))
.toList() ??
[];
}
}
當我們完成功能後,就可以應該順手加上一個測試,一方面驗證程式邏輯是否符合預期,一方面也避免後續重構壞掉,根據前面天介紹的技巧,由於這個方法沒有 return,我們使用 Mock 來測試它。
test("add favorite", () {
var mockSharedPreferences = MockSharedPreferences();
var myFavorites = MyFavorites(mockSharedPreferences);
myFavorites.add(const Product(1));
verify(mockSharedPreferences.setStringList("favorites", ["1"]));
});
在上面測試中,當我們呼叫了 add 方法之後,我們使用 Mock 來驗證 add 方法是否正確地與 MockSharedPreference 互動,也確認參數是否符合預期,綠燈通過,看起來也沒什麼問題,再來讓我們看看 getAll 的測試。
test("getAll", () {
var mockSharedPreferences = MockSharedPreferences();
when(mockSharedPreferences.getStringList("favorites")).thenReturn(["1"]);
var myFavorites = MyFavorites(mockSharedPreferences);
expect(myFavorites.getAll(), [const Product(1)]);
});
在 getAll 測試中,我們使用 Stub 來設定假資料 [”1”],並呼叫 getAll 取得回傳值確認結果,也是綠燈通過。[範例連結]
不知道觀眾朋友看完兩個測試之後,有沒有覺得不太對勁的地方?若我們以黑箱測試的角度來看,外面的人對這個物件預期是塞了一個 id = 1 的 Product 進去,要能從 getAll 取回包含 id = 1 的 Product 的 List,那我們是不是能用這種方法測試呢?
在前幾天的文章中,我們有提到相比於使用驗證互動,我們更傾向於驗證狀態,我們希望能夠呼叫 MyFavorites 的 add 方法後,再驗證 MyFavorites 的 getAll 方法回傳的 MyFavorites,就像下面例子那樣。
test() {
...
myFavorites.add(Product(1));
expect(myFavorites.getAll(), [Product(1)]);
}
但是在 MyFavorites 的例子中,由於 MyFavorites 實際上是交給外部依賴儲存,所以在 Mock 測試中,我們呼叫 add 方法,卻無法用 getAll 方法來取回結果。如果想完成這個驗證狀態的測試,我們就必須請出其他測試替身來幫忙了。
Fake 是一種假的實作,一種可以工作的簡易實作,用來替代真正的產品程式碼。
出處:http://xunitpatterns.com/Fake%20Object.html
同樣以 MyFavorites 例子來說,我們可以做一個 FakeSharePreference 來替代 MockSharePreference。
class FakeSharedPreferences 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);
}
我們在測試中建立一個 FakeSharedPreference 之後,修改一下測試,讓他使用這個假的 SharePreference,最後就能用 getAll 方法取回結果並驗證,綠燈通過。
test("add favorite", () {
var fakeSharedPreferences = FakeSharedPreferences();
var myFavorites = MyFavorites(fakeSharedPreferences);
myFavorites.add(const Product(1));
expect(myFavorites.getAll(), [const Product(1)]);
});
可以發現最後程式碼也變得更好懂一些,這個 FakeSharePreference 也能重複使用在其他與 SharePreference 相依的物件測試中。[範例連結]
與 Mock 相比,在使用 Fake 的測試中,我們能更完整測試一個類別的使用行為。在正式程式碼的行為中,我們如果用 MyFavorites 存了一筆資料,我們就理所當然的能從 MyFavorites 取回一筆資料,在 Fake 測試中也是如此。但是如果我們使用 Mock 來測試的話,在 MyFavorites 中寫了一筆資料,我們是無法從 MyFavorites 中讀回一筆資料的,因為 MockSharedPreference 的 setStringList 方法是假的。
使用 Fake 還有另外一個優點是,當我們調整 MyFavorites 的實作時,相關測試有機會不用跟著大調整。在 Mock 測試中,測試對於 MyFavorites 是如何實作有一定了解,因為測試知道 MyFavorites 怎麼使用 SharedPreference 的 setStringList。
讓我們修改一下程式碼,假設 Product 因需求調整多了 type 之後,需要在 SharedPreference 存 json 而非 id。
class MyFavorites {
final SharedPreferences _preferences;
MyFavorites(SharedPreferences preference) : _preferences = preference;
Future<void> add(Product product) async {
var favorites = getAll();
favorites.add(product);
await _preferences.setStringList("favorites",
favorites.map((product) => jsonEncode(product.toJson())).toList());
}
List<Product> getAll() {
return _preferences
.getStringList("favorites")
?.map((json) => Product.fromJson(jsonDecode(json)))
.toList() ??
[];
}
}
如果我們執行一下原本的 Mock 程式之後就會發現測試錯了,因為呼叫 SharedPreference 的 setStringList 時所傳入的參數格式完全不一樣了。
在 Mock 測試中,我們不只需要在 Product 物件上加上 type,還得修改 verify 的預期結果,遇上參數是 json 陣列又更難處理了。類似的事情也得在 getAll 測試上也要再做一次,必須要調整 MockSharedPreference 的 getStringList 方法的回傳值。
test("add favorite", () {
var mockSharedPreferences = MockSharedPreferences();
var myFavorites = MyFavorites(mockSharedPreferences);
myFavorites.add(const Product(1, "book"));
verify(mockSharedPreferences.setStringList("favorites", ['{"id":1,"type":"book"}']));
});
test("getAll", () {
var mockSharedPreferences = MockSharedPreferences();
when(mockSharedPreferences.getStringList("favorites")).thenReturn(['{"id":1,"type":"book"}']);
var myFavorites = MyFavorites(mockSharedPreferences);
expect(myFavorites.getAll(), [const Product(1, "book")]);
});
如果我們使用 Fake 來測試,當我們調整需求後,其實也就只是在 Product 上加入新的 type 參數而已,調整幅度縮小許多。
test("add favorite", () {
var fakeSharedPreferences = FakeSharedPreferences();
var myFavorites = MyFavorites(fakeSharedPreferences);
myFavorites.add(const Product(1, "book"));
expect(myFavorites.getAll(), [const Product(1, "book")]);
});
有興趣的觀眾朋友可以參考這邊 [Mock 測試修改後範例] [Fake 測試修改後範例]。
在實務上,我也們能將這個技巧用於資料庫上,比如我們能寫一個用 Memory 儲存資料的 FakeMemoryDB,在測試中讓 SUT 使用這個 FakeMemoryDB,像是真的資料庫一樣。我們先前提過,有些套件對測試有比較好的支援度,讓開發者可以省一些麻煩,例如:drift 有提供 Memory 版本的資料庫,需要在測試中 Fake 資料庫時就比較方便。
test("add favorite", () async {
MyDatabase database = MyDatabase(NativeDatabase.memory());
var myFavorites = MyFavorites(database);
await myFavorites.add(const Product(id: 1, type: "book"));
expect(await myFavorites.getAll(), [const Product(id: 1, type: "book")]);
await database.close();
});
在上面的測試中,我們用 Memory 來替代真的資料庫放在測試中使用,更詳細的例子可以看[這邊]。
相比於 Stub 與 Mock,Fake 是比較少聽過的測試替身,但是其實 Fake 相當的好用,尤其是當類別行為會跟於外部依賴的狀態有關時,使用 Fake 能更完整的測試整個類別的行為,而不用把一個完整行為拆分成很多個測試,讓我們能直接測試一個類別的完整行為,除了測試數量可能會少一點之外,測試也不容易因為程式碼一改就壞。