FlutterアプリにHumble Object を導入してテストを実現

はじめに

自己紹介

名前:さくしん
職業:医療系スタートアップにてFlutterエンジニア
趣味:Flutter、旅行、サウナ
技術ブログ: flutter.salon
Udemyの講座: 一覧 TDD クーポンコード:20240607

対象者

  • Flutterでユニットテストの基本が分かっている人
  • HumbleObject等、細かい言葉の設定が気にならない人

10分後の姿

  • モックの検証方法がなんとなく分かる
  • HumbleObjectパターンを使いこなし、ユーザ操作のあるコードのユニットテストが作成できる

ユースケース(なぜ始めたか)

業務にて以下のような仕様が出ました。ちょっと簡略化してます。

  • ダイアログの表示

    • SharedPreferencesに特定のキーの無い場合はダイアログを非表示
    • SharedPreferencesに特定のキーとアプリ内の定数が
      • 同じであれば、ダイアログは非表示
      • 異なっている場合はダイアログを表示する。
  • SharedPreferencesの値の更新

    • ダイアログが表示されて
      • "いいえ"が押された場合、更新しない
      • "はい"が押された場合、更新する

実装自体はそれほど難しくありませんが、実際にそれをテストして正しく動作するか確認するのは面倒です。そこでどういう風にすれば、テストケースを網羅的に確認できるか検討しました

「単体テストの考え方/使い方」P218-219

以前購入した本や t-wadaさんの講演を思い出しました。

プロダクトコードの複雑さの4種類/

ドメインにおける重要性: 協力オブジェクトの数:
少ない 多い
高い ドメインモデル・アルゴリズム 過度に複雑なコード
低い 取るに足らないコード コントローラ

「過度に複雑なコード」からテストしやすい部分を抽出
抽出された部分を包み込む質素(humble)なクラスを作成し、その作成した質素なクラスに対してテストすることが難しい依存を結びつける
(「単体テストの考え方/使い方」P218-219)

テストすることが難しい:ダイアログからのユーザ入力。WidgetTestでユーザ入力を再現できるが、テストが複雑になりやすく、メンテナンス性が劣る。

HumbleObjectパターンで、「ドメインモデル・アルゴリズム」と「コントローラ(Controller/Presenter)」に分割することでユニットテストにできないだろうか。

実装

ベースとなるのはFlutterの標準のカウントアップアプリです。通常はボタンを押すとカウントが増えるだけのシンプルなアプリですが、以下の条件を追加しました:

  • 偶数のときはそのままカウントが増える。
  • 奇数の場合はダイアログが表示される。
  • ダイアログで「はい」を押すと値が増える。「いいえ」や範囲外を押す場合は値を増やさない。

テストを考えずに実装

  void _incrementCounter() async {
    if (_counter % 2 == 1) {
      final result = await showDialog<bool>(
          context: context,
          builder: (context) => SimpleDialog(
                title: Text('足しますか'),
                children: <Widget>[
                  SimpleDialogOption(
                    onPressed: () => Navigator.pop(context, true),
                    child: Text('はい'),
                  ),
                  SimpleDialogOption(
                    onPressed: () => Navigator.pop(context, false),
                    child: Text('いいえ'),
                  ),
                ],
              ));
      if (result != true) {
        return;
      }
    }

    setState(() {
      _counter++;
    });
  }

これが「過度に複雑なコード」になっています。これを「ドメインモデル・アルゴリズム」と「コントローラ(Controller/Presenter)」に分けていきたいと思います。
WidgetTestでも、できなくもないですが、、、

テストしたい項目の検討

テストの要因を洗い出す

  • 現在値はいくつか → 偶数か
  • ダイアログが表示されるか
  • ダイアログで「はい」が押されたか。「いいえ」か枠外を押して閉じられたか
  • 足す処理が実施されるか

最終的には、テストが不要の「取るに足らないコード」に落とし込む。

abstract interface class HumbleView {
  /// 偶数か
  bool isEven();
  Future<bool?> askQuestion();
  void increase();
}

Presenter内でHumbleObjectの動作を定義する。

class Presenter {
  Future<void> process(HumbleView view) async {
    if (view.isEven()) {
      view.increase();
      return;
    }

    final result = await view.askQuestion();
    if (result == true) {
      view.increase();
    }
  }
}

テストを作成

Mockitoを使用して、モックを作成。
Mockitoの詳細については、以下を参照

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

MockHumbleView:Mockitoが自動生成するモッククラス。
モッククラス:メソッドの戻り値を設定したり、例外を発生させてみたり、呼ばれた回数を記録したりできる。

例:
when(mock.isEven()).thenReturn(false): 同期処理の戻り値を設定
when(mock.askQuestion()).thenAnswer((_) async => true) : Future型の戻り値を設定
verify(mock.askQuestion()).called(1): 1回実行されたことを検証
verifyNever(mock.increase()): 実行されてないことを検証

