Flutterでフォーム画面（Widget）の単体テストを実装する（mockito利用）

FlutterでWidgetの単体テストを書きましたので、メモしておきます。
Widgetの単体テスト（フォーム画面）で良いサンプルがすぐは見つからなかったので、参考になれば幸いです。

## テストする画面

* 認証OK時の動作

![login_ok.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/a877d718-631a-d593-c884-cea9827bc999.gif)

* 入力チェックエラー時の動作

![validation_error.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/8d71e7b4-ea6e-2d81-fccf-997a25bee299.gif)

* 認証NG時の動作

![login_error.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/1a2d203a-e797-cde2-b42f-bc644969dbfd.gif)

## GitHubレポジトリ

https://github.com/Thirosue/flutter_sample/tree/main/login_form_sample/flutter_app

## テスト対象画面の解説

https://qiita.com/takeshi_hirosue/items/d347071b57ef74ced80a

## テストケース

テストケースは以下としました。
以下3点を実装し、テストを実施しています。
1. 要素の存在チェック
2. 入力したテキストの表示確認
3. モックの使用

|No  |観点  |ケース  |
|---|---|---|
|1  |画面初期表示  |画面が初期表示されたとき、ログインボタンが存在すること  |
|2  |入力チェック  |画面が初期表示されたとき、入力チェックが動作していないこと  |
|3  |入力チェック  |ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること  |
|4  |入力チェック  |パスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること  |
|5  |入力チェック  |ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること  |
|6  |パスワードマスク  |パスワードを入力したとき、入力した文言がマスク(••••)されていること  |
|7  |パスワードマスク  |パスワードを入力し、パスワード表示アイコンを押したとき、入力した文言のマスクが解除されていること。もう一度パスワードマスクアイコンを押したとき、パスワードがマスクされること  |
|8  |認証エラー  |ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること  |

## テスト実行結果

```bash
% flutter test --no-sound-null-safety
```

![スクリーンショット 2021-03-15 10.16.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/461de5b6-5751-5d0f-5fa9-1d5b8b197b79.png)

## 要素の存在チェック

