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

## 経緯
アプリ開発未経験から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**](https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html)クラスを使用します。

### 例
```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('トップページ')が見つかりませんでした。");
```
<details><summary>expect functionについて</summary>

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を渡すと、このテストはスキップされます。
</details>

今回はこのexpectの第一引数にfinderを与えてwidgetを探し、第二引数にはそのwidgetが見つかるかどうかという使い方をしていきます。(第二引数のmatcherについてはまた別の機会に・・・)

## Finderの種類
公式[flutter_test library](https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html)の[CommonFinders class](https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html)から、現在プロジェクトで利用しているFinderを抜粋してみていきます。

### byIcon
[byIcon method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/byIcon.html)
特定のアイコンを持っているアイコンウィジェットを検索します。
#### 使用例
```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);
```

:::note info
[参考: MasaoSasaki/sample-codes/by_icon.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/by_icon.dart)
[参考: MasaoSasaki/sample-codes/by_icon_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/by_icon_test.dart)
:::

***

### byKey
[byKey method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/byKey.html)
特定のキーを持っているウィジェットを検索します。
#### 使用例
```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);
```

:::note info
[参考: MasaoSasaki/sample-codes/by_key.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/by_key.dart)
[参考: MasaoSasaki/sample-codes/by_key_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/by_key_test.dart)
:::

***

### byTooltip
[byTooltip method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/byTooltip.html)
特定のツールチップを持っているウィジェットを検索します。
#### 使用例
```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);
```

:::note info
[参考: MasaoSasaki/sample-codes/by_tooltip.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/by_tooltip.dart)
[参考: MasaoSasaki/sample-codes/by_tooltip_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/by_tootip_test.dart)
:::

***

### byType
[byType method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/byType.html)
特定のウィジェットを検索します。
#### 使用例
```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`メソッドを使うことで、フォームの位置が変わってもある程度は対応できるようになります。

:::note info
[参考: MasaoSasaki/sample-codes/by_type.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/by_type.dart)
[参考: MasaoSasaki/sample-codes/by_type_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/by_type_test.dart)
:::

***

### descendant
[descendant method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/descendant.html)
子孫関係にあるウィジェットを検索します。
#### 使用例
```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)`を探しています。

:::note info
[参考: MasaoSasaki/sample-codes/descendant.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/descendant.dart)
[参考: MasaoSasaki/sample-codes/descendant_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/descendant_test.dart)
:::

***

### text
[text method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/text.html)
冒頭の例の通り、特定の文字列を持つTextウィジェットを検索します。
※テキストを検索するわけではありません。

:::note info
[参考: MasaoSasaki/sample-codes/text.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/text.dart)
[参考: MasaoSasaki/sample-codes/text_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/text_test.dart)
:::

***

### widgetWithIcon
[widgetWithIcon method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/widgetWithIcon.html)
子孫ウィジェットに特定のアイコンウィジェットを持つウィジェットを検索します。
#### 使用例
```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);
```

:::note info
[参考: MasaoSasaki/sample-codes/widget_with_icon.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/widget_with_icon.dart)
[参考: MasaoSasaki/sample-codes/widget_with_icon_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/widget_with_icon_test.dart)
:::

### widgetWithText
***
[widgetWithText method](https://api.flutter.dev/flutter/flutter_test/CommonFinders/widgetWithText.html)
子孫ウィジェットに特定のテキストウィジェットを持つウィジェットを検索します。
#### 使用例
```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);
```

:::note info
[参考: MasaoSasaki/sample-codes/widget_with_text.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/lib/common_finders/widget_with_text.dart)
[参考: MasaoSasaki/sample-codes/widget_with_text_test.dart](https://github.com/MasaoSasaki/sample-codes/blob/writing_20220623_flutter_test_CommonFinders/test/widget_test/common_finders/widget_with_text_test.dart)
:::

All tests passed!

## 参考記事
[flutter_test library](https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html)
[CommonFinders class](https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html)
[expect function](https://api.flutter.dev/flutter/flutter_test/expect.html)
[Unit テストの expect の詳しい使い方](https://flutter.takuchalle.dev/docs/test/expect_matcher/)

[Card class](https://api.flutter.dev/flutter/material/Card-class.html)
[Tooltip class](https://api.flutter.dev/flutter/material/Tooltip-class.html)