本文進入測試環節。適合對 Riverpod 有實際玩過且熟悉的朋友們,不會特別講解相關開發技巧,我們著重在於如何寫好一個基本的測試,穩固專案的品質。希望由這些範例讓大家更有感,對寫測試不害怕而且喜歡,相信對於自己的專案會有所幫助。
過程中會使用到 Mocktail 套件,一個 Dart 的測試模擬工具,由 Bloc 作者 Felix Angelov 製作。Mocktail 提供很多簡單使用的 API,讓我們可以輕鬆偽造動作與資料,不需要自己撰寫 Mock Data 或是 Codegen 生成 (就像 mockito 套件)。
在驗證測試的過程中我們會需要 ProviderSubscription 的 listen()
函式,負責監聽 Provider 的狀態變化,利於在測試裡檢查狀態的更新,是否跟我們邏輯流程裡期望的結果一樣。
而在開始之前,首先需要自定義一個 ProviderListener,它負責 listen()
函式裡的 callback,包含兩個參數,第一個為上一個狀態,第二個為新的狀態。驗證的過程都可以透過它知道 callback 被呼叫幾次,以及狀態是否符合期望。
// Mock listener
class ProviderListener<T> extends Mock {
void call(T? previous, T? next);
}
以下是後面會看到的實際範例,由 listener 進行驗證
// Use Container to listen specific Provider status
providerContainer.listen(
testAppThemeModeProvider,
listener,
fireImmediately: true,
);
build()
→ 負責初始化,允許進行非同步操作,返回 FutureOr 型別資料從本地儲存取得 APP 上次保存的 ThemeMode,透過它可以知道裝置為 light 還是 dark
首先在每個測試執行前初始化一些物件,進行前置作業。需要 ProviderContainer 存取每個 Provider、每個狀態,接著因為此測試要取得本地儲存的資料,需要偽造使用到的 LocalStorage,透過 overrides 覆蓋為 MockLocalStorage,準備測試使用。
makeProviderContainer()
→ 方便在初始化時使用,只需給予要偽造的 Provider,以及在測試結束後釋放資源
ProviderContainer makeProviderContainer({required List<Override> overrides}) {
final container = ProviderContainer(overrides: overrides);
addTearDown(container.dispose);
return container;
}
當使用 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 的操作只有一次
測試運行成功!
以下為範例程式碼:
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);
},
);
本範例一樣是從本地取得 APP 保存的 ThemeMode,不同的是,這裡使用 AsyncNotifier 在 build()
初始化時從本地取得 ThemeMode,並設置初始狀態。AppThemeModeNotifier 經由 appThemeModeNotifierProvider
提供
通常一開始都是先使用 when()
和 then()
相關 API,進行操作的資料偽照。接著透過 listen()
進行 Provider 狀態的監聽
appThemeModeNotifierProvider
類型是 AsyncNotifierProvider,這邊使用 await future 等待初始化完成,再進行結果檢查,預期拿到的數值是 ThemeMode.light
測試運行成功
以下為範例程式碼:
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);
},
);
新增 toggleMode()
目的為切換 App 的 ThemeMode,驗證過程中在拋出例外後的狀態更新與流程是否正常
在測試一開始先安排資料預期的輸出,在存取本地資料的時候希望拋出例外
當 listen()
指定 Provider 狀態的時候,就開始進行 build()
的初始化,這時候會去存取 LocalStorage,所以先檢查是否已經呼叫一次
當存取 LocalStorage 的時候,期望獲得一個例外,可以使用 throwA(isA<Exception>())
檢查兩個狀態更新
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);
},
);
在初始 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();
// call once in build()
// call once in toggleMode()
// call once in appThemeModeProvider
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(3);
expect(
providerContainer.read(appThemeModeNotifierProvider.notifier).currentModeStream,
emitsInOrder(
const [
ThemeMode.light,
ThemeMode.dark,
],
),
);
// call once in build()
verify(() => storage.get(LocalStorageKeys.isLightTheme)).called(1);
},
);
注意:如果是使用 expectLater()
來檢查結果的話需要先在操作前定義好,等待操作後的結果,這樣寫測試比較不自然。建議用 expect()
,可以在操作後進行檢查
any()
幫助檢查timeout
參數,確保我們的測試在需要時快速失敗,不會卡住流程將一些重複的操作優化成 Extension API,除了方便外,穩定性、可度性都能提高。
/// For flutter_test
extension FinderMatchExtension on Finder {
void never() => expect(this, findsNothing);
void once() => expect(this, findsOneWidget);
void times(int number) => expect(this, findsNWidgets(number));
void some() => expect(this, findsWidgets);
}
/// For mocktail
extension VerificationCalledExtension on VerificationResult {
void never() => called(0);
void once() => called(1);
void twice() => called(2);
void threeTimes() => called(3);
void times(int number) => called(number);
}
在使用 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>());
});
到這裡大家覺得測試好玩嗎?有沒有接受並開始懂的如何寫了呢?其實,不管自己和公司專案有沒有在寫測試,或是有 QA 在幫忙,都應該要重視這一塊。當我們針對邏輯、操作流程、元件做了一層保護,開發、維護上也會更有保障,不是靠其他人驗證就沒問題,我們也不用提心吊膽的進行改版更新。應該讓自己慢慢養成寫測試的習慣,如何在時間與需求的壓力下讓專案趨近於完整和高品質,一直是開發者的課題。
也期許未來能夠看到更多有關測試的經驗談,之後也會在撰寫另一篇有關測試技巧的內容,歡迎大家提出來討論,分享踩坑紀錄,我想應該蠻有趣的!