FlutterでRiverpodを用いた画面のWidget Testやってみた

2024.05.07

こんにちは。
モダンアプリコンサル部の坂本です。

前回前々回とFlutterでのUnitTestについて投稿してきました。
今回は前回までに製造したログイン機能に画面を追加し、Widgetのテストを実施してみたいと思います。

画面の製造

UIはそこまで拘らず、しかしある程度実際のプロジェクトで使いそうな部分を踏襲することを目標に製造してみます。

見た目はこのようになりました。

シンプルな見た目ですが、必要最低限のパーツは揃っているのではないかと思います。

続いてソースコードです。

login_page.dart

class LoginPage extends StatefulHookConsumerWidget {
  const LoginPage({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() =>
      _LoginPageState();
}

class _LoginPageState extends ConsumerState<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  bool _isObscure = true;

  final _halfOnlyFormatter =
      FilteringTextInputFormatter.allow(RegExp(r'^[ -~]+$'));

  @override
  Widget build(BuildContext context) {
    ref.listen(loginUserControllerProvider, (previous, next) {
      if (next != null) {
        Navigator.pushReplacement(
          context,
          MaterialPageRoute(builder: (context) => const HomePage()),
        );
      }
    });

    final loginId = useTextEditingController();
    final password = useTextEditingController();

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("ログイン"),
      ),
      body: HookConsumer(builder: (ctx, ref, child) {
        final state = ref.watch(loginControllerProvider);
        final controller = ref.read(loginControllerProvider.notifier);

        return Stack(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 30),
              child: Form(
                key: _formKey,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Container(
                      decoration: const BoxDecoration(
                        shape: BoxShape.circle,
                        color: Colors.grey,
                      ),
                      height: 90,
                      width: 90,
                      child: const Icon(
                        Icons.person,
                        color: Colors.white,
                        size: 80,
                      ),
                    ),
                    const Gap(20),
                    TextFormField(
                      key: const Key("login_id_form_field"),
                      controller: loginId,
                      enabled: !state.isLoading,
                      autovalidateMode: AutovalidateMode.always,
                      validator: (value) =>
                        value == null || value.isEmpty ?
                        "ログインIDを入力してください" : null,
                      onTapOutside: (_) =>
                          FocusManager.instance.primaryFocus?.unfocus(),
                      onEditingComplete: () =>
                          FocusManager.instance.primaryFocus?.unfocus(),
                      inputFormatters: [
                        _halfOnlyFormatter,
                      ],
                      decoration: const InputDecoration(
                        label: Text("ログインID"),
                      ),
                    ),
                    const Gap(20),
                    TextFormField(
                      key: const Key("password_form_field"),
                      controller: password,
                      enabled: !state.isLoading,
                      obscureText: _isObscure,
                      autovalidateMode: AutovalidateMode.always,
                      validator: (value) =>
                        value == null || value.isEmpty ?
                        "パスワードを入力してください" : null,
                      onTapOutside: (_) =>
                          FocusManager.instance.primaryFocus?.unfocus(),
                      onEditingComplete: () =>
                          FocusManager.instance.primaryFocus?.unfocus(),
                      inputFormatters: [
                        _halfOnlyFormatter,
                      ],
                      decoration: InputDecoration(
                        label: const Text("パスワード"),
                        suffixIcon: IconButton(
                          icon: Icon(
                            _isObscure ?
                              Icons.visibility_off : Icons.visibility,
                            color: Colors.grey,
                          ),
                          onPressed: () =>
                              setState(() => _isObscure = !_isObscure),
                        ),
                      ),
                    ),
                    const Gap(20),
                    TextButton(
                      onPressed: state.isLoading ?
                      null : () async {
                        if (_formKey.currentState!.validate()) {
                          controller.login(
                            loginId: loginId.text,
                            password: password.text,
                          );
                        }
                      },
                      child: const Text("ログイン"),
                    ),
                    const Gap(20),
                    Text(
                      key: const Key("login_error_message"),
                      state.error.errorMessage ?? "",
                      style: const TextStyle(
                        color: Colors.red,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
              ),
            ),
            if (state.isLoading)
              const Center(
                child: CircularProgressIndicator(),
              ),
          ],
        );
      }),
    );
  }
}

少々長いですが、パスワードのマスクの切り替え、入力フィールドの検証、ログイン処理時のインジケータの表示など、ログイン画面を製造する際に使いそうな機能を盛り込んでみました。

長くなるので詳細は割愛しますが、LoginControllerでログインに関する処理を提供し、LoginUserControllerでログインユーザーの状態(未ログイン、ログイン済)を管理、LoginPageLoginControllerからログイン処理を呼び出し、LoginUserControllerにユーザーのインスタンスが流れてくると画面遷移、という流れとしています。  

