3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutter Testで頻繁に使用されるCommonFinderを整理してみた。

Last updated at Posted at 2022-07-08

経緯

アプリ開発未経験から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
pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

Finderについて

Widgetテスト、Integrationテストでは画面上に期待するwidgetや値が正しく表示されているかを確認します。
テスト時にはwidgetを特定するためにCommonFinderクラスを使用します。

main.dart
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),
        ),
      ),
    );
  };
}
main_test.dart
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 libraryCommonFinders classから、現在プロジェクトで利用しているFinderを抜粋してみていきます。

byIcon

byIcon method
特定のアイコンを持っているアイコンウィジェットを検索します。

使用例

by_icon.dart
@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),
      ),
    ),
  );
}
by_icon_test.dart
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byIcon(Icons.home), findsOneWidget);

byKey

byKey method
特定のキーを持っているウィジェットを検索します。

使用例

by_key.dart
@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('***')を付与しています。

by_key_test.dart
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
特定のツールチップを持っているウィジェットを検索します。

使用例

by_tooltip.dart
@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:キーに対しても使えます。

by_tooltip_test.dart
expect(find.byTooltip('右下の+を押すと数字が増えます。'), findsOneWidget);
expect(find.byTooltip('increment'), findsOneWidget);

byType

byType method
特定のウィジェットを検索します。

使用例

by_type.dart
@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),
      ),
    ),
  );
}
by_type_test.dart
// 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
子孫関係にあるウィジェットを検索します。

使用例

descendant.dart
@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),
      ),
    ),
  );
}
descendant_test.dart
// 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
子孫ウィジェットに特定のアイコンウィジェットを持つウィジェットを検索します。

使用例

widget_with_icon.dart
@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),
      ),
    ),
  );
}
widget_with_icon_test.dart
expect(find.widgetWithIcon(ElevatedButton, Icons.search), findsOneWidget);

widgetWithText


widgetWithText method
子孫ウィジェットに特定のテキストウィジェットを持つウィジェットを検索します。

使用例

widget_with_text.dart
@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),
      ),
    ),
  );
}
widget_with_text_test.dart
expect(find.widgetWithText(ElevatedButton, '送信'), findsOneWidget);

All tests passed!

参考記事

flutter_test library
CommonFinders class
expect function
Unit テストの expect の詳しい使い方

Card class
Tooltip class

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?