経緯
アプリ開発未経験からFlutterを触り始めて3ヶ月目に入り、最近はFlutter Testについてキャッチアップすることが多かったので、整理と今後のために記事として残しておくことにしました。
今回はその中でも、widgetを見つけるときに使用するFinderについてまとめてみます。
前提
$ flutter --version
Flutter 2.10.5 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5464c5bac7 (9 weeks ago) • 2022-04-18 09:55:37 -0700
Engine • revision 57d3bac3dd
Tools • Dart 2.16.2 • DevTools 2.9.2
dev_dependencies:
flutter_test:
sdk: flutter
Finderについて
Widgetテスト、Integrationテストでは画面上に期待するwidgetや値が正しく表示されているかを確認します。
テスト時にはwidgetを特定するためにCommonFinderクラスを使用します。
例
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'0',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
);
};
}
expect(find.text('トップページ'), findsOneWidget, reason: "Text('トップページ')が見つかりませんでした。");
expect functionについて
expext(dynamic actual, dynamic matcher, {String? reason,
dynamic skip})
expectの第一引数(actual)には実際に入る値、第二引数(matcher)には期待する値を指定します。
第三引数のreasonキーには、このテストに失敗した際に表示されるメッセージを指定することができます。
表示される際は以下のようになります。
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching node in the widget tree
Actual: _TextFinder:<zero widgets with text "トップページ" (ignoring offstage widgets)>
Which: means none were found but one was expected
Text('トップページ')が見つかりませんでした。
第四引数のskipキーではこのテストをスキップするかどうかを指定し、skipキーに文字列(スキップする理由)かtrueを渡すと、このテストはスキップされます。
今回はこのexpectの第一引数にfinderを与えてwidgetを探し、第二引数にはそのwidgetが見つかるかどうかという使い方をしていきます。(第二引数のmatcherについてはまた別の機会に・・・)
Finderの種類
公式flutter_test libraryのCommonFinders classから、現在プロジェクトで利用しているFinderを抜粋してみていきます。
byIcon
byIcon method
特定のアイコンを持っているアイコンウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.home))],
),
body: const Text('You have pushed the button this many times:'),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byIcon(Icons.home), findsOneWidget);
byKey
byKey method
特定のキーを持っているウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
key: const Key('top_page_app_bar'),
title: const Text('トップページ'),
),
body: const Text(
'You have pushed the button this many times:',
key: const Key('top_page_msg')
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add, key: Key('top_page_FAB_icon')),
),
),
);
}
Widgetに対してkey: Key('***')
を付与しています。
expect(find.byKey(const Key('top_page_app_bar')), findsOneWidget);
expect(find.byKey(const Key('top_page_msg')), findsOneWidget);
expect(find.byKey(const Key('top_page_FAB_icon')), findsOneWidget);
byTooltip
byTooltip method
特定のツールチップを持っているウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('トップページ')),
body: Row(
children: const [
Text('You have pushed the button this many times:'),
Tooltip(
message: '右下の+を押すと数字が増えます。',
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.all(Radius.circular(50))),
triggerMode: TooltipTriggerMode.tap,
child: Icon(
CupertinoIcons.question_circle,
color: Colors.grey,
)),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
Tooltipウィジェットのmessage:キーやFABのtooltip:キーに対しても使えます。
expect(find.byTooltip('右下の+を押すと数字が増えます。'), findsOneWidget);
expect(find.byTooltip('increment'), findsOneWidget);
byType
byType method
特定のウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
),
body: Column(children: [
const Text('新規登録'),
Row(
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: '田中',
labelText: '姓',
),
),
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: '太郎',
labelText: '名',
),
),
))
],
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: 'xxx@example.com',
labelText: 'メールアドレス',
),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: '8桁(英大文字, 英小文字, 数字各1文字以上)',
labelText: 'パスワード',
),
),
)
]),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
// Finderを変数化しておく。
final Finder _lastNameForm = find.byType(TextFormField).at(0);
final Finder _firstNameForm = find.byType(TextFormField).at(1);
final Finder _emailForm = find.byType(TextFormField).at(2);
final Finder _passwordForm = find.byType(TextFormField).last;
// TextFormFieldに値を入力するなら。
await tester.enterText(_lastNameForm, '鈴木');
await tester.enterText(_firstNameForm, '一郎');
await tester.enterText(_emailForm, 'test@test.com');
await tester.enterText(_passwordForm, 'Widget1234test');
expect(_lastNameForm, findsOneWidget);
expect(_firstNameForm, findsOneWidget);
expect(_emailForm, findsOneWidget);
expect(_passwordForm, findsOneWidget);
byTypeでは複数のウィジェットが見つかることが前提となっているため、.at(index)
で何個目かのウィジェットを指定する必要があります。
.first
、.last
で最初もしくは最後のウィジェットを指定することもできます。
ただし、これらの方法(.at()
, first
, last
)ではフォームの位置に変更があったときに対応できないので、前述のbyKey
メソッドを使うか、後述するdescendant
メソッドを使うことで、フォームの位置が変わってもある程度は対応できるようになります。
descendant
descendant method
子孫関係にあるウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
const double _labelFieldWidth = 150.0;
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
),
body: Column(children: [
const Text('新規登録'),
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const SizedBox(width: _labelFieldWidth, child: Text('メールアドレス')),
Flexible(
child: TextFormField(
decoration: const InputDecoration(
hintText: 'xxx@example.com',
enabledBorder:
OutlineInputBorder(borderSide: BorderSide())),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const SizedBox(width: _labelFieldWidth, child: Text('パスワード')),
Flexible(
child: TextFormField(
decoration: const InputDecoration(
hintText: '8桁(英大文字, 英小文字, 数字各1文字以上)',
enabledBorder:
OutlineInputBorder(borderSide: BorderSide())),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const SizedBox(
width: _labelFieldWidth, child: Text('パスワード(確認用)')),
Flexible(
child: TextFormField(
decoration: const InputDecoration(
hintText: '8桁(英大文字, 英小文字, 数字各1文字以上)',
enabledBorder:
OutlineInputBorder(borderSide: BorderSide())),
),
),
],
),
),
]),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
// Finderを変数化しておく。
final Finder _emailForm = find.descendant(of: find.widgetWithText(Padding, 'メールアドレス'), matching: find.byType(TextFormField));
final Finder _passwordForm = find.descendant(of: find.widgetWithText(Padding, 'パスワード'), matching: find.byType(TextFormField));
final Finder _confirmPasswordForm = find.descendant(of: find.widgetWithText(Padding, 'パスワード(確認用)'), matching: find.byType(TextFormField));
// TextFormFieldに値を入力する。
await tester.enterText(_emailForm, 'test@test.com');
await tester.enterText(_passwordForm, 'Widget1234test');
await tester.enterText(_confirmPasswordForm, 'Widget1234test');
expect(_emailForm, findsOneWidget);
expect(_passwordForm, findsOneWidget);
expect(_confirmPasswordForm, findsOneWidget);
descendantのofキーで見つかったウィジェットの子孫ウィジェットをmatchingキーで検索します。
上記のコードの場合だと、widgetWithTextで特定のテキストウィジェットが含まれるウィジェット(上記の例だと、Text('メールアドレス')が含まれるPadding())のツリーからmatching: find.byType(TextFormField)
を探しています。
text
text method
冒頭の例の通り、特定の文字列を持つTextウィジェットを検索します。
※テキストを検索するわけではありません。
widgetWithIcon
widgetWithIcon method
子孫ウィジェットに特定のアイコンウィジェットを持つウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.home))],
),
body: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(children: [
Flexible(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Search',
),
),
),
ElevatedButton(onPressed: () {}, child: Row(
children: const [
Text('検索'),
Icon(Icons.search),
],
)),
]),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
expect(find.widgetWithIcon(ElevatedButton, Icons.search), findsOneWidget);
widgetWithText
widgetWithText method
子孫ウィジェットに特定のテキストウィジェットを持つウィジェットを検索します。
使用例
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
),
body: Column(children: [
const Text('新規登録'),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: 'xxx@example.com',
labelText: 'メールアドレス',
),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: '8桁(英大文字, 英小文字, 数字各1文字以上)',
labelText: 'パスワード',
),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextFormField(
decoration: const InputDecoration(
hintText: '8桁(英大文字, 英小文字, 数字各1文字以上)',
labelText: 'パスワード(確認用)',
),
),
),
ElevatedButton(onPressed: () {}, child: const Text('送信')),
]),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'increment',
child: const Icon(Icons.add),
),
),
);
}
expect(find.widgetWithText(ElevatedButton, '送信'), findsOneWidget);
All tests passed!
参考記事
flutter_test library
CommonFinders class
expect function
Unit テストの expect の詳しい使い方