開發 App 時,我們會在不同頁面呈現不同資訊,當使用者按下頁面的中的按鈕或者列表中的項目時,可能會把使用者導向另外一個頁面,提供使用者更詳細的資訊或開啟不同的操作流程。那我們該如何測試頁面是否正常導轉呢?今天就來聊聊這個議題吧。
我們修改一下昨天的聊天室例子,首頁一樣顯示聊天室列表,當使用者點擊創建聊天室的按鈕,會開啟另一個創建聊天室的頁面,而不是顯示彈跳視窗。在創建聊天室時,我們還能選擇邀請好友一起加入。[範例程式]
程式已經有了,那我們要怎麼測試頁面跳轉呢?最簡單的方式,我們可以測試當使用者點下按鈕跳轉後,畫面是否出現下一個頁面的元素。還記得我們在測試建立聊天室功能的途中,也驗證過”建立”按鈕是否存在嗎?這邊就稍微換一下,改驗證是否出現建立聊天室的標題吧。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<FriendRepository>()])
main() {
testWidgets("", (tester) async {
await tester.pumpWidget(MultiProvider(
providers: [
Provider<ChatRoomRepository>(create: (context) => ChatRoomRepository()),
Provider<FriendRepository>(create: (context) => FriendRepository()),
],
child: const MyApp(),
));
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.text("建立聊天室"), findsOneWidget);
});
}
在這個測試中,我們模擬使用者真的操作畫面,打開建立聊天室頁面,最後成功驗證建立聊天室的標題有出現在畫面上。那我們有沒有其他測試方法呢?答案肯定是有的,讓我們利用 NavigatorObserver 來測試吧。
NavigatorObserver 是什麼呢?NavigatorObserver 是一個用來監聽 Route 變化的類別,身上有 didPush、didPop …等方法,當 Route 有變化時,相對應的方法就會被呼叫。在實務上,當我們有監聽 Route 變化的需求時,我們就可以繼承 NavigatorObserver 並覆寫我們想要監聽的方法。
class MyNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
// do something
}
}
在 firebase_analytics 的套件中,也有提供 FirebaseAnalyticsObserver 協助我們追蹤使用者的頁面使用狀況,而 FirebaseAnalyticsObserver 其實也是繼承了 NavigatorObserver。
當我們有自己的 NavigatorObserver 之後,就可以在 MaterialApp 的參數中設定使用。
MaterialApp(
navigatorObservers: [MyNavigatorObserver()],
routes: {
ChatRoomListPage.routeName: (context) => const ChatRoomListPage(),
CreateChatRoomPage.routeName: (context) => const CreateChatRoomPage(),
},
)
那我們要怎麼用 NavigatorObserver 來測試呢? 首先我們必須用 mockito 產生一個 MockNavigatorObserver。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
var mockNavigatorObserver = MockNavigatorObserver();
});
}
接著我們就一樣需要在測試中把 MockNavigatorObserver 放到測試中的 MaterialApp 中。在 MaterialApp,我們除了設定 NavigatorObserver 之外,我們放入要測試的頁面,也就是 ChatRoomListPage,最後我們還得給 onGenerateRoute 一個 Dummy,讓測試可以任意 Route 到任何路徑都不會出錯。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
var mockNavigatorObserver = MockNavigatorObserver();
await tester.pumpWidget(
Provider<ChatRoomRepository>(
create: (context) => MockChatRoomRepository(),
child: MaterialApp(
home: const ChatRoomListPage(),
navigatorObservers: [mockNavigatorObserver],
onGenerateRoute: (settings) => MaterialPageRoute(settings: settings, builder: (context) => const SizedBox()), ),
),
);
});
}
最後我們一樣點擊按鈕,假裝進入建立聊天室頁面,最後用 Mock 驗證 didPush 是否真的被呼叫到。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
...
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
var result = verify(mockNavigatorObserver.didPush(captureAny, any));
expect(result.captured[1].settings.name, CreateChatRoomPage.routeName);
});
}
眼尖的朋友可會發現,這邊的 verify 用法不單純只是 verify,而是會會傳一個結果,還有後面的 didPush 呼叫中使用了 captureAny,這又是什麼呢?在這個測試中,我們使用 mocktio 的 capture 機制來輔助驗證參數,在 capture 機制中,我們可以用 capture 獲取傳進 didPush 的參數,在 verify 結束後用回傳值傳出來。後續測試就可以拿這個剛剛傳入 didPush 的參數繼續做更深入的驗證。
var result = verify(mockNavigatorObserver.didPush(captureAny, any));
以這邊的例子來說,由於我們只想驗證 Route 的名稱是否正確,並不關心 Route 的其他狀態,所以我們用 capture 把參數抓出來,並單獨驗證 RouteSettings 的 name 而已。
另外由於測試過程中,Mock 的方法可能被呼叫很多次,所以 result 中的 capture 是一個陣列,會記錄每一次傳入的參數。
expect(result.captured[1].settings.name, CreateChatRoomPage.routeName);
那為什麼在這個測試中,我們抓取陣列中第二次呼叫的參數來比較,而不是第一次呢?因為第一次的 didPush 是發生在我們用 pumpWidget 建立畫面時,顯示初始頁面也是一種 Route 變化,所以在這個測試中,我們會抓取的是第二次頁面轉換的參數來驗證。
最後執行測試,也正確的通過測試,得到綠燈。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
var mockNavigatorObserver = MockNavigatorObserver();
await tester.pumpWidget(
Provider<ChatRoomRepository>(
create: (context) => MockChatRoomRepository(),
child: MaterialApp(
home: const ChatRoomListPage(),
navigatorObservers: [mockNavigatorObserver],
routes: {CreateChatRoomPage.routeName: (context) => const SizedBox()},
),
),
);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
var result = verify(mockNavigatorObserver.didPush(captureAny, any));
expect(result.captured[1].settings.name, CreateChatRoomPage.routeName);
});
}
測試寫完後,可以發現測試變得一大坨,肯定是不容易理解與維護。一樣的當我們寫完測試之後,我們必須重構一下,幫將來的自己節省時間。
在 MaterialApp 中,為了避免在測試過程中跳轉到其他未定義的頁面,這邊我們有設定 onGenerateRoute,但是這個對於理解測試沒有什麼幫助,所以我們抽取方法抽了一個 dummyRouteGenerator。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
var mockNavigatorObserver = MockNavigatorObserver();
await tester.pumpWidget(
Provider<ChatRoomRepository>(
create: (context) => MockChatRoomRepository(),
child: MaterialApp(
home: const ChatRoomListPage(),
navigatorObservers: [mockNavigatorObserver],
onGenerateRoute: dummyRouteGenerator,
),
),
);
...
});
}
Route Function(RouteSettings) get dummyRouteGenerator =>
(settings) => MaterialPageRoute(settings: settings, builder: (context) => const SizedBox());
再來我們可以處理一下驗證的部分,還記得介紹非正常流程的文章與介紹 Finder 的文章中,我們提到放在 expect 二個參數的東西嗎?
expect(() => server.execute(), throwsA(isA<MoneyNotEnoughException>()));
expect(find.text("Hello World"), findsOneWidget);
我們在第二個參數中放的 throwsA 與 findsOneWidget 回傳的 Throws 與 _FindsWidgetMatcher 其實都是繼承於 Matcher 這個類別,Matcher 最主要的功能就是拿來驗證結果。如果我們走進 Matcher 的原始碼中,就可以看到其核心的方法 matches,不同的Matcher 都是繼承自 Matcher 然後實作各自的驗證方式,最後統一回傳 bool 決定測試成功或失敗。
abstract class Matcher {
const Matcher();
/// Does the matching of the actual vs expected values.
///
/// [item] is the actual value. [matchState] can be supplied
/// and may be used to add details about the mismatch that are too
/// costly to determine in [describeMismatch].
bool matches(dynamic item, Map matchState);
/// Builds a textual description of the matcher.
Description describe(Description description);
/// Builds a textual description of a specific mismatch.
///
/// [item] is the value that was tested by [matches]; [matchState] is
/// the [Map] that was passed to and supplemented by [matches]
/// with additional information about the mismatch, and [mismatchDescription]
/// is the [Description] that is being built to describe the mismatch.
///
/// A few matchers make use of the [verbose] flag to provide detailed
/// information that is not typically included but can be of help in
/// diagnosing failures, such as stack traces.
Description describeMismatch(dynamic item, Description mismatchDescription,
Map matchState, bool verbose) =>
mismatchDescription;
}
在我們了解 Matcher 是什麼之後,是時候來製作 RouteMatcher 了。在 Matcher 中,我們實作了比較 settings 的 name 是否符合預期。
class RouteMatcher extends Matcher {
final String routeName;
RouteMatcher({required this.routeName});
@override
Description describe(Description description) {
return description.add('routeName: $routeName');
}
@override
bool matches(item, Map matchState) {
return item.settings.name == routeName;
}
}
最後用法上就可以直接 captureThat 傳入 RouteMatcher 讓 mockito 直接依照 RouteMatcher 的規則去驗證就好,而不用自己把參數抓出來驗證。
@GenerateNiceMocks([MockSpec<ChatRoomRepository>(), MockSpec<NavigatorObserver>()])
main() {
testWidgets("open create chat room page", (tester) async {
...
verify(mockNavigatorObserver.didPush(
captureThat(RouteMatcher(routeName: CreateChatRoomPage.routeName)),
any,
));
});
}
再加上之前介紹過的重構技巧,最後測試就變得更好懂了。
今天就先談到這邊,今天聊了 Route 的測試,也講到如何用 NavigatorObserver 輔助測試,不知道有沒有觀眾朋友好奇一件事,好像不用 NavigatorObserver 的測試看起來反而比較簡單,如果只是簡單的例子也確實是這樣,但是用 NavigatorObserver 有一個好處,這部分我們明天會繼續論。另外,明天會繼續討論 Route 測試的另一半:從其他頁面返回結果的情境,有興趣的觀眾朋友也歡迎繼續追蹤。