🔥

[flutter]テスタビリティを考慮してRiverpod + StateNotifier + Firebase 前編

2020/10/17に公開
2

はじめに

Riverpod + StateNotifierの組み合わせ、スッキリ状態管理ができていいなあと感じております。
今回はflutterでよくあるシナリオとしてFirebaseを使った開発をする際にどのようにテストしやすい状態管理クラスを構成するか紹介したいと思います。(何番煎じかわかりませんが)
Riverpodがユニットテストに関しての仕組みを提供してくれているので後編にてそれらを使ったテストの書き方も紹介したいと思います。

なおこの記事で書いているコードはあくまで例であり(動作確認してません)、本番で使うにはエラーハンドリングなどが足りていないので注意してください

環境

hooksは使ってません

バージョン
flutter 1.22.1(MacOS)
state_notifier 0.6.0
freezed 0.12.2
freezed_annotation 0.12.0
flutter_riverpod 0.11.1
mockito 4.1.2

Firebase周りのモックはきつい

各所で言われていることですが、Firebase周りの処理をMockitoや抽象クラスの継承を使ってやっていくのは相当厳しいです。自分も少しやってみましたがcollection().where().get()みたいなチェーンになっている呼び出しをモックするのが面倒すぎました

なので基本的にはfirebase含む外部との通信を担う専門のクラスを作ってしまい、状態管理のクラスと分けるのがいいと思います。

今回のケース

Firebaseを使ってサービスを構築する以上大抵はAuthenticationで発行されたuidを使ってUser用のコレクションを作成するかと思います。今回はログインしているユーザーのドキュメントのストリームに登録して、変更があるたびにアプリ側で保持するUserデータの更新を行う仕組みを作っていきます。
あんまり長々とコードを書いても読むのが面倒だと思うので要点が最低限わかるくらいのものを作っていきます。

状態管理の方はStateNotifier + Riverpodです

Repositoryクラス

外部通信を担うクラスになります。このクラスには基本的にfirebaseの処理を書いていくわけですがいくつか注意点があります。

  • firebaseプラグインから来ているオブジェクトを返さない
  • 自作のロジックを必要最小限にし、可能な限り通信を行うだけのクラスにする

1点目はテストを書くときにRepositoryクラスを丸々モックするだけで済むようにするため(StateNotifier側にfirebaseオブジェクトがあるとそっちもモックしないといけなくなります)
2点目はこのクラスがテストできない以上自作ロジックは最小限にしましょうということです

以下のような関数を持つクラスを作っていきます。

  • uidを受け取ってドキュメントのsnapshotを取得し、StateNotifier側から受け取ったリスナを登録する
  • listenerを破棄する(今回は使いませんが、ログアウト時の処理などに組み込む想定です)
class UserRepository {
  final _db = FirebaseFirestore.instance;
  final _auth = FirebaseAuth.instance;

  Stream<DocumentSnapshot> _userStream;
  StreamSubscription _userStreamListener;
  DocumentReference _documentRef;

  Future<void> disposeStream() async {
    if (_userStreamListener != null) {
      await _userStreamListener.cancel();
    }
    _userStream = null;
  }

  void subscribeStream(
    void Function(Map<String, dynamic>) onCompleted, {
    void Function() onEmpty,
  }) {
    final uid = _auth.currentUser.uid;
    final documentPath = getUserDocumentPath(uid);
    _documentRef = _db.doc(documentPath);

    _userStream = _documentRef.snapshots();
    _userStreamListener = _userStream.listen((DocumentSnapshot snapshot) {
      if (snapshot.exists) {
        onCompleted(snapshot.data());
      } else {
        onEmpty();
      }
    });
  }
}

subscribeStreamメソッドではコールバックをいくつかとっていますが、このような構成にしておくとうまく通信と状態変更を分離できると思います。テストも書きやすくなるでしょう。

状態管理クラス

Userデータモデル

firestoreからのデータをアプリケーション側で使えるようにする際普通にmapや自作クラスにしてもいいのですがfreezedを使ってimmutableなデータクラスを作ったほうが使い勝手がいいと思われます。一応サンプルを載せておきます。


