【Flutter】ユーザの入力がある画面をテスト駆動型開発する

  • 2024年4月10日
  • test

対象者

  • Flutterのテスト方法を検討している人
  • mockitoによるモックテストの基本は分かったが、実際どう使えばいいか分からない人
  • ユーザの入力に対してもTDDで立ち向かいたい人

はじめに

最近、開発中のアプリに新しい機能を追加しました。この機能では、ユーザーがダイアログを通じて操作を進めるかどうかを選択し、その後に入力値を設定します。キャンセルされず、入力値が存在する場合には、アプリのデータが更新されるという流れになっています。

このような機能のテストを手動で何度も行うのは非効率的です。そこで、自動テストを導入してこのプロセスを簡略化したいと考えました。特に、Flutterにおいてユーザーとのインタラクションが発生する場合に、自動テストをどのように実装すれば良いかについて検証してみました。

この検証内容をベースにして、開発中のアプリにテスト可能な機能を実装できました。

TDDについてはこちらの講座をお願いします。

検証用アプリ(プロダクトコード)

検証に作成したアプリは非常にシンプルな構成です。アプリのボタンを押すとダイアログが表示され、ユーザーは「はい」または「いいえ」のボタンを選択できます。もし「はい」を選択した場合、アプリは挨拶のメッセージを出力します。この挨拶はコンソールに表示されるため、画面上には表示されません。

GreetingLogic

GreetingLogicは抽象クラスとして定義されており、挨拶をするかどうかを問うaskQuestionメソッドと、実際に挨拶を行うsayHelloメソッドの2つを持っています。この抽象クラスは、具体的な実装をStatefulWidgetで行い、テスト時にはモックとして実装することで利用します。

abstract class GreetingLogic {
  Future<bool> askQuestion();
  void sayHello();
}

GreetingUseCase

GreetingUseCaseクラスでは、GreetingLogicを使用して、ユーザーの回答に応じて挨拶をするかどうかのロジックを定義しています。ユーザーが「はい」を選択した場合には挨拶を行い、「いいえ」を選択した場合には何もしません。
このクラスは、テストケースで使用しますのでモックできません。(モックすると内部のコードが動作がなくなるので)
また、ユースケースというネーミングは間違えている気がする。元のプロジェクト名を引きずってますので、大目に見てください。

class GreetingUseCase {
  Future<void> greeting(GreetingLogic logic) async {
    if (await logic.askQuestion()) {
      // テスト不可能
      sayHello();

      // テスト可能
      logic.sayHello();
    }
  }

  // モックにできないから動作されたか、テストできない。
  void sayHello() {
    print('hello from useCase(テスト不可)');
  }
}

MyHomePage

MyHomePageクラスは、(ご存じの通り)アプリケーションのメイン画面です。

build

アプリバーと中央に配置されたボタンを含むシンプルなレイアウトを設定しています。ボタンが押されると、GreetingUseCaseを通じてダイアログが表示され、ユーザーに挨拶するかどうかを尋ねます。

  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () {
            GreetingUseCase().greeting(this);
          },
          child: const Text('Show Dialog'),
        ),
      ),
    );

askQuestion() と sayHello()

ダイアログを表示してユーザーの回答を取得するための非同期メソッドです。ここでは、showDialog関数を使ってシンプルなダイアログを表示し、ユーザーが「はい」または「いいえ」を選択できるようにしています。選択結果に応じて、真偽値を返します。
「はい」を答えるとユースケース側からsayHelloが呼ばれるので、hello from stateが出力されます。

  @override
  Future<bool> askQuestion() async {
    final answer = await showDialog<bool>(
        context: context,
        builder: (context) => SimpleDialog(
              title: Text('挨拶しますか'),
              children: [
                FilledButton(
                    onPressed: () => Navigator.of(context).pop(true),
                    child: Text('はい')),
                FilledButton(
                    onPressed: () => Navigator.of(context).pop(),
                    child: Text('いいえ')),
              ],
            ));
    return answer == true;
  }

  @override
  void sayHello() {
    print('hello from state');
  }

テストコード

Mockitoについて

テストコードでは、mockitoパッケージを使ってGreetingLogicのモックを作成しています。アノテーションで作成するクラスを指定(@GenerateNiceMocks([MockSpec<GreetingLogic>()]))して、build_runnerを走らせると、モッククラスが作られます。
Mockitoの使い方については、以下の記事を参考にしてください。

【Flutter】DIとMock を使ってWEB APIをテストする + mockito チートシート コード付

これにより、実際のダイアログ表示やユーザー入力を使用せずに、GreetingUseCaseのテストを行うことができます。
確認に以下のメソッドを使用しています。

  • when().thenAnswer(): 指定のメソッドの戻り値を設定する(Future型用。Future型以外には、thenResultを使う)
  • verifyNever() :指定のメソッドが未実施であることを検証
  • verify().called(1): 指定のメソッドが1度実施されたことを検証
  test('mockitoのテスト', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => true);
    expect(await logic.askQuestion(), true);

    verifyNever(logic.sayHello());
    logic.sayHello();
    verify(logic.sayHello()).called(1);
  });