[公式](https://flutter.dev/docs/cookbook/testing/widget/introduction#additional-matchers)に記載がある通り、findsNothing（Widgetが存在しないとき）、findsOneWidget（Widgetが一つ存在するとき）、findsNWidgets（Widget数を検証）を利用し、テストを実装していきます。

* Widgetが存在しないときのテストケース

```dart:test/ui/login_page_test.dart
    testWidgets('2. 画面が表示されたとき、入力チェックが動作していないこと', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(find.text('入力してください'), findsNothing); // エラーメッセージが存在しないこと
    });
```

* Widgetが1つ存在するときのテストケース

```dart:test/ui/login_page_test.dart
  final _submitButton = find.text('ログイン');

    testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(_submitButton, findsOneWidget); // ログインボタンが1つ存在すること
    });
```

* Widgetの数を検証するケース

```dart:test/ui/login_page_test.dart
    testWidgets('5. ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
        (tester) async {
      await tester.pumpWidget(loginApp());

      await tester.tap(_submitButton);
      await tester.pump();

      final validationErrorMessages = find.text('入力してください');
      expect(validationErrorMessages, findsNWidgets(2)); // エラーメッセージが2つ表示されること
    });
```

## 入力したテキストの表示確認

こちらは[Flutter本体のレポジトリ](https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/editable_text_utils.dart)の方法を参考にしました。
具体的には以下の関数を利用しています。

RenderObjectを利用し、再帰的に操作すると、入力文言が取得できるようです。

```dart:test/ui/login_page_test.dart
RenderEditable findRenderEditable(WidgetTester tester, int index) {
  final RenderObject root =
      tester.renderObject(find.byType(EditableText).at(index)); // 対象のフィールドのRenderObjectを取得
  expect(root, isNotNull);

  late RenderEditable renderEditable;
  void recursiveFinder(RenderObject child) {
    if (child is RenderEditable) {
      renderEditable = child;
      return;
    }
    child.visitChildren(recursiveFinder); // 再帰的に処理する
  }

  root.visitChildren(recursiveFinder);
  expect(renderEditable, isNotNull);
  return renderEditable;
}
```

* Widgetに表示される文言を取得するケース

```dart:test/ui/login_page_test.dart
    testWidgets('6. パスワードを入力したとき、入力した文言がマスク(••••)されていること', (tester) async {
      await tester.pumpWidget(loginApp());
      await tester.enterText(_password, 'hoge');
      await tester.pump();

      final String editText = findRenderEditable(tester, 1).text!.text!; // 上記関数を利用し、Widgetに表示されたテキストを取得する
      print(editText); // ••••が出力される

      expect(editText.substring(editText.length - 1), '\u2022'); //表示されたパスワードが「•」であること（＝マスクされていること）の検証
    });
```

## モックを利用する

今回のテストでは、認証処理をMock化します。
Mockライブラリは、mockitoを利用します。

### 画面（Widget）のプログラムコード

* 状態クラス

```dart:lib/ui/login_model.dart
class LoginModel extends ChangeNotifier {
  final AuthRepository repository; // モック化対象オブジェクト

...(中略)...

  LoginModel(this.repository); // モック化対象オブジェクト

...(中略)...

  Future<bool> auth() async {
    print("id: $id, password: $password");

    var results = await repository.auth(); // モック化対象処理

    return results;
  }
}
```

* 状態クラスの利用方法

```dart:lib/ui/login_page.dart
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => LoginModel(
        AuthRepository(), // レポジトリのインスタンスを渡す
      ),
      child: LoginApp(),
    );
  }
}
```

### テストコード

#### Mockライブラリインポート

pubspec.yamlにmockitoを追加し、パッケージをインポート（`dart pub get`）します。

```yaml:./pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

  mockito: ^4.1.4
```

#### Mockの定義

```dart:test/ui/login_page_test.dart
class MockAuthRepository extends Mock implements AuthRepository {}
```

#### Mockの利用

Widgetの初期化時に、mockインスタンスを渡します。

```dart:test/ui/login_page_test.dart
  final mockAuthRepository = MockAuthRepository(); //モックインスタンス生成。テストケースで利用する

  MaterialApp loginApp() {
    return MaterialApp(
      home: ChangeNotifierProvider(
        create: (context) => LoginModel(
          mockAuthRepository, //モックインスタンスを渡す
        ),
        child: LoginApp(),
      ),
    );
  }
```

#### テストケースでの利用方法

##### 振る舞いを定義（変更）する

* 正常系

```dart:test/ui/login_page_test.dart
    testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
        (tester) async {
      await tester.pumpWidget(loginApp());
      // 定義したインスタンス変数を利用する
      // 認証レポジトリのauthメソッドの振る舞いを定義（＝trueを返す）する
      when(mockAuthRepository.auth())
          .thenAnswer((_) => Future.value(true)); // 認証OK

      await tester.enterText(_password, 'password');
      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる

      expect(find.text('入力してください'), findsNothing);
      expect(find.text('パスワードが誤っています'), findsNothing);
      verify(mockAuthRepository.auth()).called(1);
    });
```

* 異常系

```dart:test/ui/login_page_test.dart
    testWidgets('8. ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること',
        (tester) async {
      await tester.pumpWidget(loginApp());
      when(mockAuthRepository.auth())
          .thenAnswer((_) => Future.value(false)); // 認証NG 認証レポジトリのauthメソッドの振る舞いを定義（＝falseを返す）する

      await tester.enterText(_password, 'password');
      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump(Duration(seconds: 1));

      expect(find.text('パスワードが誤っています'), findsOneWidget);
      verify(mockAuthRepository.auth()).called(1);
    });
```

* 例外系

thenThrowメソッドを利用します。

```dart
  final mockAuthRepository = MockAuthRepository();
  when(mockAuthRepository.auth()).thenThrow(
    Exception('api error occurred'),
  );
```

##### メソッドの呼び出し回数の検証

verify（メソッドの呼び出し回数を検証）、verifyNever（メソッドが1度も呼び出されていないことを検証）を利用し、対象のメソッドの呼び出し回数を検証します。

* メソッドが1度も呼び出されていないことを検証する

入力チェックがエラーとなった場合は、認証処理が呼び出されないので、verifyNeverを利用して検証します。

```dart:test/ui/login_page_test.dart
    testWidgets('3. ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
        (tester) async {
      await tester.pumpWidget(loginApp());

      await tester.enterText(_password, 'password');

      await tester.tap(_submitButton);
      await tester.pump();

      final validationErrorMessages = find.text('入力してください');
      expect(validationErrorMessages, findsOneWidget);
      verifyNever(mockAuthRepository.auth()); // 認証処理が1回も呼び出されていないことを検証する
    });
```

* メソッドが呼び出された回数を検証する

入力チェックを通過したあとは、認証処理が呼び出されるので、verifyを利用し、認証処理の呼び出し回数を検証します。

```dart:test/ui/login_page_test.dart
    testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
        (tester) async {
      await tester.pumpWidget(loginApp());
      when(mockAuthRepository.auth())
          .thenAnswer((_) => Future.value(true)); // 認証OK

      await tester.enterText(_password, 'password');
      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる

      expect(find.text('入力してください'), findsNothing);
      expect(find.text('パスワードが誤っています'), findsNothing);
      verify(mockAuthRepository.auth()).called(1); // 認証処理が1回呼び出されていることを検証する
    });
```

## その他

### Widgetの再構築（再描画）

`tester.pump()`を利用します。

* Frame処理を伴わない（テキスト入力など）とき

```dart
await tester.pump();
```

* Frame処理を伴う（SnackBar表示など）とき

```dart
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる
```

### テスト対象のWidgetの初期化

MaterialAppでラップして、Widgetを初期化します。
中身はStatelessWidgetで定義したものを記載します。

```dart:test/ui/login_page_test.dart
  MaterialApp loginApp() {
    return MaterialApp( // MaterialAppでラップ
      home: ChangeNotifierProvider(
        create: (context) => LoginModel(
          mockAuthRepository,
        ),
        child: LoginApp(),
      ),
    );
  }

    testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
      await tester.pumpWidget(loginApp()); // 上記を利用して、Widgetを描画する
      expect(_submitButton, findsOneWidget);
    });
```

### テストのグルーピング

group関数で囲みます。

```dart:test/ui/login_page_test.dart
  group('LoginPage ', () { // テストケースのグルーピング
    testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(_submitButton, findsOneWidget);
    });

...(中略)...

  });
```

### テスト全体で利用する要素（Finder）の定義

テスト全体で利用するFiderの定義。
各テストケースで頻出する要素はテストケースとは別に予め定義しておくと便利です。

```dart:test/ui/login_page_test.dart
  final _id = find.byType(TextFormField).at(0); // ID入力フィールド
  final _password = find.byType(TextFormField).at(1); // パスワード入力フィールド
  final _passwordViewToggle = find.byType(IconButton); // パスワード表示・非表示切り替えアイコン
  final _submitButton = find.text('ログイン'); // ログインボタン
```

## テストコードの全量

```dart:test/ui/login_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_app/repository/auth_repository.dart';
import 'package:flutter_app/ui/login_model.dart';
import 'package:flutter_app/ui/login_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';

RenderEditable findRenderEditable(WidgetTester tester, int index) {
  final RenderObject root =
      tester.renderObject(find.byType(EditableText).at(index));
  expect(root, isNotNull);

  late RenderEditable renderEditable;
  void recursiveFinder(RenderObject child) {
    if (child is RenderEditable) {
      renderEditable = child;
      return;
    }
    child.visitChildren(recursiveFinder);
  }

  root.visitChildren(recursiveFinder);
  expect(renderEditable, isNotNull);
  return renderEditable;
}

class MockAuthRepository extends Mock implements AuthRepository {}

void main() async {
  final mockAuthRepository = MockAuthRepository();

  MaterialApp loginApp() {
    return MaterialApp(
      home: ChangeNotifierProvider(
        create: (context) => LoginModel(
          mockAuthRepository,
        ),
        child: LoginApp(),
      ),
    );
  }

  /// ///////////////////
  /// target elements
  /// //////////////////
  final _id = find.byType(TextFormField).at(0);
  final _password = find.byType(TextFormField).at(1);
  final _passwordViewToggle = find.byType(IconButton);
  final _submitButton = find.text('ログイン');

  group('LoginPage ', () {
    testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(_submitButton, findsOneWidget);
    });

    testWidgets('2. 画面が表示されたとき、入力チェックが動作していないこと', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(find.text('入力してください'), findsNothing);
    });

    testWidgets('3. ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
        (tester) async {
      await tester.pumpWidget(loginApp());

      await tester.enterText(_password, 'password');

      await tester.tap(_submitButton);
      await tester.pump();

      final validationErrorMessages = find.text('入力してください');
      expect(validationErrorMessages, findsOneWidget);
      verifyNever(mockAuthRepository.auth());
    });

    testWidgets('4. パスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
        (tester) async {
      await tester.pumpWidget(loginApp());

      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump();

      final validationErrorMessages = find.text('入力してください');
      expect(validationErrorMessages, findsOneWidget);
      verifyNever(mockAuthRepository.auth());
    });

    testWidgets('5. ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
        (tester) async {
      await tester.pumpWidget(loginApp());

      await tester.tap(_submitButton);
      await tester.pump();

      final validationErrorMessages = find.text('入力してください');
      expect(validationErrorMessages, findsNWidgets(2));
      verifyNever(mockAuthRepository.auth());
    });

    testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
        (tester) async {
      await tester.pumpWidget(loginApp());
      when(mockAuthRepository.auth())
          .thenAnswer((_) => Future.value(true)); // 認証OK

      await tester.enterText(_password, 'password');
      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる

      expect(find.text('入力してください'), findsNothing);
      expect(find.text('パスワードが誤っています'), findsNothing);
      verify(mockAuthRepository.auth()).called(1);
    });

    testWidgets('6. パスワードを入力したとき、入力した文言がマスク(••••)されていること', (tester) async {
      await tester.pumpWidget(loginApp());
      await tester.enterText(_password, 'hoge');
      await tester.pump();

      final String editText = findRenderEditable(tester, 1).text!.text!;
      print(editText);

      expect(editText.substring(editText.length - 1), '\u2022');
    });

    testWidgets(
        '7. パスワードを入力し、パスワード表示アイコンを押したとき、入力した文言のマスクが解除されていること。もう一度パスワードマスクアイコンを押したとき、パスワードがマスクされること',
        (tester) async {
      await tester.pumpWidget(loginApp());
      await tester.enterText(_password, 'hoge');
      await tester.tap(_passwordViewToggle);
      await tester.pump();

      final String editText = findRenderEditable(tester, 1).text!.text!;
      print('unMask: $editText');

      expect(editText, 'hoge');

      await tester.tap(_passwordViewToggle);
      await tester.pump();

      final String editTextAfter = findRenderEditable(tester, 1).text!.text!;
      print('Mask: $editTextAfter');
      expect(editTextAfter.substring(editText.length - 1), '\u2022');
    });

    testWidgets('8. ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること',
        (tester) async {
      await tester.pumpWidget(loginApp());
      when(mockAuthRepository.auth())
          .thenAnswer((_) => Future.value(false)); // 認証NG

      await tester.enterText(_password, 'password');
      await tester.enterText(_id, 'demo');

      await tester.tap(_submitButton);
      await tester.pump(Duration(seconds: 1));

      expect(find.text('パスワードが誤っています'), findsOneWidget);
      verify(mockAuthRepository.auth()).called(1);
    });
  });
}
```

# 終わりに

FlutterのWidget単体テストは、mockitoも利用しやすく、かなり書きやすい印象（jestを利用したコンポーネントテストなどに比べ）です。
実行時間も快速なので、テストコードを書く作業が捗りそうですね。

# 参考

* Widgetテストガイド

https://flutter.dev/docs/cookbook/testing/widget/introduction

* 入力したテキストの表示された文字を取得する

https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/editable_text_utils.dart

* mockito

https://tech.uzabase.com/entry/2020/03/24/182505