final mock = MockHumbleView();
final target = Presenter();

test('奇数は質問する', () async {
  when(mock.isEven()).thenReturn(false);
  await target.process(mock);

  verify(mock.askQuestion()).called(1);
  verifyNever(mock.increase());
});

HumbleViewをStateに組み込む

StateにHumbleView をインプリメントします。そうすることで HumbleViewが実装されているので、presenter.process(this)として、thisHumbleViewとして、processに渡すことができるようになります。

class _MyHomePageState extends State<MyHomePage> implements HumbleView {
  final presenter = Presenter();
  
  (中略)
        floatingActionButton: FloatingActionButton(
        onPressed: () => presenter.process(this),

HumbleView の実装をしないといけません。

  @override
  Future<bool?> askQuestion() {
    return showDialog<bool>(
        context: context,
        builder: (context) => SimpleDialog(
              title: Text('足しますか'),
              children: <Widget>[
                SimpleDialogOption(
                  onPressed: () => Navigator.pop(context, true),
                  child: Text('はい'),
                ),
                SimpleDialogOption(
                  onPressed: () => Navigator.pop(context, false),
                  child: Text('いいえ'),
                ),
              ],
            ));
  }

  @override
  void increase() {
    setState(() {
      _counter++;
    });
  }

  @override
  bool isEven() {
    return _counter % 2 == 0;
  }

テストの必要がない程度に簡単なコードかと思います。

まとめ

HumbleObjectパターンを使用して、ユーザ入力をユニットテストする方法を学びました。
次回テスト作成時に思い出して、参考にしていただけると幸いです!

ソースコード

main.dart(プロダクトコード)

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> implements HumbleView {
  int _counter = 0;

  final presenter = Presenter();

  void _incrementCounter() async {
    if (_counter % 2 == 1) {
      final result = await showDialog<bool>(
          context: context,
          builder: (context) => SimpleDialog(
                title: Text('足しますか'),
                children: <Widget>[
                  SimpleDialogOption(
                    onPressed: () => Navigator.pop(context, true),
                    child: Text('はい'),
                  ),
                  SimpleDialogOption(
                    onPressed: () => Navigator.pop(context, false),
                    child: Text('いいえ'),
                  ),
                ],
              ));
      if (result != true) {
        return;
      }
    }

    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.bodyMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => presenter.process(this),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

  @override
  Future<bool?> askQuestion() {
    return showDialog<bool>(
        context: context,
        builder: (context) => SimpleDialog(
              title: Text('足しますか'),
              children: <Widget>[
                SimpleDialogOption(
                  onPressed: () => Navigator.pop(context, true),
                  child: Text('はい'),
                ),
                SimpleDialogOption(
                  onPressed: () => Navigator.pop(context, false),
                  child: Text('いいえ'),
                ),
              ],
            ));
  }

  @override
  void increase() {
    setState(() {
      _counter++;
    });
  }

  @override
  bool isEven() {
    return _counter % 2 == 0;
  }
}

abstract interface class HumbleView {
  /// 偶数か
  bool isEven();
  Future<bool?> askQuestion();
  void increase();
}

class Presenter {
  Future<void> process(HumbleView view) async {
    if (view.isEven()) {
      view.increase();
      return;
    }

    final result = await view.askQuestion();
    if (result == true) {
      view.increase();
    }
  }
}

main_test.dart(テストコード)

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

import 'main_countup_humble_object_test.mocks.dart';

@GenerateNiceMocks([
  MockSpec<HumbleView>(),
])
void main() {
  final mock = MockHumbleView();
  final target = Presenter();

  group('現在の数', () {
    test('奇数は質問する', () async {
      when(mock.isEven()).thenReturn(false);
      await target.process(mock);

      verify(mock.askQuestion()).called(1);
      verifyNever(mock.increase());
    });
    test('偶数はそのまま増やす', () async {
      when(mock.isEven()).thenReturn(true);
      await target.process(mock);

      verifyNever(mock.askQuestion());
      verify(mock.increase()).called(1);
    });
  });

  group('質問の回答', () {
    setUp(() => when(mock.isEven()).thenReturn(false));

    test('はい', () async {
      when(mock.askQuestion()).thenAnswer((_) async => true);

      await target.process(mock);
      verify(mock.increase()).called(1);
    });
    test('いいえ', () async {
      when(mock.askQuestion()).thenAnswer((_) async => false);

      await target.process(mock);
      verifyNever(mock.increase());
    });
    test('外枠', () async {
      when(mock.askQuestion()).thenAnswer((_) async => null);

      await target.process(mock);
      verifyNever(mock.increase());
    });
  });
}