教你為 Riverpod 2.0 撰寫 Flutter 測試,使用 Mocktail 讓你輕鬆不畏懼! part.2

Yii Chen
Flutter Formosa
Published in
18 min readDec 31, 2022

--

測試幾個開發常遇到的 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,包含 AsyncLoadingAsyncDataAsyncError

AsyncNotifierProvider

  • 回傳 AsyncValue,包含 AsyncLoadingAsyncDataAsyncError
  • 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() 進行初步確認

  1. verify() 用來驗證狀態的更新
  2. expect() 用來檢查目前的 Provider 狀態,跟我們期望是否相同
  1. 完成非同步操作,從 MockLocalStorage 取得資料並返回 ThemeMode
  2. 驗證 Provider 狀態,從 AsyncLoading 到 AsyncData,並取得 ThemeMode.light,因為前面 Mock 的時候我們希望能拿到 true
  3. 期望 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 狀態的監聽

  1. 首先驗證初始狀態,一樣是 null 到 AsyncLoading,並確認之後沒有新的狀態更新了
  2. 因為 Provider 類型是 FutureProvider 這邊使用 await 等待完成,再進行結果檢查,預期拿到的數值是 ThemeMode.light
  3. 最後確認資料只有被存取過一次

測試運行成功

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>())

檢查兩個狀態更新

  1. 第一個情境,在取得目前的 ThemeMode 之前會先更新為 AsyncLoading 狀態
  2. 第二個情境,從 AsyncLoading 準備取得資料,這時候存取資料會拋出例外,是我們安排的情況,Provider 狀態會更新成有錯誤。錯誤的檢查方式使用 predicate(),確認其中 AsyncValue 是否有錯誤,有的話才符合我們要的流程
  3. 最後確認資料只有被存取過一次

也可以用另外一種方式,檢查狀態型別是否正確

() => 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 次,進行呼叫的次數驗證

  1. 因為 Notifier 初始化的關係,一開始在 build() 裡存取資料,取得目前設定的 ThemeMode
  2. 第二次是在呼叫 toggleMode() 時,一開始也會取得資料
  3. 第三次則是再更新 ThemeMode 後,刷新 appThemeModeProvider,一樣需要存取本地資料。實際上是否要有這個 Provider 狀態還是根據實際開發需求,這裡只是做個範例展示
  1. 檢查 Stream 資料流,我們期望它能照流程給予狀態。先是原本的 ThemeMode.light,再來點擊切換樣式後,更新成 ThemeMode.dark
  2. 最後再次驗證 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

--

--

Yii Chen
Flutter Formosa

Flutter Lover || Organizer FlutterTaipei || Writer, Speaker || wanna make Flutter strong in Taiwan. https://linktr.ee/yiichenhi