テストする

製造した画面のテストコードを書いていきます。

まずは初期状態のテストから。

login_page_test.dart

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  // 画面タイトル
  final titleText = find.widgetWithText(AppBar, "ログイン");
  // ログインID
  final loginIdFormField = find.byKey(const Key("login_id_form_field"));
  final loginIdValidateMessage = find.descendant(
    of: loginIdFormField,
    matching: find.text("ログインIDを入力してください"),
  );
  // パスワード
  final passwordFormField = find.byKey(const Key("password_form_field"));
  final passwordValidateMessage = find.descendant(
    of: passwordFormField,
    matching: find.text("パスワードを入力してください"),
  );
  // パスワード入力フィールド
  // TextFormFieldはTextFieldを子要素として持っている
  // obscureTextはTextFieldのプロパティのため、TextFieldを取得する
  final passwordTextField = find.descendant(
    of: passwordFormField,
    matching: find.byType(TextField),
  );
  // パスワードマスクボタン
  final obscureButton = find.descendant(
    of: passwordFormField,
    matching: find.byType(IconButton),
  );
  // パスワードマスクアイコン
  final obscureIcon = find.descendant(
    of: obscureButton,
    matching: find.byType(Icon),
  );
  // ログインボタン
  final loginButton = find.widgetWithText(TextButton, "ログイン");
  // エラーメッセージ
  final errorMessageText = find.byKey(const Key("login_error_message"));

  group("LoginPage", () {
    testWidgets("initial ui", (tester) async {
      final container = ProviderContainer(
        overrides: [
          loginControllerProvider.overrideWith(() => MockLoginController()),
          loginUserControllerProvider.overrideWith(() => MockLoginUserController()),
        ],
      );
      addTearDown(container.dispose);

      await tester.pumpWidget(
        UncontrolledProviderScope(
          container: container,
          child: const MaterialApp(
            home: LoginPage(),
          ),
        ),
      );

      // アイコン画像
      expect(find.byIcon(Icons.person), findsOneWidget);
      // 画面タイトル
      expect(titleText, findsOneWidget);
      // ログインID
      expect(loginIdFormField, findsOneWidget);
      expect(loginIdValidateMessage, findsOneWidget);
      // パスワード
      expect(passwordFormField, findsOneWidget);
      expect(passwordTextField, findsOneWidget);
      expect(passwordValidateMessage, findsOneWidget);
      // パスワードマスクアイコン
      expect(obscureButton, findsOneWidget);
      expect(obscureIcon, findsOneWidget);
      // パスワードのマスクの状態、アイコンの状態を確認
      expect(tester.widget<TextField>(passwordTextField).obscureText, isTrue);
      expect(tester.widget<Icon>(obscureIcon).icon, Icons.visibility_off);
      // ログインボタン
      expect(loginButton, findsOneWidget);
      // エラーメッセージ
      expect(errorMessageText, findsOneWidget);
      expect(tester.widget<Text>(errorMessageText).data, isEmpty);
    });
  });
}

上記では、画面内の各Widgetを検索し、描画されているかや状態が適切かを評価しています。
テストコードについていくつかポイントがあるので説明していきます。

CommonFinders

まず最初にfindを用いた見慣れない構文が目に入ります。

final titleText = find.widgetWithText(AppBar, "ログイン");

これはFinderと呼ばれるWidgetの要素ツリーから特定の要素を検索してくれるクラスをインスタンス化している部分で、CommonFindersと呼ばれる条件に応じたFinderクラスを提供してくれるクラスを用いています。

CommonFindersはpackage:flutter_test/flutter_test.dartをimportすることで使用できるようになります。

条件の指定方法は複数ありますので、特定したいWidgetの特性に合わせてこちらの記事などを参考にしてみてください。

Finderクラスはインスタンスの生成時点で検索を実施するわけではないので、よく使うWidgetのFinderは予め定義しておくことで使い回すことができます。

testWidgets

これまでのUnitTestとの違いとして、テストコードはtestではなくtestWidgetsに記述しています。

testWidgetsはWidgetTesterを引き渡してくれる点がtestメソッドと異なっており、WidgetテストではこのWidgetTesterを用いてWidgetの描画や操作を実施していくことになります。

上記テストではWidgetTester.pumpWidgetを用いてLoginPageを描画するところからスタートしています。
今回はRiverpodを使用しているため、UncontrolledProviderScopeを用いてWidgetツリーにProviderContainerを公開するよう構成しています。

await tester.pumpWidget(
  UncontrolledProviderScope(
    container: container,
    child: const MaterialApp(
      home: LoginPage(),
    ),
  ),
);

またWidgetTesterはFinderで検索したWidgetを取得することもできるので、Widgetの状態を直接確認するためにも使用できます。

