教你為 Riverpod 2.0 撰寫 Flutter 測試,使用 Mocktail 讓你輕鬆不畏懼! part.2
測試幾個開發常遇到的 Riverpod 使用情境
本文適合對 Riverpod 2.0 有實際玩過且熟悉的朋友們,不會特別講解相關開發技巧,我們著重在如何寫好一個基本的測試,穩固專案的品質。
對 Riverpod 有興趣但不熟悉的朋友可以查看之前我發過的文章,包含基礎知識以及實作
上一篇
Riverpod
ProviderSubscription
使用 ProviderSubscription 的 listen()
函式,監聽 Provider 的狀態變化,利於在測試裡檢查狀態的更新,是否跟我們邏輯流程裡期望的結果一樣
// Mock listener
class ProviderListener<T> extends Mock {
void call(T? previous, T? next);
}
// Use Container to listen specific Provider status
providerContainer.listen(
testAppThemeModeProvider,
listener,
fireImmediately: true,
);
FutureProvider
- 回傳 AsyncValue,包含 AsyncLoading、AsyncData、AsyncError
AsyncNotifierProvider
- 回傳 AsyncValue,包含 AsyncLoading、AsyncData、AsyncError
build()
負責初始化,可進行非同步操作
Testing Example (FutureProvider)
情境
從本地取得 APP 保存的 ThemeMode
實作
首先在每個測試執行前初始化一些物件,進行前置作業。需要 ProviderContainer 存取每個 Provider、每個狀態,接著因為此測試要取得本地儲存的資料,需要偽造使用到的 LocalStorage,透過 overrides 覆蓋為 MockLocalStorage,準備測試使用
makeProviderContainer()
→ 方便在初始化時使用,只需給予要偽造的 Provider,以及在測試結束後釋放資源
當使用 storage.get()
的時候我想要它返回指定資料,這邊設置為 true
創建一個 Listener,資料類型為 Provider 提供的資料,透過 container 監聽此狀態,利於我們檢查狀態的更新
此測試的主角 appThemeModeProvider 本身是 FutureProvider,在還沒完成之前的狀態都是 null 到 Loading,透過 verify()
和 expect()
進行初步確認
verify()
用來驗證狀態的更新expect()
用來檢查目前的 Provider 狀態,跟我們期望是否相同
- 完成非同步操作,從 MockLocalStorage 取得資料並返回 ThemeMode
- 驗證 Provider 狀態,從 AsyncLoading 到 AsyncData,並取得
ThemeMode.light
,因為前面 Mock 的時候我們希望能拿到 true - 期望 Provider 狀態,目前的狀態是 AsyncData,內容為
ThemeMode.light
最後驗證 Listener 是不是沒有狀態的更新了,而且存取 LocalStorage 的操作只有一次
測試運行成功
Source code
test(
'Get ThemeMode(light) of APP',
() async {
/// arrange
when(() => storage.get(LocalStorageKeys.isLightTheme)).thenAnswer((_) => Future.value('true'));
/// run
final listener = ProviderListener<AsyncValue<ThemeMode>>();
providerContainer.listen(
appThemeModeProvider,
listener,
fireImmediately: true,
); // Check state before completing future
// 1. by verify
verify(() => listener(null, const AsyncLoading<ThemeMode>()));
// 2. by expect
expect(providerContainer.read(appThemeModeProvider), const AsyncLoading<ThemeMode>()); // Finish the future operation
await providerContainer.read(appThemeModeProvider.future); // Check state when future completed.
// 1.
verify(() => listener(const AsyncLoading<ThemeMode>(), const AsyncData<ThemeMode>(ThemeMode.light)));
// 2.
expect(providerContainer.read(appThemeModeProvider), const AsyncData<ThemeMode>(ThemeMode.light)); // No new status
verifyNoMoreInteractions(listener); // Only be called one time
verify(() => storage.get(any())).called(1);
},
);
Example (AsyncNotifierProvider)
情境
本範例一樣是從本地取得 APP 保存的 ThemeMode,使用 AsyncNotifier 實作
在 build()
初始化時從本地取得 ThemeMode,並設置初始狀態
實作
通常一開始都是先使用 when()
和 then()
相關函式,進行操作的資料偽照。接著透過 listen()
進行 Provider 狀態的監聽
- 首先驗證初始狀態,一樣是 null 到 AsyncLoading,並確認之後沒有新的狀態更新了
- 因為 Provider 類型是 FutureProvider 這邊使用 await 等待完成,再進行結果檢查,預期拿到的數值是 ThemeMode.light
- 最後確認資料只有被存取過一次
測試運行成功
Source code
test(
'initialize in build() and get ThemeMode.light',
() async {
/// arrange
when(() => storage.get(LocalStorageKeys.isLightTheme)).thenAnswer((_) async => 'true'); /// run final listener = ProviderListener<AsyncValue<ThemeMode>>();
// Listen testAppThemeModeProvider to check status later.
providerContainer.listen(
appThemeModeNotifierProvider,
listener,
fireImmediately: true,
); // In the beginning, always from null data to Loading state.
verify(() => listener(null, const AsyncLoading()));
verifyNoMoreInteractions(listener); // Complete build() of AsyncNotifier.
// Need to use expectLater() to check current state.
await expectLater(await providerContainer.read(appThemeModeNotifierProvider.notifier).future, ThemeMode.light); // Try to get local data from local storage in build().
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(1);
},
);
Example (AsyncNotifierProvider and Exception)
情境
toggleMode()
目的為切換 App 的 ThemeMode,測試過程中拋出例外後的狀態更新與流程是否正常
實作
在測試一開始先安排資料預期的輸出,在存取本地資料的時候希望拋出例外
當 listen()
指定 Provider 狀態的時候,就開始進行 build()
的初始化,這時候會去存取 LocalStorage,所以先檢查是否已經呼叫一次
當存取 LocalStorage 的時候,期望獲得一個例外,可以使用 throwA(isA<Exception>())
檢查兩個狀態更新
- 第一個情境,在取得目前的 ThemeMode 之前會先更新為 AsyncLoading 狀態
- 第二個情境,從 AsyncLoading 準備取得資料,這時候存取資料會拋出例外,是我們安排的情況,Provider 狀態會更新成有錯誤。錯誤的檢查方式使用
predicate()
,確認其中 AsyncValue 是否有錯誤,有的話才符合我們要的流程 - 最後確認資料只有被存取過一次
也可以用另外一種方式,檢查狀態型別是否正確
() => listener(
const AsyncLoading(),
any(
that: isA<AsyncError<ThemeMode>>(),
),
),
測試運行成功
test(
'call toggleMode() but throw exception.',
() async {
/// arrange
when(() => storage.get(LocalStorageKeys.isLightTheme)).thenThrow(Exception('Can not get theme!'));
when(() => storage.set(LocalStorageKeys.isLightTheme, any<bool>())).thenAnswer((invocation) => Future.value()); /// run final listener = ProviderListener<AsyncValue<ThemeMode>>();
providerContainer.listen(
appThemeModeNotifierProvider,
listener,
fireImmediately: true,
); // When listen the provider, it will initialize and run build()
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(1); await expectLater(() async => providerContainer.read(appThemeModeNotifierProvider.notifier).toggleMode(), throwsA(isA<Exception>())); verifyInOrder([
// Beginning set the loading state
() => listener(null, const AsyncLoading()),
// Error will appear when complete
() => listener(
const AsyncLoading(),
any(
that: predicate<AsyncValue<void>>(
(value) {
expect(value.hasError, true); return true;
},
),
),
),
]); // Call storage.get() again
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(1);
},
);
Example (AsyncNotifierProvider and Stream)
情境
在初始 build()
和 toggleMode()
進行 Stream 更新,取得目前的 ThemeMode,檢查狀態是否有按照期望的流程更新
實作
依正常流程來說此範例會存取本地資料 3 次,進行呼叫的次數驗證
- 因為 Notifier 初始化的關係,一開始在
build()
裡存取資料,取得目前設定的 ThemeMode - 第二次是在呼叫
toggleMode()
時,一開始也會取得資料 - 第三次則是再更新 ThemeMode 後,刷新
appThemeModeProvider
,一樣需要存取本地資料。實際上是否要有這個 Provider 狀態還是根據實際開發需求,這裡只是做個範例展示
- 檢查 Stream 資料流,我們期望它能照流程給予狀態。先是原本的 ThemeMode.light,再來點擊切換樣式後,更新成 ThemeMode.dark
- 最後再次驗證 storage 被存取的次數,在這邊為一次
測試運行成功
test(
'call toggleMode() and check stream data is correct',
() async {
/// arrange
when(() => storage.get(LocalStorageKeys.isLightTheme)).thenAnswer((_) async => Future.value('true'));
when(() => storage.set(LocalStorageKeys.isLightTheme, any<bool>())).thenAnswer((invocation) => Future.value()); /// run await providerContainer.read(appThemeModeNotifierProvider.notifier).toggleMode(); // called once in build()
// called once in toggleMode()
// called once in appThemeModeProvider
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(3); expect(
providerContainer.read(appThemeModeNotifierProvider.notifier).currentModeStream,
emitsInOrder(
const [
ThemeMode.light,
ThemeMode.dark,
],
),
); // called once in build() because read(appThemeModeNotifierProvider.notifier)
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(1);
},
);
注意:如果是使用 expectLater()
來檢查結果的話需要先在操作前定義好,等待操作後的結果,這樣寫測試比較不自然。建議用 expect()
,可以在操作後進行檢查
Tips
- 盡可能的給予泛型,將型別描述出來,方便閱讀以及查找問題
- 無法確保實際值的時候使用
any()
幫助檢查 - 使用 ProviderSubscription 監聽 Provider 狀態變化,方便檢查
- 每個測試可以新增自定義的
timeout
參數,確保我們的測試在需要時快速失敗,不會卡住流程
Problem
在使用 any()
或 captureAny()
時可能會出現的錯誤
Bad state: A test tried to use `any` or `captureAny` on a parameter of type `AsyncValue<void>`, but
registerFallbackValue was not previously called to register a fallback value for `AsyncValue<void>`.
需要 registerFallbackValue()
,否則無法作為值分配給不可為 null 的參數。如果此型別在很多測試裡都會使用到,可以在所有測試執行前進行設置
setUpAll(() {
registerFallbackValue(const AsyncLoading<void>());
});
Source Code
其他文章
- Flutter 輕鬆實作 i18n,使用 easy_localization_generator 就對了
- Flutter CICD 使用 Gitlab Runner 和 App Center 實作 part.1
- 使用 CodeMagic 和 Firebase 實現 Flutter CICD
- 輕鬆完成Flutter開發環境,最新版!
- 實作Flutter多變有趣的滾動效果CustomScrollView!
- 如何在Flutter使用 Makefile 節省你的時間?
- Easily understand StatefulWidget LifeCycle of Flutter
- “freezed” makes model class strong and easily
- 提高Flutter性能的小技巧!(一)
- 提高Flutter性能的小技巧!(二)
- 提高Flutter性能的小技巧!(三)
- What are Async and Isolates in Flutter?
- LoadBalancer is optimization for Isolates in Flutter
- Riverpod 輕鬆學,原來這麼好用!
- Riverpod 輕鬆學(二),一些進階用法!
關於我
- GitHub: chyiiiiiiiiiiii
- Instagram: flutterluvr.yii
- Linkedin: yiichenhi
- Youtube: Yii
- Youtube: 一起饅頭(美食頻道)
- Email: ab20803@gmail.com
贊助
謝謝你花費時間看完,非常感謝!
如果覺得文章不錯的話可以贊助,讓我有更多動力和熱情分享學習紀錄和生活!請我喝一杯咖啡吧~
最後
希望有幫助到你/妳,歡迎追蹤我,方便瀏覽最新的文章~