こんにちは。
モダンアプリコンサル部の坂本です。
モダンアプリコンサル部として(ジョインブログ以外で)の初投稿です。
元々スマホアプリの開発経験を積んできたので、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
だと単純な正当値を返却します。
(null
や0
、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: "田中 太郎",
));
when
でMockAuthRepository
のlogin
へのスタブ生成を宣言しています。
MockitoではargThat
で引数を条件としたスタブを生成することができ、ここでは引数としてloginId
にtanaka@example.com
、password
に8peJuzJ*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度呼び出されているかを評価していますが、呼び出し回数は固定値だけではなく、greaterThan
やlessThan
のようなmatcherも利用することができます。
「呼び出しされていないこと」を評価する場合、verify(...).called(0)
としたくなるところですが、verifyNever
を使用しないとTest Failedとなる点注意が必要です。
モックのリセット
reset(authRepository);
こちらの記述によってモックのスタブや呼び出し回数などのインタラクションをリセットできます。
テスト対象が依存するオブジェクトのモックをテスト毎に生成しないケースでは、setUp
にこちらを宣言しておくことでテスト開始時にモックをフレッシュな状態にすることができます。
まとめ
いかがでしたでしょうか。
様々な言語やフレームワークでモックやスタブを生成するライブラリがありますが、それらに慣れていればMockitoも違和感なく使用できるように感じました。
今回はMockitoを使用した依存関係のある単体テストのみにフォーカスしましたが、実際のプロジェクトではRiverpodなどを状態管理として使用することも多いかと思います。
これらのライブラリはプロジェクト構造への影響も小さくないことから、テストコードでも配慮が必要になると思います。
今後はこれらのライブラリを用いたテストコードについても学んで行きたいと思います。