FlutterでMockitoを使って単体テストやってみた

2024.04.08

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

モダンアプリコンサル部として(ジョインブログ以外で)の初投稿です。
元々スマホアプリの開発経験を積んできたので、Flutterでの単体テストについて向き合ってみます。

今回は公式で紹介されているMockitoを使用しつつ、Flutterでの依存関係のある構造の簡単なテストを実施し、解説してみたいと思います。

はじめに

実際のプロジェクトでFlutterを用いた開発をする際、ControllerやViewModelなどのWidgetの状態を管理・操作するレイヤーと、データの読み書きを抽象化するRepositoryレイヤーを用いることが多いのではないでしょうか。

こういった構成を取る場合、ControllerやViewModelはRepositoryに依存することも多く、依存関係のあるクラスのテストコードを記述するとき依存オブジェクトをモックする必要が出てきます。

Mockitoでは依存オブジェクトを簡単にモックでき、依存関係のテストを簡潔に記述できます。
今回は簡単なログイン機能を題材に、Mockitoを用いたテストコードを記述してみます。

auth_repository.dart

class AuthRepository {
  Future login({required String loginId, required String password}) async {
    // WebAPIのレスポンスを待ってる風
    await Future.delayed(const Duration(microseconds: 500));

    if (loginId == "sakamoto@example.com" && password == "%!vTdkQ#wJ7|") {
      return const User(loginId: "sakamoto@example.com", name: "坂本 勇人");
    } else {
      throw Exception("ログインに失敗しました。");
    }
  }
}

login_controller.dart

class LoginController {
  final AuthRepository _authRepository;

  const LoginController(this._authRepository);

  Future login({required String loginId, required String password}) async {
    // LoginId、Passwordの入力チェック
    if (loginId.isNotEmpty && password.isNotEmpty) {
      return await _authRepository.login(loginId: loginId, password: password);
    } else {
      throw Exception("ログインID、パスワードは必須です");
    }
  }
}

user.dart

class User {
  final String loginId;
  final String name;

  const User({required this.loginId, required this.name});
}

書いてみる

上記のような構成でLoginControllerをテストする場合、依存しているAuthRepositoryをモックする必要があります。

ここでMockitoを使っていきます。

実際のテストコードが以下です。

login_controller_test.dart

@GenerateNiceMocks([MockSpec<AuthRepository>()])
void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  final authRepository = MockAuthRepository();

  setUp(() {
    reset(authRepository);
  });

  group("login", () {
    test("success", () async {
      final controller = LoginController(authRepository);

      when(
        authRepository.login(
          loginId: argThat(equals("tanaka@example.com"), named: "loginId"),
          password: argThat(equals("8peJuzJ*naBd"), named: "password"),
        ),
      ).thenAnswer((_) async => const User(
        loginId: "tanaka@example.com",
        name: "田中 太郎",
      ));

      expect(
        await controller.login(
          loginId: "tanaka@example.com",
          password: "8peJuzJ*naBd",
        ),
        isA()
          .having((p0) => p0.loginId, "loginId", "tanaka@example.com")
          .having((p0) => p0.name, "name", "田中 太郎"),
      );
      verify(
        authRepository.login(
          loginId: "tanaka@example.com",
          password: "8peJuzJ*naBd",
        ),
      ).called(1);
    });

    test("loginId empty", () async {
      final controller = LoginController(authRepository);

      await expectLater(
        controller.login(loginId: "", password: "8peJuzJ*naBd"),
        throwsA(isA()),
      );
      verifyNever(
        authRepository.login(
          loginId: anyNamed("loginId"),
          password: anyNamed("password"),
        )
      );
    });

    test("password empty", () async {
      final controller = LoginController(authRepository);

      await expectLater(
        controller.login(loginId: "tanaka@example.com", password: ""),
        throwsA(isA()),
      );
      verifyNever(
        authRepository.login(
          loginId: anyNamed("loginId"),
          password: anyNamed("password"),
        ),
      );
    });

    test("authRepository throws exception", () async {
      final controller = LoginController(authRepository);

      when(
        authRepository.login(
          loginId: anyNamed("loginId"),
          password: anyNamed("password"),
        ),
      ).thenThrow(Exception("予期せぬエラーが発生しました。"));

      await expectLater(
        controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd"),
        throwsA(isA()),
      );
      verify(
        authRepository.login(
          loginId: anyNamed("loginId"),
          password: anyNamed("password"),
        ),
      ).called(1);
    });
  });
}