「はい」と答えたケース

このテストケースでは、モックを使ってaskQuestionメソッドがtrueを返すように設定しています。これにより、ユーザーが「はい」と答えた場合の挙動をテストしています。期待通りにsayHelloメソッドが呼び出されることを確認(verify(logic.sayHello()).called(1))することで、正しく挨拶が行われることを検証しています。

  test('はいと答えた', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => true);

    final target = GreetingUseCase();
    await target.greeting(logic);
    verify(logic.sayHello()).called(1);
  });

「いいえ」と答えたケース

このテストケースでは、askQuestionメソッドがfalseを返すように設定しています。これにより、ユーザーが「いいえ」と答えた場合の挙動をテストしています。このケースでは、sayHelloメソッドが呼び出されないことを確認することで、不要な挨拶が行われないことを検証(verifyNever(logic.sayHello()))しています。

  test('いいえと答えた', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => false);

    final target = GreetingUseCase();
    await target.greeting(logic);
    verifyNever(logic.sayHello());
  });

気付き

  • ロジックとユースケースをそれぞれ作成する。ユーザのダイアログの回答など自動テスト内で実施不可能なことと、それに実施するかかが変わることをロジック側にまとめる。
  • ロジックについては、StatefuleWidgetのStateにインプリメントする。ユースケースにStateを渡すことで、ユースケース内でダイアログの回答による分岐を扱うことができる
  • テストではユースケースにモックのロジックを渡すことでテスト内で「ダイアログの回答」に応じた分岐をテストすることができる
  • ロジック内のメソッドはモックすることで、メソッドにテスト用の戻り値を設定したり、実施回数などを検証したりできる
  • ユースケース内にメソッドは、ユースケース自体をモックするわけではないので、実施回数の検証はできない

書籍「単体テストの使い方/考え方」より

上記の書籍の「7.1.2 質素なオブジェクト(Humble Object)を用いた過度に複雑なコードの分割」に「過度に複雑なコードからテストを行いやすい部分を抽出(中略)抽出された部分を包み込む質素(humble)なクラスを作成し、その作成した質素なクラスに対してテストすることが難しい依存を結びつける」とあります。
ダイアログのよる回答が「テストすることが難しい」部分にあたり、ここをロジックにまとめました。「テストを行いやすい部分」をユースケースにまとめました。
まあ、なんといいますか、この本、日本語が難しく、理解し切れている気はしませんが、こういうことなんだと思ってます、、また読み直さないと、、、

まとめ

Flutterでユーザからのダイアログからの入力に対して、どのようにテストをするか、について検証しました。この方法で開発中のアプリに対して対応できました。1年前に読んだ「単体テストの使い方/考え方」の「過度に複雑なコードの分割」を実際に取り入れることができ、ようやく買った甲斐ができました。
まだ1カ所なので、応用してテストカバレッジをあげていきたいと思います。

参考

ソース

プロダクトコード lib/main.dart

import 'package:flutter/material.dart';

/// ダイアログを表示するロジック
abstract class GreetingLogic {
  Future<bool> askQuestion();
  void sayHello();
}

class GreetingUseCase {
  Future<void> greeting(GreetingLogic logic) async {
    if (await logic.askQuestion()) {
      // テスト不可能
      sayHello();

      // テスト可能
      logic.sayHello();
    }
  }

  // モックにできないから動作されたか、テストできない。
  void sayHello() {
    print('hello from useCase(テスト不可)');
  }
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> implements GreetingLogic {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () {
            GreetingUseCase().greeting(this);
          },
          child: const Text('Show Dialog'),
        ),
      ),
    );
  }

  @override
  Future<bool> askQuestion() async {
    final answer = await showDialog<bool>(
        context: context,
        builder: (context) => SimpleDialog(
              title: Text('挨拶しますか'),
              children: [
                FilledButton(
                    onPressed: () => Navigator.of(context).pop(true),
                    child: Text('はい')),
                FilledButton(
                    onPressed: () => Navigator.of(context).pop(),
                    child: Text('いいえ')),
              ],
            ));
    return answer == true;
  }

  @override
  void sayHello() {
    print('hello from state');
  }
}

テストコード test/main_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'main.dart';
import 'main_test.mocks.dart';


@GenerateNiceMocks([
  MockSpec<GreetingLogic>(),
])
main() {
  test('mockitoのテスト', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => true);
    expect(await logic.askQuestion(), true);

    verifyNever(logic.sayHello());
    logic.sayHello();
    verify(logic.sayHello()).called(1);
    logic.sayHello();
    verify(logic.sayHello()).called(1);
  });

  test('はいと答えた', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => true);

    final target = GreetingUseCase();
    await target.greeting(logic);
    verify(logic.sayHello()).called(1);
  });

  test('いいえと答えた', () async {
    final logic = MockDialogLogic();
    when(logic.askQuestion()).thenAnswer((_) async => false);

    final target = GreetingUseCase();
    await target.greeting(logic);
    verifyNever(logic.sayHello());
  });
}