LoginSignup
5
8

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-15

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

テストする画面

  • 認証OK時の動作

login_ok.gif

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

validation_error.gif

  • 認証NG時の動作

login_error.gif

GitHubレポジトリ

テスト対象画面の解説

テストケース

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

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

テスト実行結果

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

スクリーンショット 2021-03-15 10.16.32.png

要素の存在チェック

公式に記載がある通り、findsNothing(Widgetが存在しないとき)、findsOneWidget(Widgetが一つ存在するとき)、findsNWidgets(Widget数を検証)を利用し、テストを実装していきます。

  • Widgetが存在しないときのテストケース
test/ui/login_page_test.dart
    testWidgets('2. 画面が表示されたとき、入力チェックが動作していないこと', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(find.text('入力してください'), findsNothing); // エラーメッセージが存在しないこと
    });
  • Widgetが1つ存在するときのテストケース
test/ui/login_page_test.dart
  final _submitButton = find.text('ログイン');

    testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
      await tester.pumpWidget(loginApp());
      expect(_submitButton, findsOneWidget); // ログインボタンが1つ存在すること
    });
  • Widgetの数を検証するケース
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本体のレポジトリの方法を参考にしました。
具体的には以下の関数を利用しています。

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

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に表示される文言を取得するケース
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)のプログラムコード

* 状態クラス

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;
  }
}
  • 状態クラスの利用方法
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)します。

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

  mockito: ^4.1.4

Mockの定義

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

Mockの利用

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

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

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

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

振る舞いを定義(変更)する
  • 正常系
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);
    });
  • 異常系
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メソッドを利用します。

  final mockAuthRepository = MockAuthRepository();
  when(mockAuthRepository.auth()).thenThrow(
    Exception('api error occurred'),
  );
メソッドの呼び出し回数の検証

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

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

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

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を利用し、認証処理の呼び出し回数を検証します。

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処理を伴わない(テキスト入力など)とき
await tester.pump();
  • Frame処理を伴う(SnackBar表示など)とき
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる

テスト対象のWidgetの初期化

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

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関数で囲みます。

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

...(中略)...

  });

テスト全体で利用する要素(Finder)の定義

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

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('ログイン'); // ログインボタン

テストコードの全量

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テストガイド

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

  • mockito

5
8
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
5
8