本篇將分享在真實專案(Real World Project)中,時常會遇到的Widget Tests(Unit Tests)案例,不廢話直接點索引看扣!
- Widgets中有呼叫HTTP Requests,怎麼寫測試?
- Navigation Push/Pop怎麼寫測試?
- 如何在Widget Tests中使用BuildContext?
- Widget有傳入Callback參數,怎麼寫測試?
- NetworkImage出現Error怎麼辦?
- ScrollView內容太長,找不到Widget,怎麼辦?
內容目錄
開始前
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);
}