FlutterアプリをCleanArchitecture + TDDで書く1(概要とユースケース実装)
Flutter最高ですよね。こんなUI部品ないかな?と思って調べると大体標準SDKで用意されている...。
そんなFlutterでネイティブアプリをテスト駆動で書いてみます。
環境
- Flutter 2.0.3 • channel stable
- Tools • Dart 2.12.2
アーキテクチャ
CleanArchitectureを採用しました。責務分けが明確で、あまり考えなくても書けるので...。ファイル数は増えますが・・・
TDDですので、リファクタリングのタイミングで設計を都度行いますが、基本方針としCleanArchitectureに沿って書いていきます。
CleanArchitectureの書籍の下図に沿ってつくりたいと思います。(書籍ではWebシステムの具体的な例として扱っているものですが...)
ところで、TDDとDartは相性がいい気がします。テストで大量のモックができますが、Dartには暗黙的インターフェースがあるので、明示的にインターフェースをつくらなくてもよい場合があるからです。
プレゼンテーションロジックについて
CleanArchitectureを厳格に実装しようとすると、ユースケースはプレゼンターを間接参照して結果の出力はそれに対して指示します。
本記事では、プレゼンテーションロジックをもつViewModel(Provider
+ChangeNotifier
を使用します)がユースケースを参照します。なので、すみません、なんちゃってCleanArchitectureです。
後述の参考にしたサイトでは、CleanArchitecture + BLoCパターンを採用しており、同様にBLoCがユースケースを参照してます。本記事では、BLoCの部分をProvider
+ChangeNotifier
に差し替えたかたちになります。(本記事はユースケースのみを扱ってますので、次回以降にでてきます)
どこまでTDDで書くのか
UIは変わりやすい部分なので、TDDどころか、テストを書かないです。ユースケース・データアクセス(リポジトリー)・プレゼンテーションロジックまでTDDで書いて、最後にUIを書くときに結合する、という流れでいきます。
またデモですので、異常系の考慮したケースなどは実装しないです。
参考にしたサイト
つくる機能
ログイン機能をつくります。メールとパスワードを入力して、ボタンを押すとログイン実行します。
プロジェクト作成
まずプロジェクトをつくります。
flutter create tdd_login_demo
そしたらお好きなエディタでプロジェクトを開きます。自分はAndroidStudioを使用しています。
ここではNull safetyで実装したいので、sdkのバージョンを以下のようにしました。
environment:
sdk: ">=2.12.0 <3.0.0"
ユースケース
まずユースケースをつくります。CleanArchitectureの図のUse Case Interactorに該当する部分です。ファイル名は login_test.dart
にします。
/// login_test.dart
void main() {
}
TDDだと実装する機能をTODOリストとしてメモしてから、書くことが多いと思いますが、ずぼらなのでテストケースをTODOリストっぽく書いてしまいます。
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ログインする', () {
test('リポジトリのログイン実行', () {
});
test('リポジトリのログイン実行結果を返す', () {
});
});
}
ログインする、というユースケースはAPIでID/passwordを送信してログインみたいな流れが多いと思いますので、APIを実行されること(=リポジトリのメソッドを呼ぶ)、APIの結果が想定通りであること、の2点を確認することとしました。
リポジトリのログイン実行のケース
まずリポジトリのログイン実行
のケースから書いていきます。APIの実行などデータアクセスはリポジトリの役割です。なのでリポジトリのメソッドが呼ばれたらOKということにします。
test('リポジトリのログイン実行', () {
// arrange
var email = "test@test.com";
var pass = "password";
// assert
verify(repository.login(email: email, pass: pass));
});
verifyでコンパイルエラー...モックライブラリがまだなかったです...。mockito
を使いますので、pubspec.yamlに追加して、flutter pub get
します。
dev_dependencies:
# ...
mockito: ^5.0.3 # モックライブラリ
build_runner: ^1.12.2 # モッククラスの自動生成に必要です
おわったらmockito
がimportできます。
repository
の変数がまだないので、テストファイルのmain直下に追加します。また、モック自動生成に必要なアノテーションもつけておきます。
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
([UserRepository])
void main() {
late UserRepository repository;
UserRepository
をつくります。実装はまだしなくてよいので抽象クラスにしておきます。
abstract class UserRepository {
}
まだ、repository
がないと怒られてますので、テスト実行前メソッドsetUp
で初期化します。ただしモッククラスMockUserRepository
を生成して代入します。
void main() {
late UserRepository repository;
setUp(() {
repository = MockUserRepository();
});
// ...
}
モッククラスをコマンドで自動生成しましょう。
flutter pub run build_runner build
できたら、importします。
import 'login_test.mocks.dart';
([UserRepository])
void main() {
ところでrepository.login
メソッドがまだないのでつくります。自動補完(AndroidStudioだと"Create method 'login'")をすると、以下のようなメソッドができました。
abstract class UserRepository {
login({String email, String pass}) {}
いまのところ戻り値はなしにしちゃいます。またNull safetyなので、メソッドの引数にrequired
も追加します。
abstract class UserRepository {
login({required String email, required String pass});
テスト実行します!
flutter test test/login_test.dart
メソッドが呼ばれてないよ、とのことで見事に失敗しました!レッドフェーズです。
No matching calls (actually, no calls at all).
(If you called `verify(...).called(0);`, please instead use `verifyNever(...);`.)
すみません、というかテスト対象のユースケースのメソッドを実行していませんでした...
ユースケースにはメソッドをひとつしかつくらない予定なので、call
というメソッド名にして、変数名 + ()
だけでメソッドを呼べるようにします。
test('リポジトリのログイン実行', () {
// arrange
// ...
// act 👇 追加
useCase();
// assert
// ...
});
ユースケースクラスがまだないので作ります。メソッドはcall
です。
void main() {
late Login useCase;
// ...
setUp(() {
repository = MockUserRepository();
useCase = Login();
});
}
class Login {
/// ログインする
call() {
// TODO
}
}
もう一回テスト実行です!何もしてないので同じように失敗します。
次にテストが通るようにします。ユースケースにリポジトリを渡して、実行だけするようにします。
void main() {
// ...
setUp(() {
repository = MockUserRepository();
useCase = Login(repository: repository); // 👈 リポジトリを渡す
});
// ...
}
class Login {
final UserRepository repository;
Login({required this.repository});
call() {
repository.login(email: "test@test.com", pass: "password"); // 👈 実行するだけ
}
}
テスト実行すると、まだ失敗します。。
MissingStubError: 'login'
No stub was found which matches the arguments of this method call:
login({email: test@test.com, pass: password})
Add a stub for this method using Mockito's 'when' API, or generate the mock for MockUserRepository with 'returnNullOnMissingStub: true'.
どうやらloginメソッドのスタブがないみたいです。whenするか、returnNullOnMissingStub: true
という設定をいれてモッククラスをつくるか、しないといけないみたいです。いったんwhenでnullを返すようにします。
test('リポジトリのログイン実行', () {
// arrange
var email = "test@test.com";
var pass = "password";
// 👇 whenを追加
when(repository.login(email: email, pass: pass))
.thenAnswer((_) => null);
テスト実行で今度はとおります!グリーンです。
次にリファクタリングです。リポジトリに渡している引数が重複してるので、ユースケースに引数に渡して、それを使うようにします。(実際やるときは、最初から引数に渡すようにするかと思いますが、、、)
void main() {
// ...
group('ログインする', () {
test('リポジトリのログイン実行', () {
// ...
useCase(email: email, pass: pass);
// ...
});
// ...
}
class Login {
call({required String email, required String pass}) {
repository.login(email: email, pass: pass);
}
}
もう一回実行します。通ります!このケースはこれでOKです。
リポジトリのログイン実行結果確認のケース
ここでは、ユースケースの実行結果が、リポジトリの実行結果をそのまま返していることを確認します。
まずアサート文です。
test('リポジトリのログイン実行結果を返す', () {
// assert
expect(actual, expected);
});
結果の値actual
と想定値expected
がないです。メソッドを実行して、actual
を取得します。
test('リポジトリのログイン実行結果を返す', () {
// act
final actual = useCase(email: "test@test.com", pass: "password");
// assert
expect(actual, expected);
});
まだ想定値expected
がないので、追加します。
test('リポジトリのログイン実行結果を返す', () {
// arrange
final expected = User(userId: 1);
// act
final actual = useCase(email: "test@test.com", pass: "password");
// assert
expect(actual, expected);
});
ログイン結果としてユーザー情報が取得したいと思い、User
を返すようにしてみました。まだないので、User
クラスをつくります。
class User {
final int userId;
User({required this.userId});
}
テストを実行します!失敗します。またstubの問題です。。前のケースのarrangeをコピペしちゃいましょう。
test('リポジトリのログイン実行結果を返す', () {
// arrange
var email = "test@test.com";
var pass = "password";
when(repository.login(email: email, pass: pass))
.thenAnswer((_) => null);
final expected = User(userId: 1);
// act
final actual = useCase(email: email, pass: pass);
テストを実行します!失敗しました。。まだ、Loginユースケースが何も返してないからですね。
flutter test test/login_test.dart
package:test_api expect
package:flutter_test/src/widget_tester.dart 429:3 expect
test/login_test.dart 37:7 main.<fn>.<fn>
Expected: <Instance of 'User'>
Actual: <null>
テストを通すために修正します。Loginユースケースの戻り値は、非同期のAPI実行結果を返すため、Future<User>
として、結果と一致する値を返すだけの仮実装します。呼び出し元のメソッドにasync
/await
も追記します。
test('リポジトリのログイン実行結果を返す', () async {
// ...
// act
final actual = await useCase(email: email, pass: pass);
});
}
class Login {
Future<User> call({required String email, required String pass}) {
repository.login(email: email, pass: pass);
return Future.value(User(userId: 1));
}
}
テスト実行です!あれ...失敗しました。
Expected: <Instance of 'User'>
Actual: <Instance of 'User'>
メンバ変数の値は同じでも、別のインスタンスだからですね...。Kotlinだったらdata class , Swiftだったらstruct (すみません、SwiftもEquatableの実装が必要です)を使えば何もせずいけそうですが、dartはダメみたいです。
Equatableというオブジェクトの比較を楽にするパッケージがあるので、それを使います。
dependencies:
# ...
equatable: ^2.0.0
追記したら flutter pub get
して、User
を以下のように書き換えます。
class User extends Equatable {
final int userId;
User({required this.userId});
List<Object?> get props => [userId];
}
これで userId
の値が一致すれば、等しいという判定になります。再度テスト実行します!
通ります!
・・・
次にリファクタリングです。
まず想定値User
が重複していますので、リポジトリの戻り値がそれになるようにします。そして、リポジトリのモックはUser
スタブを返すようにします。
test('リポジトリのログイン実行', () {
// arrange
// asyncを追加して戻り値をUserに
when(repository.login(email: email, pass: pass))
.thenAnswer((_) async => User(userId: 1));
// ...
});
test('リポジトリのログイン実行結果を返す', () async {
// arrange
// ...
final expected = User(userId: 1);
// asyncを追加して戻り値をUserに
when(repository.login(email: email, pass: pass))
.thenAnswer((_) async => expected);
// ...
});
}
class Login {
// ...
Future<User> call({required String email, required String pass}) {
// リポジトリの結果を返すようにする
return repository.login(email: email, pass: pass);
}
}
abstract class UserRepository {
Future<User> login({required String email, required String pass});
}
テストがまたこけました...。
type 'Null' is not a subtype of type 'Future<User>'
どうやら変更したUserRepository
のlogin
メソッドが自動生成されたMockUserRepository
にないようです。。。もう一度自動生成!(これもっといい方法ないかな・・・)
flutter pub run build_runner build
テストは通りました!
リファクタリングです。使用している引数や想定値が同じなので、group配下に定数として定義しちゃいます。
group('ログインする', () {
final email = "test@test.com";
final pass = "password";
final expected = User(userId: 1);
test('リポジトリのログイン実行', () {
// arrange
when(repository.login(email: email, pass: pass))
.thenAnswer((_) async => expected);
// act
useCase(email: email, pass: pass);
// assert
verify(repository.login(email: email, pass: pass));
});
test('リポジトリのログイン実行結果を返す', () async {
// arrange
when(repository.login(email: email, pass: pass))
.thenAnswer((_) async => expected);
// act
final actual = await useCase(email: email, pass: pass);
// assert
expect(actual, expected);
});
});
すこしスッキリしましたね!あとはarrangeやactのところをもっと共通化できるかもですが、いったんここまでにします。。
タイトル末尾の"1"でおわかりかと思いますが、つづきます...。ユースケースだけで結構なボリュームになってしまいましたので。。リポジトリとプレゼンテーションロジックなども続けて書いていきたいと思います。
また、Null safetyにしたことでモックをコマンドで都度生成する必要がでてきたのが懸念です。かなりモッククラスができることになりますが、都度コマンドを叩いてくしかないのでしょうか...。もしもっと効率的な方法があればご教授ください・・・
それでは次回もよろしくお願いします。
続きはこちらです:
Discussion