🔥

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

2020/10/17に公開

前編の続きです。こちらでは前回作成した状態管理クラスをどうテストするか書いていきます。

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

環境

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

前回作成したクラス

外部との通信をするRepositoryクラス

class UserRepository {
  final _db = FirebaseFirestore.instance;

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

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

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

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

データクラス


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,
    };
  }
}

状態管理クラス


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(
      uid,
      _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));
  }
}

ユニットテスト

テストの目的は自作ロジックのテストでしょう。前回コールバックとして状態変更の処理を渡すような形でクラスを作ったので今回はMockitoとRiverpodのProviderContainerをどのように使ってテストを書いていくかを紹介したいと思います。

テストケースは以下の2つとします

  1. 初回fetchでユーザードキュメントが存在した場合、その情報が正しくstateに反映されている
  2. 初回fetchでユーザードキュメントが存在しなかった場合、stateがnullになっている

前提

テストを書く前にriverpodでRepositoryクラスの振る舞いを自由に定義する方法を書いておきます。

まず以下のようにテストファイル内でグローバル変数として普通にStateNotifierProviderを生成します。

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

その後以下のようにMockitoProviderContainerを使用してDIしているuserRepositoryをオーバーライドすることができます。ProviderContainerを生成しオーバーライドする処理は基本すべてのテストで行うので関数として切り出しておきましょう。

こうするとテスト内でProviderContainerを通じてuserProviderを呼び出した場合には中のuserRepositoryMockedUserRepositoryに変わります。

class MockedUserRepository extends Mock implements UserRepository {}

ProviderContainer override() {
  return ProviderContainer(
    overrides: [
      userRepository.overrideWithProvider(
        Provider((ref) => MockedUserRepository()),
      ),
    ],
  );
}

ケース1

test('case1', () {
  final container = override();

  final notifier = container.read(userProvider);
  final dummyData = User.toMap(userModelDummy);

  when(notifier.userRepository.subscribeStream(
    any,
    any,
    onEmpty: anyNamed('onEmpty'),
  )).thenAnswer((realInvocation) {
    realInvocation.positionalArguments[1](dummyData);
  });

  notifier.init();

  verify(notifier.userRepository.subscribeStream(
    any,
    any,
    onEmpty: anyNamed('onEmpty'),
  )).called(1);

  expect(container.read(userProvider.state).isFetching, isFalse);
  expect(container.read(userProvider.state).user, userModelDummy);
});

この部分でcontainerを通じて呼び出すことにより、オーバーライドしています

final notifier = container.read(userProvider);

Streamの振る舞いを定義したい場合はthenAnswerを使用します。引数に渡ってくるrealInvocationには実際の呼び出しをオブジェクト化したものが入っており、渡ってきた引数や返り値の型、関数のタイプなど様々な情報が格納されています。
今回はコールバックとして渡したonCompletedを取り出したいので、名前無し引数として渡したオブジェクトのリストが格納されているpositionalArgumentsから該当するindexで取り出し、発火しています。

when(notifier.userRepository.subscribeStream(
  any,
  any,
  onEmpty: anyNamed('onEmpty'),
)).thenAnswer((realInvocation) {
  realInvocation.positionalArguments[1](dummyData);
});

コールバックに特定の引数を与えて呼び出し、その後のstateなどを比較することでコールバック内の自作ロジックがうまく動いているかをテストできます。今回はダミーデータとして与えたMap(firestoreからもらうデータ)がそのままstateに反映されているかを確認しています。自作ロジックが複雑になった場合でもこの方法でテストすることができます。
またverifyを使ってsubscribeStreamが呼び出されたかも確認しています

verify(notifier.userRepository.subscribeStream(
  any,
  any,
  onEmpty: anyNamed('onEmpty'),
)).called(1);

expect(container.read(userProvider.state).isFetching, isFalse);
expect(container.read(userProvider.state).user, userModelDummy);

ケース2

test('case2', () {
  final container = override();

  final notifier = container.read(userProvider);

  when(notifier.userRepository.subscribeStream(
    any,
    any,
    onEmpty: anyNamed('onEmpty'),
  )).thenAnswer((realInvocation) {
    realInvocation.namedArguments[const Symbol('onEmpty')]();
  });

  notifier.init();

  verify(notifier.userRepository.subscribeStream(
    any,
    any,
    onEmpty: anyNamed('onEmpty'),
  )).called(1);

  expect(container.read(userProvider.state).isFetching, isFalse);
  expect(container.read(userProvider.state).user, isNull);
});

1と同様にcontainerを生成し、以下の部分でsubscribeStreamの振る舞いを定義しています。今回はドキュメントが存在しなかったときに呼び出されるコールバックをテストしたいのでonEmptyとして渡した関数をnamedArgumentsの中から取り出しています(namedArgumentsの中にはSymbol型をkeyとするMapが入っています)
名前付き引数にはanyではなくanyNamedを使うことに注意してください

when(notifier.userRepository.subscribeStream(
  any,
  any,
  onEmpty: anyNamed('onEmpty'),
)).thenAnswer((realInvocation) {
  realInvocation.namedArguments[const Symbol('onEmpty')]();
});

まとめ

今回紹介したことは以下の2点です。

  • ProviderContainerを使用するとDIしている他のProviderを簡単にオーバーライドできる
  • 自作ロジックが複雑になってもRepositoryクラスとして外部との通信を切り出すなどの工夫で簡単にテストできる

ネットで検索してもそこまでサンプルが多くない感じだったので一つの例として使っていただけると幸いです。とにかくRiverpodは簡単にテストができるようになっているので(Provider時代のテストを書き方は知りませんが💦)、テスト書いてみてください

なにか間違い、アドバイスなどございましたら是非コメントお願いします。

Discussion