FlutterアプリをCleanArchitecture + TDDで書く3(プレゼンテーションロジック実装)
前回の続き、第3回目です。
今回はChangeNotifier
を使ったプレゼンテーション層の実装をしていきたいと思います。
背景
Flutterで開発をはじめたばかりのときに、状態管理をどのような仕組みを使って行えばよいのか調べていたら、強い理由がなければProvider + ChangeNotifier
でやるのがいいよ、と書いてありました。
そこで使ってみたところ、使い方はシンプルで実装はしやすいのですが、テストがしにくいことに気付きました。。具体的に言うと、Blocパターンのテストでよく出てくる、値が順に流れてくることを検証するemitsInOrder
みたいなことをしようとすると、addListener
だけだとテストコードが複雑になってしまいました。そこで自分のアプリでは、検証する値のStream
をつくることで対応しました。他にもやり方はあるかもしれませんので、こんなやり方あるよとかありましたらコメントいただけると幸いです。
プレゼンテーション
まずはテストファイルを作成します。ユーザーのログイン状態に関するプレゼンテーションロジックを書くので、user_login_model_test.dart
にしました。TODOリスト件テストケースを書きます。
/// user_login_model_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ログイン', () {
test('未入力 → emailの入力の場合、状態に変化なし', () {
});
test('email入力済み → パスワード入力で、初期状態 → ログイン可能 の順で状態遷移する', () {
});
test('ログイン実行で、初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
});
});
}
ChangeNotifier
を継承したクラスが、入力により状態が遷移することを確認します。
未入力 → emailの入力
まずは、ユーザーがemail/パスワードを入力することにより、ログインボタンの活性・非活性が切り替わる(よくあるサンプル...)ケースを作成していきます。アサート文を書いてみます。
test('未入力 → emailの入力の場合、状態に変化なし', () {
// assert
expect(actual, emitsInOrder([
false, // 初期状態
false // ログインボタンは非活性のまま
]));
});
パスワードは未入力なので、ログインボタンは非活性のままです。シンプルにログインの実行が可能かどうかのフラグを持つことにしました。まだテスト実行結果actual
がないので、テスト対象のメソッドを実行して取得したいところですが...今回はオブジェクトの(メンバ変数の)状態が遷移することを確認するのでひと手間加える必要があります。
Flutterの参考サイトを見ると、以下のテストコードのサンプルがありますが、これだと、状態が順に遷移したことの確認が大変です...。今回の場合、初期状態のfalse → email入力でfalseのままになっていることだけなので、なんとかなりそうですが、さらにもう一段階状態が遷移する場合などはめんどくさそうです。
(参考)上記サイトに載っているChangeNotifier
クラスのテストサンプルです。
test('adding item increases total cost', () {
final cart = CartModel();
final startingPrice = cart.totalPrice;
cart.addListener(() {
expect(cart.totalPrice, greaterThan(startingPrice));
});
cart.add(Item('Dash'));
});
ここでは上述したようにStream
に変換して、値が流れてくることを確認したいと思います。Stream
に変換するため、以下のようなヘルパーを用意しました。(このメソッドのテストは省略・・・)
Stream convertToStream<T extends ChangeNotifier, R>(T model, R Function(T notifier) target) {
final controller = StreamController(
onListen: null,
onCancel: () {
model.dispose();
});
model.addListener(() {
controller.sink.add(target(model));
});
// 初期状態の値を流す
controller.sink.add(target(model));
return controller.stream;
}
target
という関数の戻り値を検証するようにしてます。
それではactを追加して、actual
を取得します。
test('未入力 → emailの入力の場合、状態に変化なし', () {
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
// assert
// ...
});
model
(UserLoginModel
)がまだないので作成します。また、model
が持つcanLogin
というメンバ変数を確認対象にしてますので、その定義も追加します。
void main() {
late UserLoginModel model;
setUp(() {
model = UserLoginModel();
});
group('ログイン', () {
test('未入力 → emailの入力の場合、状態に変化なし', () {
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
// assert
// ...
});
// ...
});
}
class UserLoginModel extends ChangeNotifier {
bool canLogin = false;
}
UserLoginModel
の初期化コードも追加しました。
コンパイルエラーがなくなったので実行します!
待つこと30秒ほどでしょうか・・・やっと失敗してくれました・・・(しかもAndroidStudioで実行すると、--start-paused
というオプションがデフォルトでつき、いつまでたってもタイムアウトしてくれないので、ターミナルで実行しなおした)
値が流れてくるのを待っても、流れてこないのでタイムアウトになったようです。
次のように--timeout
オプションを指定すると短くできるみたいです。test
やgroup
ごとにもタイムアウトを設定できるようです。
flutter test --timeout 3s test/login_info_model_test.dart
すると以下のように3秒でタイムアウトになってくれました。
TimeoutException after 0:00:03.000000: Test timed out after 3 seconds.
dart:isolate _RawReceivePortImpl._handleMessage
まだemailを入力してませんでしたので、追加します。
void main() {
// ...
test('未入力 → emailの入力の場合、状態に変化なし', () {
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
model.inputEmail('test@test.com');
// assert
// ...
});
// ...
}
class UserLoginModel extends ChangeNotifier {
bool canLogin = false;
void inputEmail(String s) {
canLogin = false;
notifyListeners();
}
}
フラグに値をセットし、通知するだけの仮実装しました。
テストを実行すると通ります!次のケースで本実装しますので、いったんこれはこのままでOKです。
email入力済み → パスワード入力
まずアサートです。
test('email入力済み → パスワード入力で、初期状態 → ログイン可能 の順で状態遷移する', () {
// assert
expect(actual, emitsInOrder([
false, // email入力済み
true, // パスワード入力するとログイン可能フラグはtrue
]));
});
実行の部分を書きます。また、email入力済みの状態にするため、arrangeも追加しちゃいます。
test('email入力済み → パスワード入力で、初期状態 → ログイン可能 の順で状態遷移する', () {
// arrange
model.inputEmail('test@test.com');
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
model.inputPassword('p@ssw0rd');
// assert
// ...
});
まだ、inputPassword
メソッドがないので追加します。
class UserLoginModel extends ChangeNotifier {
bool canLogin = false;
// ...
void inputPassword(String s) {
canLogin = true;
notifyListeners();
}
}
仮実装して、テスト実行します!通ります。
ここからは明白な実装ということで、ちゃんと実装しちゃいます。TDDの三角測量でやってもいいのですが、単純な実装なので、、、emailとパスワードがともに空でなければログイン可能という実装にします。
class UserLoginModel extends ChangeNotifier {
bool _canLogin = false;
bool get canLogin => _canLogin;
set canLogin(bool newValue) {
_canLogin = newValue;
notifyListeners();
}
String _inputtedEmail = "";
String _inputtedPassword = "";
void inputEmail(String s) {
_inputtedEmail = s;
_validate();
}
void inputPassword(String s) {
_inputtedPassword = s;
_validate();
}
void _validate() {
canLogin = _inputtedEmail.isNotEmpty && _inputtedPassword.isNotEmpty;
}
}
バリデーションメソッド(_validate
)でnotifyListeners
してもいいのですが、ログイン可能フラグのセッターを用意してそこで通知することにしました。
テストは通ります!
ログイン実行
ログインボタンを押したときの処理です。まずアサートから書きます。
test('ログイン実行で、初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// assert
expect(actual, emitsInOrder([
Empty(),
Loading(),
Loaded(result: User(userId: 1))
]));
});
以下を参考に、状態ごとにクラスを作成する予定です。API実行中はローディングを出すなどします。Empty
/Loading
/Loaded
というクラスをつくります。
abstract class UserLoginState extends Equatable {
List<Object?> get props => [];
}
class Empty extends UserLoginState {}
class Loading extends UserLoginState {}
class Loaded extends UserLoginState {
final User result;
Loaded({required this.result});
List<Object?> get props => [result];
}
UserLoginState
という抽象クラスをつくり、各状態はそれを継承しました。
次にテストの実行結果actual
がないので、いままでと同じようにStream
を取得します。
login
というメソッドをつくる想定です。また、state
というメンバ変数で状態管理することにします。
test('ログイン実行で、初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// act
final actual = convertToStream<UserLoginModel, UserLoginState>(
model, (notifier) => notifier.state
);
model.login();
// assert
// ...
});
仮実装でlogin
メソッドとstate
をつくります。
class UserLoginModel extends ChangeNotifier {
// ...
UserLoginState state = Empty();
void login() {
state = Loading();
notifyListeners();
state = Loaded(result: User(userId: 1));
notifyListeners();
}
}
テストを実行すると成功します。...とここでひとつ気付いたのですが、ユースケースのログイン実行を検証するのが漏れてました...。ケースを修正します。
// 👇 group追加
group('ログイン実行', () {
// 👇 ケース追加
test('ユースケースを実行する', () {
});
test('初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// ...
});
});
ユースケースを実行する
ユースケースを実行することを検証します。
test('ユースケースを実行する', () {
// assert
verify(useCase(email: email, pass: pass));
});
useCase
/email
/pass
を追加します。
([Login])
void main() {
late UserLoginModel model;
late Login useCase;
setUp(() {
useCase = MockLogin();
model = UserLoginModel();
});
group('ログイン', () {
// ...
group('ログイン実行', () {
test('ユースケースを実行する', () {
// arrange
final email = 'test@test.com';
final pass = 'p@ssw0rd';
// act
model.login();
// assert
verify(useCase(email: email, pass: pass));
});
そしたらモック生成のため、build_runnerを実行しましょう。
flutter pub run build_runner build
コンパイルエラーが解消したら、テストを実行してみます!ユースケースのメソッドをまだ呼んでいないので失敗します。レッドです。
No matching calls (actually, no calls at all).
(If you called `verify(...).called(0);`, please instead use `verifyNever(...);`.)
ユースケースを呼ぶだけの仮実装します。またスタブがないと怒られるので、when
も書いときます。
// ...
setUp(() {
useCase = MockLogin();
model = UserLoginModel(useCase: useCase);
});
// ...
group('ログイン実行', () {
test('ユースケースを実行する', () {
// arrange
final email = 'test@test.com';
final pass = 'p@ssw0rd';
when(useCase(email: email, pass: pass))
.thenAnswer((_) async => User(userId: 1));
// act
// ...
});
test('初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// arrange
when(useCase(email: 'test@test.com', pass: 'p@ssw0rd'))
.thenAnswer((_) async => User(userId: 1));
// act
// ...
});
});
// ...
}
class UserLoginModel extends ChangeNotifier {
final Login useCase;
UserLoginModel({required this.useCase});
// ...
void login() {
// 👇 呼ぶだけ
useCase(email: 'test@test.com', pass: 'p@ssw0rd');
state = Loading();
// ...
}
}
テスト実行すると、グリーンです!
リファクタリングしていきます。ユースケースの引数email
/pass
の重複排除と、ユースケースの実行結果を返すようにし、state
のセッターつくって以下のようになりました。
void main() {
// ...
group('ログイン実行', () {
// 👇 email/passなどをまとめてしまう
final email = 'test@test.com';
final pass = 'p@ssw0rd';
final expectedUser = User(userId: 1);
test('ユースケースを実行する', () {
// arrange
when(useCase(email: email, pass: pass))
.thenAnswer((_) async => expectedUser);
// ...
});
test('初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// arrange
when(useCase(email: email, pass: pass))
.thenAnswer((_) async => expectedUser);
// ...
// assert
expect(actual, emitsInOrder([
Empty(),
Loading(),
Loaded(result: expectedUser)
]));
});
});
// ...
}
class UserLoginModel extends ChangeNotifier {
// ...
UserLoginState _state = Empty();
UserLoginState get state => _state;
set state(UserLoginState newValue) {
_state = newValue;
notifyListeners();
}
// ...
void login() async {
state = Loading();
final user = await useCase(email: _inputtedEmail, pass: _inputtedPassword);
// 👇 ユースケースの実行結果をいれるようにする
state = Loaded(result: user);
}
}
テスト実行すると、失敗しました!
MissingStubError: 'call'
No stub was found which matches the arguments of this method call:
call({email: , pass: })
ユースケースの引数のemail
/pass
が空文字になってますね。。。これはUserLoginModel
のメンバ変数_inputtedEmail
/_inputtedPassword
を使用するようにしていたからです。arrangeでemail
/pass
もセットするようにします。長いですが、以下のようになりました。すみません、長いのでトグルにしました。
テスト
([Login])
void main() {
late UserLoginModel model;
late Login useCase;
setUp(() {
useCase = MockLogin();
model = UserLoginModel(useCase: useCase);
});
group('ログイン', () {
test('未入力 → emailの入力の場合、状態に変化なし', () {
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
model.inputEmail('test@test.com');
// assert
expectLater(actual, emitsInOrder([
false, // 初期状態
false, // ログイン可能フラグはfalseのまま
]));
});
test('email入力済み → パスワード入力で、初期状態 → ログイン可能 の順で状態遷移する', () {
// arrange
model.inputEmail('test@test.com');
// act
final actual = convertToStream<UserLoginModel, bool>(
model, (notifier) => notifier.canLogin
);
model.inputPassword('p@ssw0rd');
// assert
expect(actual, emitsInOrder([
false, // email入力済み
true, // パスワード入力するとログイン可能フラグはtrue
]));
});
group('ログイン実行', () {
final email = 'test@test.com';
final pass = 'p@ssw0rd';
final expectedUser = User(userId: 1);
void _setUseCaseMock() {
when(useCase(email: email, pass: pass))
.thenAnswer((_) async => expectedUser);
model.inputEmail(email);
model.inputPassword(pass);
}
test('ユースケースを実行する', () {
// arrange
_setUseCaseMock();
// act
model.login();
// assert
verify(useCase(email: email, pass: pass));
});
test('初期状態 → ローディング → ログイン結果取得済み の順で状態遷移する', () {
// arrange
_setUseCaseMock();
// act
final actual = convertToStream<UserLoginModel, UserLoginState>(
model, (notifier) => notifier.state
);
model.login();
// assert
expect(actual, emitsInOrder([
Empty(),
Loading(),
Loaded(result: expectedUser)
]));
});
});
});
}
UserLoginModel
class UserLoginModel extends ChangeNotifier {
final Login useCase;
UserLoginModel({required this.useCase});
bool _canLogin = false;
bool get canLogin => _canLogin;
set canLogin(bool newValue) {
_canLogin = newValue;
notifyListeners();
}
String _inputtedEmail = "";
String _inputtedPassword = "";
UserLoginState _state = Empty();
UserLoginState get state => _state;
set state(UserLoginState newValue) {
_state = newValue;
notifyListeners();
}
void inputEmail(String s) {
_inputtedEmail = s;
_validate();
}
void inputPassword(String s) {
_inputtedPassword = s;
_validate();
}
void login() async {
state = Loading();
final user = await useCase(email: _inputtedEmail, pass: _inputtedPassword);
state = Loaded(result: user);
}
void _validate() {
canLogin = _inputtedEmail.isNotEmpty && _inputtedPassword.isNotEmpty;
}
}
あとは、login()
でログイン可能かどうかの制御をいれてもいいと思いますが、UI側でも制御すると思いますのでいったんこのままで・・・(記事も長くなってきたので・・・)
今回は以上です。
次回がシリーズ最後の予定ですので、よろしくお願いいたします。
👇 最終回書きました:
Discussion