FlutterアプリをCleanArchitecture + TDDで書く3(プレゼンテーションロジック実装)

16 min read読了の目安(約14600字

前回の続き、第3回目です。

今回はChangeNotifierを使ったプレゼンテーション層の実装をしていきたいと思います。

背景

Flutterで開発をはじめたばかりのときに、状態管理をどのような仕組みを使って行えばよいのか調べていたら、強い理由がなければProvider + ChangeNotifierでやるのがいいよ、と書いてありました。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple
そこで使ってみたところ、使い方はシンプルで実装はしやすいのですが、テストがしにくいことに気付きました。。具体的に言うと、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のままになっていることだけなので、なんとかなりそうですが、さらにもう一段階状態が遷移する場合などはめんどくさそうです。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple#changenotifier
(参考)上記サイトに載っている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オプションを指定すると短くできるみたいです。testgroupごとにもタイムアウトを設定できるようです

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を入力してませんでしたので、追加します。

convertToStream()によるStreamへの変換は、テスト対象メソッド(ここではinputEmail)の実行前に行わないと、値の流れる順序が変わるので注意が必要です...。

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実行中はローディングを出すなどします。

https://resocoder.com/2019/10/07/flutter-tdd-clean-architecture-course-10-bloc-scaffolding-input-conversion/
なので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側でも制御すると思いますのでいったんこのままで・・・(記事も長くなってきたので・・・)
今回は以上です。
次回がシリーズ最後の予定ですので、よろしくお願いいたします。

👇 最終回書きました:

https://zenn.dev/sinamori/articles/556234ef358680