iT邦幫忙

2023 iThome 鐵人賽

DAY 7
0
Mobile Development

30 天輕鬆學會 Flutter 測試系列 第 7

Day 7 程式開發不只有正常路徑

  • 分享至 

  • xImage
  •  

大多時候,我們在實現功能需求時,都是先完成正常流程,也就是順利執行原本預期要執行的操作的情境。在正常流程之外,還有一部份重要的任務需要關注,那就是錯誤處理。錯誤處理是什麼?通常我們在方法執行中拋出 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

測試 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 的物件。[範例連結]

Stubbing 一個 Exception

有了測試套件的輔助,我們除了可以驗證 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 啊?

Exception vs Error

其實 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。

小結

錯誤處理是一個比較少被討論的議題,如何處理測試錯誤狀況就又更少討論了。正常流程的是最長被使用的情境,我們得確實地用測試保護它,而出非正常流程雖然是比較次要的,但是我們也不能忽略它,因為他還是使用者會遇到的情境。想像一下,如果程式每次走到非正常流程就當機,使用者體驗肯定不好。


上一篇
Day 6 不改變狀態也不回傳,那我怎麼測試?
下一篇
Day 8 假的,都是假的,但不是業障重
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言