🪝

Flutter カスタム Hook をモックし、 Widget のテスト向けに置き換え

2023/12/31に公開

はじめに

Flutter Hooksは React Hooks のようにuseStateuseEffectが Flutter で使用できます。
また、Flutter Hooks で用意されたものだけでなく、カスタム Hook を作ることも可能です。

カスタム Hook を使用することで Widget とロジックを分離でき、見通しのよいコード・Widget になると思いました。
ただカスタム Hook は関数型のため、mockito でモックを自動生成できません。
そのため、Widget 向けのテストでカスタム Hook のモックを用意し、Widget のコンストラクタの引数でカスタム Hook のモックを指定・依存性の注入(DI)すれば、置き換えられると考えました。

環境

  • flutter: 3.16.4
  • flutter_hooks: 0.20.4

カスタム Hook の作成

Widget 向けのテストを書く前に、まずはカスタム Hook を用意します。
flutter createで最初に作成されるカウントアップを例にします。

lib/use_counter.dart

import 'package:flutter_hooks/flutter_hooks.dart';

typedef UseCounterReturn = ({
  int state,
  void Function() increment,
});

typedef UseCounter = UseCounterReturn Function();

UseCounterReturn useCounter() {
  final state = useState(0);
  final increment = useCallback(() {
    state.value++;
  }, [state]);

  return (
    state: state.value,
    increment: increment,
  );
}

lib/my_home_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

import 'use_counter.dart';

class MyHomePage extends HookWidget {
  const MyHomePage({
    super.key,
    required this.title,
  });

  final String title;

  
  Widget build(BuildContext context) {
    final counter = useCounter();

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              key: const Key('counter'),
              counter.state.toString(),
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: const Key('incrementButton'),
        onPressed: counter.increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Widget のテストでカスタム Hook をモックし、置き換え

コンストラクタの引数を追加

カスタム Hook を置き換え・依存性の注入(DI)できるように、コンストラクタに引数を追加します。
引数が指定されてない場合は、デフォルトとしてモックではないカスタム Hook を指定し、テスト以外では指定しないようにしています。

lib/my_home_page.dart 抜粋

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

import 'use_counter.dart' as hook;

class MyHomePage extends HookWidget {
  const MyHomePage({
    super.key,
    required this.title,
    this.useCounter = hook.useCounter,
  });

  final String title;
  final hook.UseCounter useCounter;

  
  Widget build(BuildContext context) {
    final counter = useCounter();
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';


-import 'use_counter.dart';
+import 'use_counter.dart' as hook;

 class MyHomePage extends HookWidget {
   const MyHomePage({
     super.key,
     required this.title,
+    this.useCounter = hook.useCounter,
   });

   final String title;
+  final hook.UseCounter useCounter;

   @override
   Widget build(BuildContext context) {

Widget のテスト

test/my_home_page_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import '../lib/my_home_page.dart';
import '../lib/use_counter.dart';

void main() {
  group('MyHomePage', () {
    testWidgets('counterが表示されること', (WidgetTester tester) async {
      UseCounterReturn useCounterMock() {
        return (
          state: 9,
          increment: () {},
        );
      }

      await tester.pumpWidget(MaterialApp(
          home: MyHomePage(title: 'title', useCounter: useCounterMock)));

      await tester.pumpAndSettle();

      final counterFinder = find.byKey(const Key('counter'));
      expect(counterFinder, findsOneWidget);

      final counterText = counterFinder.evaluate().first.widget as Text;
      expect(counterText.data, '9');
    });

    testWidgets('FABをタップして、incrementが呼ばれること', (WidgetTester tester) async {
      var incrementCalled = false;
      UseCounterReturn useCounterMock() {
        return (
          state: 0,
          increment: () {
            incrementCalled = true;
          },
        );
      }

      await tester.pumpWidget(MaterialApp(
          home: MyHomePage(title: 'title', useCounter: useCounterMock)));

      await tester.pumpAndSettle();

      await tester.tap(find.byKey(const Key('incrementButton')));

      expect(incrementCalled, true);
    });
  });
}

まとめ

Widget 向けのテストでカスタム Hook をモックを用意し、置き換えることができました。
カスタム Hook を使用し Widget とロジックを分離していて、Widget 向けのテストでは分離する方法を考えている方は、Widget のコンストラクタの引数でカスタム Hook のモックを指定する方法はいかがでしょうか。

最後まで読んでいただきありがとうございます。この記事がすこしでもよいと思ったら、Like♥ を押してもらえると励みになります。

Discussion