這幾天的文章中,我們談論如何處理那些頑劣的依賴,透過 Stub 的方式,注入我們設計過的資料到測試之中,最後驗證回傳值或者物件狀態來決定測試成功與失敗。再討論 Stub 的時候,我們介紹了測試替身,也講到測試替身有許多種,今天就介紹另一種測試替身 Mock 與它的使用場景吧。
那 Mock 是什麼呢?在前幾天我們有稍微介紹 Mock:Mock 是一個物件,用來驗證 SUT 是不是有正確跟這個 Mock 物件正確的互動,如果有正確的呼叫 Mock 物件身上的方法,那測試就會綠燈,反之則會紅燈。那為什麼我們會需要 Mock,想像一下,假設我們想測試的方法沒有回傳值,也不會改變自身的狀態時,我們就無法透過驗證狀態或回傳值等方式來測試,所以我們會需要 Mock。
出處:http://xunitpatterns.com/Mock%20Object.html
在上圖中,跟 Stub 一樣,我們會在 Arrange 階段建立 SUT 與注入 Mock 物件,也會在 Act 階段呼叫 SUT 身上的方法,但是最後是驗證 Mock 物件,而不是驗證 SUT,讓我們透過實際例子來感受一下 Mock 吧。
假設我們有一個 PurchaseProductService 的類別,當使用者購買商品時,程式會呼叫 PurchaseProductService,檢查錢包是否有足夠錢,然後透過 ProductRepository 呼叫後端 API 購買。往下看之前,有興趣的觀眾朋友可能可以想想看,我們要怎麼測試這個類別。
class PurchaseProductService {
final ProductRepository productRepository;
PurchaseProductService(this.productRepository);
void execute(Product product, Wallet wallet) {
if (product.price > wallet.money) {
throw MoneyNotEnoughException();
}
productRepository.purchase(product);
}
}
這麼方法沒有回傳值,類別本身也沒有狀態可以拿來驗證,我們就沒辦法透過狀態驗證來決定是否成功,那我們要如何解決呢?讓我們手寫一個 Mock 物件來測試這個類別吧,新增一個假的 MockProductRepository 並設定預期的結果給它,然後這個 MockProductRepository 傳入 PurchaseProductService 之中呼叫完 execute 後,呼叫 MockProductRepository.verify 來確認結果是否符合預期,也就是 callCount 要等於 1 且 product 要是 Product(100)。
main() {
test("purchase product success", () {
var mockProductRepository = MockProductRepository();
mockProductRepository.setExpectedCallCount(1);
mockProductRepository.setExpectedProduct(const Product(100));
var purchaseProductService = PurchaseProductService(mockProductRepository);
purchaseProductService.execute(const Product(100), Wallet(200));
mockProductRepository.verify();
});
}
class MockProductRepository implements ProductRepository {
int expectedCallCount = 0;
int actualCallCount = 0;
Product? expectedProduct;
Product? actualProduct;
void setExpectedProduct(Product product) {
expectedProduct = product;
}
void setExpectedCallCount(int count) {
expectedCallCount = count;
}
@override
Future<void> purchase(Product product) async {
actualProduct = product;
actualCallCount ++;
}
void verify() {
expect(actualProduct, expectedProduct);
expect(actualCallCount, expectedCallCount);
}
}
[範例連結]
是不是覺得寫 Mock 物件很累,其實如果真的要使用 Mock,我們有更輕鬆簡單的方式。包含前幾天介紹的 Stub 加上今天介紹的 Mock,當我們需要時,如果都得要花時間自己手刻,未免有點浪費時間,借助測試套件的幫助,讓我們能更快速的產生這些測試替身。
Flutter 測試相關的套件有許多,而其中常使用的肯定是 mockito 了,與 Java 著名的 Mockito 套件一樣,可以協助我們在測試中製作各式各樣的測試替身。以今天的例子來說,我們可以用 mockito 改寫一下。
@GenerateNiceMocks([MockSpec<ProductRepository>()])
main() {
test("purchase product success", () {
var mockProductRepository = MockProductRepository();
var purchaseProductService = PurchaseProductService(mockProductRepository);
purchaseProductService.execute(const Product(100), Wallet(200));
verify(mockProductRepository.purchase(const Product(100))).called(1);
});
}
首先我們得先在 main 上面加上 @GenerateNiceMocks 的 annotation,主要是讓 build_runner 可以自動幫我們產生 Mock 物件,接著我們就能直接使用 MockProductRepository 了,是不是很神奇。如果細心的觀眾朋友可能會注意到,當我們執行完 build_runner,在測試檔案旁邊會多一個 mock 檔案,這裡頭其實就是 mockito 幫我們產生好的 MockProductRepository。
class MockProductRepository extends _i1.Mock implements _i2.ProductRepository {
@override
_i3.Future<void> purchase(_i2.Product? product) => (super.noSuchMethod(
Invocation.method(
#purchase,
[product],
),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
) as _i3.Future<void>);
}
使用 mockito 來輔助製作 Mock 物件,能省去寫測試替身的時間,讓時間花在更有價值的事情上。雖然我們的例子中都用手寫測試替身來測試,但這只是希望方便大家了解測試替身的內涵,但實務中是不太會這樣做的,大多數語言都有方便製作測試替身的套件,使用這些套件節省時間,讓我們花更多時間專注實作與設計有效的測試案例是比較有價值的。
有興趣的朋友可以複製例子上的程式碼,然後使用 build_runner 產生 Mock 檔案後在執行測試。[範例連結]
mockito 除了可以用來產生 Mock 之外,還能於 Stub 假資料,讓我們改寫一下前幾天的 UserRepository 測試。
@GenerateNiceMocks([MockSpec<Client>()])
main() {
test("get user ok from api", () async {
var mockClient = MockClient();
when(mockClient.get(
Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
(_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
);
var userRepository = UserRepository(mockClient);
var user = await userRepository.get(1);
expect(user, User(id: 1, name: "Tom"));
});
}
還記得前幾天我們自己實作的 StubClient,我們修改一下測試,改用 mockito 產生一個 MockClient,接著我們能用 mockito 中的 when 方法來作假 MockClient 中的 API 回傳值。以上面的例子來說,我們就指定了 mockClient.get() 在測試中會回傳指定 Response 物件。
至此我們已經認識了兩個最常用的測試替身 Stub 與 Mock 之外,而這兩個測試替身其實也分屬於兩種不同的驗證方式:狀態驗證與行為驗證。顧名思義,狀態驗證的測試都是驗證物件身上的狀態或回傳值,來確認結果是否符合預期,而行為驗證則是確認 SUT 是否有呼叫依賴身上的方法,來決定結果是否符合預期。
這兩種測試方法倒也沒有誰好誰壞,不同的開發方式,也各自傾向的測試方式,有時候我們會只能驗證狀態,有時候我們只能驗證行為。但是當兩個方法都容易使用的時候,通常會更傾向於使用狀態驗證的方式,使用狀態驗證的測試,比較不容易因為架構調整而需要修改,驗證行為會造成測試認識物件的實作,當實作方式改變時,造成測試脆弱的問題。
測試套件除了 mockito 之外,由 Felix Angelov 製作的 mocktail 也是不錯的選擇。與 mockito 用法十分類似,一樣是使用 when 來設定假資料,一樣可以用 verify 來驗證互動,只是寫法上稍微有些差別。比較大的不同是,mockito 是使用 @GenerateMocks 或者 @GenerateNiceMocks 加上 build_runner 來產生測試替身,而 mocktail 則是需要自己寫一個 Mock 類別。
class MockClient extends Mock implements Client {}
雖然看似 mocktail 要自己寫比較麻煩,但實際上並不太花時間,有時候反而是反覆執行 build_runner 要更稍微花一點時間。
main() {
test("get user ok from api", () async {
var mockClient = MockClient();
when(() => mockClient.get(Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
(_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
);
var userRepository = UserRepository(mockClient);
var user = await userRepository.get(1);
expect(user, User(id: 1, name: "Tom"));
});
}
使用哪一個套件,還是可以根據觀眾自己的需求決定即可。
Mock 主要用於驗證 SUT 與依賴的互動狀況,當測試無法透過狀態驗證來檢查結果時,我們就會建立 Mock 物件來協助檢查互動結果。在實務中,我們常常會使用測試套件來減輕寫測試的負擔,使用測試套件快速建立測試替身。在 Flutter 中,我們可以選擇 mockito 或 mocktail 來 Stub 或 Mock,使用測試套件不但可以減少寫的時候的負擔,也可以減少我們維護的的成本。