大多時候,我們在實現功能需求時,都是先完成正常流程,也就是順利執行原本預期要執行的操作的情境。在正常流程之外,還有一部份重要的任務需要關注,那就是錯誤處理。錯誤處理是什麼?通常我們在方法執行中拋出 Exception,用以表達**可預期的非正常流程,**今天就來聊聊這個錯誤處理的測試要怎麼做。
什麼是非正常流程呢?讓我們看看昨天的 PurchaseProductService 例子,在呼叫 ProductRepository.purchase 方法之前,程式會檢查使用者的錢包裡的餘額是否足夠,當餘額不足時,就拋出 MoneyNotEnoughException。
這個 Exception 會一直往外拋,直到有人使用 try / catch 將它攔截下來處理,一種處理方式就是在 UI 層攔截 Exception,顯示訊息讓使用者知道餘額不足,而這就是非正常流程的一個例子。
class PurchaseProductService {
final ProductRepository productRepository;
final WalletRepository walletRepository;
PurchaseProductService(this.productRepository, this.walletRepository);
Future<void> execute(Product product) async {
var wallet = await _getWallet();
if (product.price > wallet.money) {
throw MoneyNotEnoughException();
}
productRepository.purchase(product);
}
Future<Wallet> _getWallet() async {
return await walletRepository.get();
}
}
當程式執行到這邊發現錢不夠時,程式透過 Exception 中斷操作,並通知呼叫者,這是一個預期會發生的狀況,所以我們也就得想法辦測試它,測試當這個非正常狀況發生時,我們的程式是否會如預期的拋出這個錯誤。
測試 Exception 的方法也很簡單,我們可以簡單地在 Act 階段的邏輯上,包上一個 try / catch 攔截錯誤並檢查錯誤是不是 MoneyNotEnoughException,還得記得再 execute 後面多補上一句 assert,確保測試走到不預期的路徑上時,會發生錯誤。
@GenerateNiceMocks([MockSpec<ProductRepository>(), MockSpec<WalletRepository>()])
main() {
test("should throw money not enough exception", () async {
var mockProductRepository = MockProductRepository();
var mockWalletRepository = MockWalletRepository();
when(mockWalletRepository.get()).thenAnswer((_) async => Wallet(50));
var purchaseProductService = PurchaseProductService(mockProductRepository, mockWalletRepository);
try {
await purchaseProductService.execute(const Product(100));
assert(false);
} catch (e) {
expect(e, isA<MoneyNotEnoughException>());
}
});
}
上面示範的是測試方式是比較手工的方式,大多時候,我們會使用測試框架提供的更方便的方法,讓我們可以簡單的驗證 Exception,而不用寫太多程式碼,當測試發生錯誤時,訊息也會比較清楚一點。
@GenerateNiceMocks([MockSpec<ProductRepository>(), MockSpec<WalletRepository>()])
main() {
test("should throw money not enough exception", () {
var mockProductRepository = MockProductRepository();
var mockWalletRepository = MockWalletRepository();
when(mockWalletRepository.get()).thenAnswer((_) async => Wallet(50));
var purchaseProductService = PurchaseProductService(mockProductRepository, mockWalletRepository);
expect(() => purchaseProductService.execute(const Product(100)), throwsA(isA<MoneyNotEnoughException>()));
});
}
與一般驗證相同,我們都是使用 expect 來檢查結果,但在驗證 Exception 的情境中,我們第一個參數會傳入不是一個值,而是一個lambda 方法,在 expect 的部分,我們可以使用 throwsA(isA()) 來檢查,從方法名稱上我們就可以很直觀地看出,throwsA 表示預期拋出一個物件,那這個物件是什麼呢?就必須用 isA 來指明物件的型別為 MoneyNotEnoughException。整段合起來看就是,這個 lambda 會拋出一個型別為 MoneyNotEnoughException 的物件。[範例連結]
有了測試套件的輔助,我們除了可以驗證 SUT 的非正常流程之外,我們也能設定測試替身丟出 Exception。在 PurchaseProductServcie 的例子中,當程式嘗試透過 WalletRepository 取得錢包時,也有可能會發生錯誤,假設我們修改需求,讓程式取 Wallet 失敗時 Retry 一次。( 只是舉例請勿模仿,這是 Bad Practice XD )
class PurchaseProductService {
...
Future<Wallet> _getWallet() async {
try {
return await walletRepository.get();
} catch (e) {
return await walletRepository.get();
}
}
}
讓我們測試當呼叫 WalletRepository.get() 發生錯誤,要再重打一次,總共打兩次的情境吧。
test("retry when get wallet fail", () {
var mockProductRepository = MockProductRepository();
var mockWalletRepository = MockWalletRepository();
when(mockWalletRepository.get()).thenThrow(Exception());
var purchaseProductService = PurchaseProductService(mockProductRepository, mockWalletRepository);
expect(() => purchaseProductService.execute(const Product(100)), throwsA(isA<Exception>()));
verify(mockWalletRepository.get()).called(2);
});
when 除了可以用在 stubbing 假的回傳值之外,我們可以 when 來設定假物件拋出 Exception,藉此來模擬錯誤狀況。在上面的測試中,我們就設定了 mockWalletRepository.get() 會拋出一個 Exception,當程式收到 Exception 時,就會再打一次 mockWalletRepository.get(),包含第一次呼叫,所以驗證呼叫次數總共兩次。[範例連結]
如果我們在程式中,主動在非正常流程中拋出 Exception,我們會直覺的為這個情境寫測試,那其他的情況呢?當我們使用 List.first 時,如果 List 中沒有任何元素,呼叫 first 就會丟出 No Element 的錯誤。
E get first {
Iterator<E> it = iterator;
if (!it.moveNext()) {
throw IterableElementError.noElement();
}
return it.current;
}
那當我們使用 List.first 時,我們要不要處理這個錯誤情況呢?其實答案不一定,端看我們的需求而定,假設在我們的程式中,在不考慮有 Bug 情況下,如果 List 不會出現空的情況,那其實我們不需要測試這個錯誤,因為他不是我們預期的情境。
但是當 List 真的有可能是空的時候呢?那其實我們應該使用 List.first 前檢查是否為空,分別為這兩種狀況做不同處理。
var products = [];
if (products.isNotEmpty) {
var firstProduct = products.first;
// ...
} else {
// ...
}
甚至也可以在判斷為空的時候,主動拋出 Exception 通知呼叫者。
var products = [];
if (products.isEmpty) {
throw ProductsEmptyException();
}
這兩種處理方式都有各自適合的情境,依照需求選擇即可。有好奇的觀眾朋友可能會問,既然我都要丟 Exception,那我是不是也不用檢查了,反正 List.first 也會拋 Exception 啊?
其實 List.first 拋得並不是 Exception,而是 Error,在 Flutter 中 Exception 說明中表示,Exception 可以被程式處理的錯誤,也就是可預期的非正常流程。
An Exception is intended to convey information to the user about a failure, so that the error can be addressed programmatically.
出處:https://api.flutter.dev/flutter/dart-core/Exception-class.html
但是 Error 則不同,Error 的說明表示,Error 是開發人員需要避免的錯誤,簡單來說就是 Bug。
An Error object represents a program failure that the programmer should have avoided.
出處:https://api.flutter.dev/flutter/dart-core/Error-class.html
當我們的程式收到 Error 時,表示程式中出現了 Bug,我們要做的事情就是修好它,而不會利用 try / catch 攔截處理它,因為我們很難正確的處理我們不預期的狀況,我們最多只能讓他不要出錯,而這是另一個更進階的議題:容錯處理,這邊我們就暫時不討論。
回到最初的問題,那我們要處理 List.first 可能會丟出的 Error 嗎?這時我們答案肯定就是否定的,因為 first 他丟出的 Error,我們如果不預期 List 是空的,那我們就該提前處理,而不是讓 Error 噴出,造成開發人員的誤以為有 Bug。
錯誤處理是一個比較少被討論的議題,如何處理測試錯誤狀況就又更少討論了。正常流程的是最長被使用的情境,我們得確實地用測試保護它,而出非正常流程雖然是比較次要的,但是我們也不能忽略它,因為他還是使用者會遇到的情境。想像一下,如果程式每次走到非正常流程就當機,使用者體驗肯定不好。