#不廢話直接看扣!#Flutter Widget Test最完整的真實範例

分享

本篇將分享在真實專案(Real World Project)中,時常會遇到的Widget Tests(Unit Tests)案例,不廢話直接點索引看扣!

開始前

Test資料夾結構大概長這樣:

- test
   - helpers
      > AppWrapper.dart  # 包在Widget最外層(方便使用、多語系也會用到)
      > MockHelper.dart  # 這邊會放Mock Classes
   - widgets             # Widget Tests都在這

# Widgets中有呼叫HTTP Requests,怎麼寫測試?

參考這篇!
#不廢話直接看扣!#Flutter 如何開始寫測試-HTTP Request Mocking (feat. Mocktail)?

# Navigation Push/Pop怎麼寫測試?

>>>> [test/helpers/AppWrapper.dart]
class LocaleWrapper extends StatelessWidget {
  final Widget? child;                               <----- 傳入要測試的Widget
  final List<NavigatorObserver>? navigatorObservers; <----- 到時會傳入MockNavigatorObserver
  const LocaleWrapper({Key? key, this.child, this.navigatorObservers})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    String defaultLanguage = 'en';
    HttpHelper.cableURL = F.cableURL;
    HttpHelper.host = F.host;
    return MaterialApp(                              <----- 如果測到客製的Widget,沒有Material作為父層,測試會失敗
      localizationsDelegates: [
        FlutterI18nDelegate(
          translationLoader: FileTranslationLoader(
            useCountryCode: true,
            fallbackFile: defaultLanguage,
            basePath: 'assets/i18n',
            forcedLocale: Locale(defaultLanguage),
          ),
        ),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      navigatorObservers: navigatorObservers ?? [],
      navigatorKey: navigatorKey,
      locale: Locale(defaultLanguage),
      home: child,
    );
  }
}

>>>> [test/helpers/MockHelper.dart]
class FakeRoute<T> extends Fake implements Route<T> {}
class MockNavigatorObserver extends Mock implements NavigatorObserver {}

>>>> [test/widgets/navigator_test.dart]
void main() {
   group('Navigator test', () {
      late NavigatorObserver mockObserver;
      setUpAll(() {
         registerFallbackValue(FakeRoute());      <----- 這個一定要加
         mockObserver = MockNavigatorObserver();
       });
      testWidgets('Navigate Push', (WidgetTester tester) async {
         await tester.pumpWidget(LocaleWrapper(
           child: NavigationA(),
           navigatorObservers: [mockObserver],
         ));
         await tester.pumpAndSettle();
         await tester.tap(find.byIcon(Icons.next));          <----- 點擊按鈕觸發
         await tester.pump();
         verify(() => mockObserver.didPush(any(), any()));   <----- 檢查是否有觸發didPush
         expect(find.byType(NavigationB), findsOneWidget);   <----- 檢查是否Push到NavigationB
      });

      testWidgets('Navigate Pop', (WidgetTester tester) async {
         await tester.pumpWidget(LocaleWrapper(
           child: NavigationB(),
           navigatorObservers: [mockObserver],
         ));
         await tester.pumpAndSettle();
         await tester.tap(find.byIcon(Icons.back));          <----- 點擊按鈕觸發
         await tester.pump();
         verify(() => mockObserver.didPop(any(), any()));    <----- 檢查是否有觸發didPop
         expect(find.byType(NavigationA), findsOneWidget);   <----- 檢查是否Push到NavigationB
      });
   });
}

# 如何在Widget Tests中使用BuildContext?

>>>> [test/widgets/UseContext_test.dart]
void main() {
   group('Use BuildContext test', () {
      BuildContext? _context;
      testWidgets('Navigate Push', (WidgetTester tester) async {
         await tester.pumpWidget(LocaleWrapper(
           child: Builder(builder: (context) {
             _context = context; <----- 使用Builder就可以取得context了
             return TestWidget();
           },
         ));
         await tester.pumpAndSettle();
         // 以下省略 :)
      });
   });
}

# Widget有傳入Callback參數,怎麼寫測試?

>>>> [lib/widgets/FacebookLoginButton.dart]
class FacebookLoginButton extends StatelessWidget {
  final FacebookAuth? facebookAuth;
  final Function()? onStart;               <----- 想檢查點擊後,是否有呼叫Callback
  final Function(String?)? onSuccess;
  final Function(String?)? onFailed;

  const FacebookLoginButton(
      {Key? key,
      this.onSuccess,
      this.onFailed,
      this.onStart,
      this.facebookAuth})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Ink(
      decoration: const ShapeDecoration(
        color: Color.fromRGBO(66, 103, 178, 1),
        shape: CircleBorder(),
      ),
      child: IconButton(
          icon:
              const Icon(FontAwesomeIcons.facebookSquare, color: Colors.white),
          color: Colors.white,
          onPressed: () => doLogin()),
    );
  }

  void doLogin() async { <----- doLogin為主要邏輯,決定呼叫哪些Callback
    onStart?.call();
    var result = await (facebookAuth ?? FacebookAuth.instance).login();
    if (result.status == LoginStatus.success) {
      final AccessToken accessToken = result.accessToken!;
      RepoHelper.auth.loginFacebook(accessToken.token).then((value) {
        onSuccess?.call(value?.authToken);
      }).catchError((e) {
        onFailed?.call(e.toString());
      });
    } else {
      onFailed?.call(result.message);
    }
  }
}