abstract class User implements _$User {
  const factory User({
     String uid,
     String name,
    String avatarPath,
    String aboutMe,
     String gender,
    String job,
    int followerCount,
  }) = _User;

  const User._();

  factory User.fromMap(Map<String, dynamic> data) {
    return User(
      uid: data['uid'] as String,
      name: data['name'] as String,
      avatarPath: data['avatarPath'] as String,
      aboutMe: data['aboutMe'] as String,
      gender: data['gender'] as String,
      job: data['job'] as String,
      followerCount: data['followerCount'] as int,
    );
  }

  static Map<String, dynamic> toMap(User user) {
    return <String, dynamic>{
      'uid': user.uid,
      'name': user.name,
      'avatarPath': user.avatarPath,
      'aboutMe': user.aboutMe,
      'gender': user.gender,
      'job': user.job,
      'followerCount': user.followerCount,
    };
  }
}

以下の記述はは公式のこのあたりに詳細が書いてあります

const User._();

StateNotifier

ではいよいよ状態管理クラスを作成していきます。


abstract class UserState with _$UserState {
  const factory UserState({
    (false) bool isFetching,
    User user,
  }) = _UserState;
}

class UserStateNotifier extends StateNotifier<UserState> {
  UserStateNotifier({
     this.userRepository,
  }) : super(const UserState());

  final UserRepository userRepository;

  void init() {
    state = state.copyWith(isFetching: true);
    userRepository.subscribeStream(
      _onDocumentFetched,
      onEmpty: _disposeUser,
    );
  }

  void _disposeUser() {
    state = state.copyWith(user: null, isFetching: false);
  }

  void _onDocumentFetched(Map<String, dynamic> data) {
    state = state.copyWith(isFetching: false, user: User.fromMap(data));
  }
}

ポイントは上述の通り、状態変更の処理_onDocumentFetched_disposeUserをコールバックとしてRepositoryクラスに渡していることです。こうすると通信と状態変更が分離できており、ユニットテストが書きやすくなります。

init()はコンストラクタ内で呼び出してもいいですが、ユニットテストのときに呼び出すタイミングをコントロールしたい(後述します)関係上、今回は外から呼び出せるようにします。
アプリケーションコードの中でも以下のように初期化できます。

final userProvider = StateNotifierProvider(
  (ref) => UserStateNotifier(
    userRepository: ref.read(userRepository),
  )..init(),
);

まとめ

一旦前編はこれで終了です。このパートで言いたかったことは以下のとおりです。

  • firebaseなどの外部サービスとの通信は状態管理クラスからRepositoryクラスなどに切り離そう
    • Repositoryクラスから返される値はMockできるものに限定しよう
    • Repositoryクラスは極力外部との通信のみに専念しよう
  • 状態変更の処理など通信の結果に合わせてStateNotifier内で行いたい処理はコールバックとしてRepositoryクラスに渡そう

サンプルが結構単純なコードになってしまいましたが、基礎的な部分は押さえられたかと思います。基本的に上のポイントに従って実装していれば自作ロジックが複雑になってもうまくその処理をテストすることができるようになると思います。

後編ではユニットテストのサンプルを乗せるのでそちらもご覧ください。

なにか間違い、アドバイスなどありましたらぜひコメント欄にお願いします

Discussion

よねよね

非常に参考になりました.記事執筆ありがとうございます.
一点だけ質問させてください.
StateNotifierのuserRepository.subscribeStream箇所で私のコードでは引数uidのところにエラーが出ます.以下エラー文です.

Undefined name 'uid'.
Try correcting the name to one that is defined, or defining the name.

state.user.uidではないかなと思っているのですがどうでしょうか?

Shunei HayakawaShunei Hayakawa

ごめんなさい、そこ思いっきり間違えてます! Userドキュメントのストリームを登録する段階ではおそらくfirebase authのログインは済んでいる想定なので、UserRepository内でcurrentUser.uidでauthからuidを取得してfirestoreに通信しに行く感じです。追記しときます。指摘ありがとうございました!