iT邦幫忙

2023 iThome 鐵人賽

DAY 18
0
Mobile Development

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

Day 18 如何用 Widget Test 測試 Routing

  • 分享至 

  • xImage
  •  

開發 App 時,我們會在不同頁面呈現不同資訊,當使用者按下頁面的中的按鈕或者列表中的項目時,可能會把使用者導向另外一個頁面,提供使用者更詳細的資訊或開啟不同的操作流程。那我們該如何測試頁面是否正常導轉呢?今天就來聊聊這個議題吧。

頁面跳轉

我們修改一下昨天的聊天室例子,首頁一樣顯示聊天室列表,當使用者點擊創建聊天室的按鈕,會開啟另一個創建聊天室的頁面,而不是顯示彈跳視窗。在創建聊天室時,我們還能選擇邀請好友一起加入。[範例程式]

1.jpg

程式已經有了,那我們要怎麼測試頁面跳轉呢?最簡單的方式,我們可以測試當使用者點下按鈕跳轉後,畫面是否出現下一個頁面的元素。還記得我們在測試建立聊天室功能的途中,也驗證過”建立”按鈕是否存在嗎?這邊就稍微換一下,改驗證是否出現建立聊天室的標題吧。

@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 是什麼呢?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 測試 Route 變化

那我們要怎麼用 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);
  });
}

mockito 的 capture 機制

眼尖的朋友可會發現,這邊的 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);
  });
}

重構測試

測試寫完後,可以發現測試變得一大坨,肯定是不容易理解與維護。一樣的當我們寫完測試之後,我們必須重構一下,幫將來的自己節省時間。

DummyRoutes

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

RouteMatcher

再來我們可以處理一下驗證的部分,還記得介紹非正常流程的文章與介紹 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 測試的另一半:從其他頁面返回結果的情境,有興趣的觀眾朋友也歡迎繼續追蹤。


上一篇
Day 17 模擬使用者互動
下一篇
Day 19 測試 Routing 回傳值
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言