FlutterアプリをCleanArchitecture + TDDで書く2(リポジトリ実装)

12 min read読了の目安(約11300字

前回 のつづきです。
前回はログイン機能のユースケースの実装をしました。
今回は、データアクセスを担当するリポジトリをみていきます。

参考サイト

HTTPクライアントをモックする方法は以下を参考にしました。

https://flutter.dev/docs/cookbook/testing/unit/mocking

リポジトリ

前回作成した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クライアントは次のパッケージを使用します。

https://pub.dev/packages/http
使い方(Using)のところを参考にPOSTされたことのassertを書いてみます。
import 'package:mockito/mockito.dart';

void main() {
  // ...
    test('HTTPクライアントでPOST', () {
      // assert
      verify(client.post(url, body: body));
    });

clienturlbodyという変数がないですので、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のコンパイルエラーはなくなったと思いますので、次にurlbodyを追加します。

    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

自動補完で出て来たメソッドの引数emailpasswordと、先ほどの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)>

これでレスポンスを変換しないと、いけなくなりましたね...。

レスポンスクラス

ここからはレスポンスクラスを作っていきます。パッケージは以下を使います。

https://pub.dev/packages/json_serializable
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で書いてもいいかもしれないですし、他に良い方法を思いつけば追記するかもしれないですが、いったんこのままにします。

補足

実際にアプリをつくるときは通信状態のチェックとかもしないといけないと思いますが、それはこちらの記事がとても参考になりますので・・・見てみてください・・・🙇‍♂️

https://resocoder.com/2019/09/19/flutter-tdd-clean-architecture-course-6-repository-implementation/
https://resocoder.com/2019/09/23/flutter-tdd-clean-architecture-course-7-network-info/
ここまで作っておいて今更ですが、エラーハンドリングしないアプリなんてありないので、デモでもいれておけばよかったかなと、、、
自分が個人的につくっているアプリの場合は、上記の解説記事でも使用している関数型プログラミングをしやすいようにしてくれるdartzというライブラリにEitherというクラスがあり、そちらで成功/失敗を返すようにしています。もしよろしければご参考ください。
https://pub.dev/packages/dartz
今回は以上になります。

続きはこちらです:

https://zenn.dev/sinamori/articles/01f5b193349af0