FlutterアプリをCleanArchitecture + TDDで書く1(概要とユースケース実装)

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

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を書くときに結合する、という流れでいきます。
またデモですので、異常系の考慮したケースなどは実装しないです。

参考にしたサイト

https://resocoder.com/2019/08/27/flutter-tdd-clean-architecture-course-1-explanation-project-structure/
https://medium.com/ideas-by-idean/a-flutter-bloc-clean-architecture-journey-to-release-the-1st-idean-flutter-app-db218021a804

つくる機能

ログイン機能をつくります。メールとパスワードを入力して、ボタンを押すとログイン実行します。

プロジェクト作成

まずプロジェクトをつくります。

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を使えば何もせずいけそうですが、dartはダメみたいです。
Equatableというオブジェクトの比較を楽にするパッケージがあるので、それを使います。

https://pub.dev/packages/equatable

CleanArchitectureなのに、エンティティーがライブラリに依存しちゃってます!
ただこのライブラリはプログラミング言語自体を補強するだけなのでお許しください...。

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>'

どうやら変更したUserRepositoryloginメソッドが自動生成された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にしたことでモックをコマンドで都度生成する必要がでてきたのが懸念です。かなりモッククラスができることになりますが、都度コマンドを叩いてくしかないのでしょうか...。もしもっと効率的な方法があればご教授ください・・・

それでは次回もよろしくお願いします。

続きはこちらです:

https://zenn.dev/sinamori/articles/a1eee515e49331