>>>> [test/helpers/AppWrapper.dart]
Widget widgetWrapper({required Widget child}) {
  return MediaQuery(            
      data: MediaQueryData(),   <----- 記得包MediaQuery,不然會有error
      child: Card(              <----- 使用Card作為Material父層
          child:
              Directionality(textDirection: TextDirection.ltr, child: child))); <----- 記得包Directionality,不然會有error
}

>>>> [test/widgets/FacebookLoginButton_test.dart]
void main() {
  group('Facebook Login', () {
    late bool onStartedCalled;
    late bool onSuccessCalled;
    late bool onFailedCalled;
    late MockFacebookAuth facebookAuth;
    Future<void> _loadWidget(WidgetTester tester) async {
      await tester.pumpWidget(
        widgetWrapper(   <----- 客製的Widget需要Material作為父層
            child: FacebookLoginButton(
                facebookAuth: facebookAuth,
                onFailed: (msg) => onFailedCalled = true,
                onStart: () => onStartedCalled = true,
                onSuccess: (token) => onSuccessCalled = true)),
      );
    }

    setUp(() {
      onStartedCalled = false;
      onSuccessCalled = false;
      onFailedCalled = false;
    });

    setUpAll(() {
      facebookAuth = MockFacebookAuth();
    });

    testWidgets('Facebook Login Success', (WidgetTester tester) async {
      MockFacebookAccessToken accessToken = MockFacebookAccessToken();  <----- 範例是測試Facebook登入,所以會Mock Facebook的Class
      HttpHelper.http = MockClient((req) async {
        print(req.url.path);
        if (req.url.path == '/auth/facebook') {
          return Response('{"auth_token": "123"}', 200);
        }
        return Response('null', 200);
      });
      await _loadWidget(tester);
      when(() => accessToken.token).thenReturn('test_token');     <----- Mock Facebook登入成功條件
      when(() => facebookAuth.login()).thenAnswer((invocation) async =>
          LoginResult(status: LoginStatus.success, accessToken: accessToken));
      await tester.tap(find.byIcon(FontAwesomeIcons.facebookSquare));
      await tester.pump();
      expect(onStartedCalled, isTrue);
      expect(onFailedCalled, isFalse);
      expect(onSuccessCalled, isTrue);
    });
  });
}

# NetworkImage出現Error怎麼辦?

最快的解法是加入第三方套件:network_image_mock

testWidgets('Layout', (WidgetTester tester) async {
   mockNetworkImagesFor(() async {  <----- 多包一層,就可以了!
     await _loadWidget(tester);
     expect(find.text('123'), findsOneWidget);
     expect(find.byType(CoverRAvatar), findsOneWidget);
     expect(find.byType(GenderAvatar), findsOneWidget);
   });
});

# ScrollView內容太長,找不到Widget,怎麼辦?

Future<Null> _verifyRatingLayoutElements(WidgetTester tester) async {
   expect(find.text('Do you smoke?'), findsOneWidget);
   expect(find.text('Personal / Matchbox IB'), findsOneWidget);

   await tester.drag(find.byWidget(_page), const Offset(0.0, -300.0)); <----- 加一個向下滑動的手勢就可以了!
   await tester.pumpAndSettle();
   expect(find.byType(OptionStarListItem), findsWidgets);

   await tester.drag(find.byWidget(_page), const Offset(0.0, -300.0));  <----- 加一個向下滑動的手勢就可以了!
   await tester.pumpAndSettle();
   expect(find.byType(CommentListItem), findsWidgets);
}

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *