FlutterアプリをCleanArchitecture + TDDで書く2(リポジトリ実装)
前回 のつづきです。
前回はログイン機能のユースケースの実装をしました。
今回は、データアクセスを担当するリポジトリをみていきます。
参考サイト
HTTPクライアントをモックする方法は以下を参考にしました。
リポジトリ
前回作成したUserRepository
の実装であるUserRemoteDataSource
をつくります(APIを実行するので"Remote"になってます)。UserRepository
は以下でした。
abstract class UserRepository {
Future<User> login({required String email, required String pass});
}
最初にTODOリスト件テストケースを書いてしまいます。UserRemoteDataSource
のテストなので、ファイル名はuser_remote_data_source_test.dart
としました。
/// user_remote_data_source_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ログインAPI', () {
test('HTTPクライアントでPOST', () {
});
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () {
});
});
}
POSTで取得したレスポンスを、モデルクラスUser
へパースするところまでがリポジトリの責務です。パース失敗などの異常系はデモなので考慮しないです...。
HTTPクライアントでPOST
HTTPクライアントは次のパッケージを使用します。
使い方(Using)のところを参考にPOSTされたことのassertを書いてみます。import 'package:mockito/mockito.dart';
void main() {
// ...
test('HTTPクライアントでPOST', () {
// assert
verify(client.post(url, body: body));
});
client
とurl
、body
という変数がないですので、client
から追加します。
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
([http.Client])
void main() {
late http.Client client;
setUp(() {
client = MockClient();
});
// ...
モック生成のコマンドを叩いてから
flutter pub run build_runner build
importを追加します。
import 'user_remote_data_source_test.mocks.dart';
そうすると、clientのコンパイルエラーはなくなったと思いますので、次にurl
とbody
を追加します。
test('HTTPクライアントでPOST', () {
// assert
// URLは後々ローカルホストで確認するのでそれにあわせてます
final url = Uri.parse('http://localhost:3000/api/login');
final body = {'email': 'test@test.com', 'password': 'p@ssw0rd'};
verify(client.post(url, body: body));
こんな感じでしょうか...。次にテスト対象のメソッド実行(actの部分)です。
test('HTTPクライアントでPOST', () {
// act
remoteDataSource.login(email: 'test@test.com', pass: 'p@ssw0rd');
// assert
// ...
remoteDateSource
変数がないので、追加します。
void main() {
late http.Client client;
late UserRemoteDataSource remoteDataSource;
setUp(() {
client = MockClient();
remoteDataSource = UserRemoteDataSource(client: client);
});
// ...
}
class UserRemoteDataSource implements UserRepository {
final http.Client client;
UserRemoteDataSource({required this.client});
Future<User> login({required String email, required String pass}) {
// TODO: implement login
throw UnimplementedError();
}
}
変数の初期化と、UserRemoteDataSource
の定義までしました。まだ実装はTODOになってます。
actまで書いたのでテスト実行してみましょう!
UnimplementedError
ですよね・・・(汗)。レッドです。
次にグリーンにします。HTTPクライアントで実行するだけのコードを入れて、戻り値は適当な値をします。
class UserRemoteDataSource implements UserRepository {
// ...
Future<User> login({required String email, required String pass}) {
final url = Uri.parse('http://localhost:3000/api/login');
client.post(url, body: {'email': 'test@test.com', 'password': 'p@ssw0rd'});
return Future.value(User(userId: 1));
}
}
実行します!
MissingStubError: 'post'
No stub was found which matches the arguments of this method call:
失敗しました。post
というメソッドのスタブがないとのことなので、when
を追加します。返却されるHTTPレスポンスはjsonを返して欲しいので、モデル変換前のjson文字列をいれました。また、リクエストパラメータの変数などもarrangeへ移動してしまいます。
test('HTTPクライアントでPOST', () {
// arrange
final url = Uri.parse('http://localhost:3000/api/login');
final body = {'email': 'test@test.com', 'password': 'p@ssw0rd'};
when(client.post(url, body: body))
.thenAnswer((_) async => http.Response("{\"user_id\": 1}", 200));
// act
remoteDataSource.login(email: 'test@test.com', pass: 'p@ssw0rd');
// assert
verify(client.post(url, body: body));
});
テスト実行すると、グリーンです!
次にリファクタリングで重複を排除していきます。まずプロダクトコード/テストコードのemail/passwordをハードコーディングしている箇所をできるだけまとめてみます。
void main() {
// ...
test('HTTPクライアントでPOST', () {
// arrange
final url = Uri.parse('http://localhost:3000/api/login');
final email = 'test@test.com';
final password = 'p@ssw0rd';
final body = {'email': email, 'password': password};
when(client.post(url, body: body))
.thenAnswer((_) async => http.Response("{\"user_id\": 1}", 200));
// act
remoteDataSource.login(email: email, pass: password);
// ...
});
}
// ...
class UserRemoteDataSource implements UserRepository {
// ...
Future<User> login({required String email, required String pass}) {
final url = Uri.parse('http://localhost:3000/api/login');
client.post(url, body: {'email': email, 'password': pass});
return Future.value(User(userId: 1));
}
こんな感じでしょうか。。まだURLと'email'
と'password'
のjsonキーのハードコーディングが気になりますね...。リクエストクラスを作成しちゃいましょう。
class LoginRequest {
final Uri url = Uri.parse('http://localhost:3000/api/login');
final String email;
final String password;
LoginRequest({
required this.email,
required this.password
});
Map<String, dynamic> get body {
return {'email': email, 'password': password};
}
}
すると次のようになりました。
void main() {
// ...
test('HTTPクライアントでPOST', () {
// arrange
final email = 'test@test.com';
final password = 'p@ssw0rd';
final request = LoginRequest(email: email, password: password);
when(client.post(request.url, body: request.body))
.thenAnswer((_) async => http.Response("{\"user_id\": 1}", 200));
// act
remoteDataSource.login(email: email, pass: password);
// assert
verify(client.post(request.url, body: request.body));
});
// ...
}
class UserRemoteDataSource implements UserRepository {
// ...
Future<User> login({required String email, required String pass}) {
final request = LoginRequest(email: email, password: pass);
client.post(request.url, body: request.body);
return Future.value(User(userId: 1));
}
}
テストを実行すると通りましたのでOKです。
HTTPクライアントのレスポンスをモデルへ変換し返す
まずアサートから書きます。戻り値が想定値を一致することを確認します。
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () {
// assert
expect(actual, expected);
});
戻り値actual
がないので、テスト対象のメソッドを実行して取得します。非同期実行のメソッドなのでasync/awaitもつけます。
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () async {
// act
final actual = await remoteDataSource.login(email: email, pass: password);
// assert
自動補完で出て来たメソッドの引数email
・password
と、先ほどのexpected
を合わせてarrangeのところに書いてしまいましょう。
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () async {
// arrange
final email = 'test@test.com';
final password = 'p@ssw0rd';
final expected = User(userId: 2);
// act
final actual = await remoteDataSource.login(email: email, pass: password);
// assert
expect(actual, expected);
});
先ほどのケースで書いた戻り値 User(userId: 1)
と被らないよう敢えて User(userId: 2)
としました。。
コンパイルエラーがなくなったので実行します!
MissingStubError: 'post'
No stub was found which matches the arguments of this method call:
post(https://flutter.tdd.login.demo/api/login, {headers: null, body: {email: test.com, password: p}, encoding: null})
whenがないのでダメでした。。前のケースをコピペして、以下のようにしました。
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () async {
// arrange
final email = 'test@test.com';
final password = 'p@ssw0rd';
final expected = User(userId: 2);
final request = LoginRequest(email: email, password: password);
when(client.post(request.url, body: request.body))
.thenAnswer((_) async => http.Response("{\"user_id\": ${expected.userId}}", 200));
テストを実行すると、失敗します。
Expected: User:<User(2)>
Actual: User:<User(1)>
これでレスポンスを変換しないと、いけなくなりましたね...。
レスポンスクラス
ここからはレスポンスクラスを作っていきます。パッケージは以下を使います。
dependencies:
# ...
json_serializable: ^4.1.0
json文字列からモデルへの変換は、
json文字列 → エンティティー(User)
と変換してしまってもいいのですが、そうするとエンティティーに上記のjson_serializable
パッケージのアノテーションなどをつける必要がありライブラリに依存してしまい、アーキテクチャ的にだめなので、以下の流れで変換するようにしました。
json文字列 → レスポンスクラス(UserResponse) → エンティティー(User)
それではレスポンスクラスをつくります。
今までは自動補完でクラスを生成してたので、すべてtest
ディレクトリ配下にすべてファイル内にクラスを作成してしまっていましたが、このファイルはそういう訳ではないのでlib
ディレクトリに作りました。json_serializable
のREADMEを参考に以下のようなクラスを作成します。
/// lib/infrastructure/response/user_response.dart
import 'package:json_annotation/json_annotation.dart';
part 'user_response.g.dart';
()
class UserResponse {
(name: "user_id")
final int userId;
UserResponse({required this.userId});
factory UserResponse.fromJson(Map<String, dynamic> json) => _$UserResponseFromJson(json);
Map<String, dynamic> toJson() => _$UserResponseToJson(this);
}
そして、モックを生成するときにいつも叩いてるbuild_runnerを実行します。
flutter pub run build_runner build
すると、同じディレクトリ配下にuser_response.g.dart
というファイルができて、コンパイルエラーがなくなるかと思います。
・・・
それでは準備ができたので、グリーンにしていきます。
class UserRemoteDataSource implements UserRepository {
final http.Client client;
UserRemoteDataSource({required this.client});
Future<User> login({required String email, required String pass}) async {
final request = LoginRequest(email: email, password: pass);
final response = await client.post(request.url, body: request.body);
// json文字列 → Mapへ変換
final jsonBody = JsonCodec().decode(response.body);
// Map → レスポンスクラスに変換
final userResponse = UserResponse.fromJson(jsonBody);
return User(userId: userResponse.userId);
}
}
エラーハンドリングがないとか、User(userId: userResponse.userId)
とか、いまいちな点がたくさんありますが、、、とりあえずテストを実行すると、通ります!
リファクタリングしていきます。テストコードの方で、パラメータが重複している箇所がありますので、group
配下に書いてまとめてしまいました。
group('ログインAPI', () {
final email = 'test@test.com';
final password = 'p@ssw0rd';
final request = LoginRequest(email: email, password: password);
test('HTTPクライアントでPOST', () {
// arrange
when(client.post(request.url, body: request.body))
.thenAnswer((_) async => http.Response("{\"user_id\": 1}", 200));
// act
remoteDataSource.login(email: email, pass: password);
// assert
verify(client.post(request.url, body: request.body));
});
test('HTTPクライアントのレスポンスをモデルへ変換し返す', () async {
// arrange
final expected = User(userId: 2);
when(client.post(request.url, body: request.body))
.thenAnswer((_) async => http.Response("{\"user_id\": ${expected.userId}}", 200));
// act
final actual = await remoteDataSource.login(email: email, pass: password);
// assert
expect(actual, expected);
});
});
レスポンスクラスUserResponse
からエンティティーUser
へ変換する処理は、別途UserResponse
で書いてもいいかもしれないですし、他に良い方法を思いつけば追記するかもしれないですが、いったんこのままにします。
補足
実際にアプリをつくるときは通信状態のチェックとかもしないといけないと思いますが、それはこちらの記事がとても参考になりますので・・・見てみてください・・・🙇♂️
自分が個人的につくっているアプリの場合は、上記の解説記事でも使用している関数型プログラミングをしやすいようにしてくれるdartz
というライブラリにEither
というクラスがあり、そちらで成功/失敗を返すようにしています。もしよろしければご参考ください。
今回は以上になります。
続きはこちらです:
Discussion