expect(tester.widget<TextField>(passwordTextField).obscureText, isTrue);

イベントの発火

画面はユーザーの操作によって状態が遷移する場合があります。
これらのユーザー操作によるイベントの試験もWidgetTesterを用いることで実施することができます。

login_page_test.dart

void main() {
  ...

  group("LoginPage", () {
    ...

    testWidgets("password obscureText change", (tester) async {
      final container = ProviderContainer(
        overrides: [
          loginControllerProvider.overrideWith(() => MockLoginController()),
          loginUserControllerProvider.overrideWith(() => MockLoginUserController()),
        ],
      );
      addTearDown(container.dispose);

      await tester.pumpWidget(
        UncontrolledProviderScope(
          container: container,
          child: const MaterialApp(
            home: LoginPage(),
          ),
        ),
      );

      // マスクoff
      await tester.tap(obscureButton);
      await tester.pump();

      expect(tester.widget<TextField>(passwordTextField).obscureText, isFalse);
      expect(tester.widget<Icon>(obscureIcon).icon, Icons.visibility);
    });

    testWidgets("loginId validate error", (tester) async {
      final loginController = MockLoginController();

      final container = ProviderContainer(
        overrides: [
          loginControllerProvider.overrideWith(() => loginController),
          loginUserControllerProvider.overrideWith(() => MockLoginUserController()),
        ],
      );
      addTearDown(container.dispose);

      await tester.pumpWidget(
        UncontrolledProviderScope(
          container: container,
          child: const MaterialApp(
            home: LoginPage(),
          ),
        ),
      );

      await tester.enterText(passwordFormField, "8peJuzJ*naBd");
      await tester.tap(loginButton);
      await tester.pump();

      // loginIdの検証エラーが表示されており、passwordの検証エラーが表示されていない
      expect(loginIdValidateMessage, findsOneWidget);
      expect(passwordValidateMessage, findsNothing);

      // login処理が呼び出されていない
      verifyNever(
        loginController.login(
          loginId: anyNamed("loginId"),
          password: anyNamed("password"),
        ),
      );
    });

    ...
  });
}

上記ではパスワード入力フィールドのマスクのON/OFFの切り替えのテストと、loginId入力フィールドのみに値を設定しないことで入力検証のエラーが表示されているかを評価しています。
WidgetTester.enterTextでテキストフィールドへの文字列入力を、WidgetTester.tapでボタンのタップを実施しています。

await tester.enterText(passwordFormField, "8peJuzJ*naBd");
await tester.tap(loginButton);

評価前にイベント発火後の状態に描画更新する必要があり、WidgetTester.pumpで行うことができます。

await tester.pump();

これにより実際のアプリを操作した場合と同じUIを評価することができるようになります。

まとめ

いかがでしたでしょうか。
WidgetテストではWidgetTesterを用いることでユーザーの操作を模倣し、Finderを用いてWidgetの検索を実施するということがわかりました。
実際のプロジェクトではコストやスケジュールからWidgetのテストを記述する機会は多くないかもしれません。
しかし状態遷移が複雑なWidgetではWidgetテストを実施することで人力での試験より詳細なテストが実施できるケースもあるのではないかと思います。
今後は小さいところからWidgetテストも記述していきたいと思いました。

おまけ

今回のブログへ向けての実装時に、RiverpodのNotifierクラスのモックをMockitoで自動生成したところ、テスト実施時に以下のようなエラーが発生しました。

Class 'MockLoginController' has no instance method '_setElement'.
Receiver: Instance of 'MockLoginController'
Tried calling: _setElement(Instance of 'AutoDisposeAsyncNotifierProviderElement<LoginController,
void>')

エラーの内容としては_setElementメソッドがインスタンスに存在しないというエラーのようで、色々調べてみたところMockitoで自動生成したMockのクラスは元のクラスをimplementsするようになっていますが、publicなメソッドのみしか実装されないことが原因のようでした。

今回はモック対象のクラスが継承しているNotifierクラスを直接継承し、MockクラスをMixinする形でモックを独自実装することで回避しています。

mock_login_controller.dart

class MockLoginController extends AutoDisposeAsyncNotifier<void> with Mock implements LoginController {
  @override
  FutureOr<void> build() =>
      (super.noSuchMethod(
        Invocation.method(
          #build,
          [],
        ),
        returnValue: null,
        returnValueForMissingStub: null,
      ) as FutureOr<void>);

  @override
  Future<void> login({required String? loginId, required String? password}) =>
      (super.noSuchMethod(
        Invocation.method(
          #login,
          [],
          {
            #loginId: loginId,
            #password: password,
          },
        ),
        returnValue: Future<void>.value(),
        returnValueForMissingStub: Future<void>.value(),
      ) as Future<void>);
}

参考