モック生成には、推奨されているGenerateNiceMocksを使用しています。

他にGenerateMocksがありますが、GenerateNiceMocksとはスタブ化されていないメソッド呼び出しに対する挙動が異なり、GenerateMocksだと例外をthrowするのに対し、GenerateNiceMocksだと単純な正当値を返却します。
null0、Fakeオブジェクトなどです)

ただしこの返却値は値を参照されることを前提としていないため、テスト時の値の評価に使用したり依存オブジェクトから参照されることがないようにする必要があります。

使用が想定されるケースではスタブを定義して評価するようにしましょう。

簡単な説明

Mockito使用箇所について簡単な説明をしていきます。

モックの宣言

冒頭の宣言が生成するモックを指定する宣言になります。

@GenerateNiceMocks([MockSpec<AuthRepository>()])

ここで宣言したクラスはflutter pub run build_runner buildなどでbuild_runnerを走らせることでモックが生成されます。
生成されたモックは以下のように「Mock + クラス名」の形で利用することができます。

final authRepository = MockAuthRepository();

スタブの生成

when(
  authRepository.login(
    loginId: argThat(equals("tanaka@example.com"), named: "loginId"),
    password: argThat(equals("8peJuzJ*naBd"), named: "password"),
  ),
).thenAnswer((_) async => const User(
  loginId: "tanaka@example.com",
  name: "田中 太郎",
));

whenMockAuthRepositoryloginへのスタブ生成を宣言しています。

MockitoではargThatで引数を条件としたスタブを生成することができ、ここでは引数としてloginIdtanaka@example.compassword8peJuzJ*naBdが指定されている場合のみに適用されるスタブを宣言しています。

条件が不要であればany(名前付き引数であればanyNamed)で引数に関わらずスタブを宣言することもできます。

続いてthenAnswerでスタブ内の処理を定義しています。
上記は非同期処理のためthenAnswerを使用していますが、固定の戻り値を宣言する場合にはthenReturn、例外をthrowする場合はthenThrowを利用します。

依存関係の評価

以下の記述でLoginControllerが依存するAuthRepositoryに対する評価を実施しています。

verify(authRepository.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd"))
  .called(1);

verifyで評価対象を宣言し、calledで呼び出し回数を評価しています。

上記の記述ではLoginControllerからauthRepository.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd")が1度呼び出されているかを評価していますが、呼び出し回数は固定値だけではなく、greaterThanlessThanのようなmatcherも利用することができます。

「呼び出しされていないこと」を評価する場合、verify(...).called(0)としたくなるところですが、verifyNeverを使用しないとTest Failedとなる点注意が必要です。

モックのリセット

reset(authRepository);

こちらの記述によってモックのスタブや呼び出し回数などのインタラクションをリセットできます。

テスト対象が依存するオブジェクトのモックをテスト毎に生成しないケースでは、setUpにこちらを宣言しておくことでテスト開始時にモックをフレッシュな状態にすることができます。

まとめ

いかがでしたでしょうか。
様々な言語やフレームワークでモックやスタブを生成するライブラリがありますが、それらに慣れていればMockitoも違和感なく使用できるように感じました。
今回はMockitoを使用した依存関係のある単体テストのみにフォーカスしましたが、実際のプロジェクトではRiverpodなどを状態管理として使用することも多いかと思います。
これらのライブラリはプロジェクト構造への影響も小さくないことから、テストコードでも配慮が必要になると思います。
今後はこれらのライブラリを用いたテストコードについても学んで行きたいと思います。