RiverpodのStreamProviderをモック化して、依存するProviderを単体テストする

# この記事は

[Cloud Firestore](https://firebase.google.com/docs/firestore?hl=ja)にはリアルタイムに最新の情報を取得する機能「[リアルタイムアップデート](https://firebase.google.com/docs/firestore/query-data/listen?hl=ja)」があり、[Flutterでも対応しています](https://firebase.flutter.dev/docs/firestore/usage/#realtime-changes)。

また、Flutterで見通しが良いコードでテスタブルな設計を推進したい場合、[Riverpod](https://riverpod.dev/)は有用です。

その2つの組み合わせを採用したときに、Firestoreを使用するProviderはStreamProviderになりますが、それをモック化して依存するProviderを単体テストするやり方を探すのに時間がかかったので記事にしました。

# 題材アプリ

社員リストアプリを今回のサンプルとして作成しました。
※ 氏名は[kotlin-faker](https://github.com/serpro69/kotlin-faker)で作成

![observe_firestore.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/ca7696ef-e291-c2df-9d1e-9cafec1a1567.gif)

スティッキーヘッダー（上に吸い付くヘッダー）を実現するために[flutter_sticky_header](https://pub.dev/packages/flutter_sticky_header)パッケージを使用しています。

データソースとして、Firestoreに部署名と氏名のペアを1ドキュメントとして100件保存しています。

![スクリーンショット 2020-11-15 1.28.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/538161ca-4111-8e8d-91e0-49c722ddc129.png)

# 必要なデータ変換

[flutter_sticky_header](https://pub.dev/packages/flutter_sticky_header)を使用するためには、予め **ヘッダー1 - 項目多** のリストを作成する必要があります。

このようなデータを

```src.yml
- division: Develop
  name: A
- division: Develop
  name: B
- division: Management
  name: C
- division: Management
  name: D
- division: Sales
  name: E
```

このようなデータに変換します。


```dst.yml
- division: Develop
  names:
    - A
    - B
- division: Management
  names:
    - C
    - D
- division: Sales
  names:
    - E

```

Widgetはこのようになります。[flutter_hooks](https://pub.dev/packages/flutter_hooks)とhook_riverpodを使用しています。


```main.dart
class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultStickyHeaderController(
        child: Scaffold(
            appBar: AppBar(
              title: Text("Member List"),
            ),
            body: MainBody()));
  }
}

class MainBody extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final futureDivisionList = useProvider(divisionListProvider);
    final snapshot = useFuture(futureDivisionList);
    if (snapshot.hasData) {
      final headerTextStyle =
          Theme.of(context).textTheme.subtitle2.copyWith(color: Colors.white);
      final headerBackgroundColor = Color.fromARGB(0xff, 0x66, 0x66, 0x66);
      final itemTextStyle = Theme.of(context).textTheme.bodyText2;
      return CustomScrollView(
        slivers: snapshot.data
            .map((e) => SliverStickyHeader(
                  header: Container(
                    // 部署名ヘッダー
                    child: Text(e.division, style: headerTextStyle),
                    padding: EdgeInsets.all(16),
                    decoration: BoxDecoration(color: headerBackgroundColor),
                  ),
                  sliver: SliverList(
                      delegate: SliverChildBuilderDelegate(
                          (context, i) => Container(
                              // 部署に所属するメンバー氏名
                              child: Text(e.names[i], style: itemTextStyle),
                              padding: EdgeInsets.fromLTRB(16, 8, 16, 8)),
                          childCount: e.names.length)),
                ))
            .toList(),
      );
    } else {
      return Center(child: CircularProgressIndicator());
    }
  }
}

```

# アーキテクチャ

今回は3層構造のMVPアーキテクチャを想定します。それぞれ1つ下のレイヤーにしか依存しません。

| レイヤー | 役割 |
|:-:|:-:|
| View  | FlutterのWidgetを構築  |
| Presenter  | Viewを制御する |
| Repository  |  データを出し入れする |

前節のデータ変換の役割はPresenterに持たせます。
FirestoreのAPIを直接使う担当がRepositoryになります。

## 単体テスト指針

Repositoryをモック化してPresenterを単体テストします。

## 補足

実際のアプリではPresenterとRepositoryの間に複雑なロジックを処理するためのUseCaseが挟まったり、SQLiteやREST APIなど、複数のデータソースを扱うためにRepositoryの下にDataStoreがあると思いますが、今回は説明をシンプルにするために3層構造にしました。

# Repository

Firestoreにアクセスしてリアルタイムに最新の情報を取得するProviderがこちらです。[snapshotメソッド](https://pub.dev/documentation/cloud_firestore/latest/cloud_firestore/Query/snapshots.html)の戻り値が[Stream](https://api.flutter.dev/flutter/dart-async/Stream-class.html)なので、[StreamProvider](https://pub.dev/documentation/riverpod/latest/all/StreamProvider-class.html)を使用します。


```repository.dart
final memberListStreamProvider = StreamProvider.autoDispose((_) {
  CollectionReference ref = FirebaseFirestore.instance.collection('member');
  return ref.snapshots().map((snapshot) => snapshot.docs
      .map((doc) => doc.data())
      .map((data) => Member(name: data['name'], division: data['division']))
      .toList());
});
```

# Presenter(単体テスト対象)

Repositoryから取り出したデータを使用して、表示のためのデータを作成するProviderがこちらです。[StreamProviderのlastプロパティ](https://pub.dev/documentation/riverpod/latest/all/StreamProvider/last.html)を[watch](https://pub.dev/documentation/riverpod/latest/all/ProviderReference/watch.html)することで[Future](https://api.dart.dev/stable/2.10.3/dart-async/Future-class.html)で包まれた最新の値を取得できます。Firestoreのコレクションが書き換わり、最新の値が変化すると、このProviderの状態も自動で更新されます。
awaitで取得を待って実際の値を取得できます。
内部でawaitを使用しているため、このProviderは `Future<List<Division>>` クラスのオブジェクトを状態として持つProviderになっています。よってView層では[useFuture](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html)を使用しています。

```presenter.dart
final divisionListProvider = Provider.autoDispose((ref) async {
  // 最新の情報をwatchする
  final futureMemberList = ref.watch(memberListStreamProvider.last);
  // Future<List<Member>>なので、値を取得できるまで待つ。
  final memberList = await futureMemberList;
  // 部署名順、次に氏名順に並べる。
  memberList.sort((a, b) {
    if (a.division == b.division) {
      return a.name.compareTo(b.name);
    } else {
      return a.division.compareTo(b.division);
    }
  });
  // 部署1 - メンバー多のデータに変換する
  final divisions = List<Division>();
  for (final e in memberList) {
    if (divisions.isEmpty || divisions.last.division != e.division) {
      divisions.add(Division(division: e.division, names: []));
    }
    divisions.last.names.add(e.name);
  }
  return divisions;
});
```

# Repositoryをモック化してPresenterをテストする

[単体テストでProviderを使用したいときはProviderContainerを使用します。](https://riverpod.dev/docs/concepts/reading/)

ProviderContainerのoverridesフィールドを使用すると、指定したProviderをモックに差し替えることができます。[readメソッド](https://pub.dev/documentation/riverpod/latest/all/ProviderContainer/read.html)で取得した状態はFutureで包まれているので、awaitで値の取得を待つ必要があります。

```presenter_test.dart
void main() {
  test('Member list with division header', () async {
    final members = [
      Member(division: 'Management', name: 'D'),
      Member(division: 'Management', name: 'C'),
      Member(division: 'Sales', name: 'E'),
      Member(division: 'Develop', name: 'B'),
      Member(division: 'Develop', name: 'A'),
    ];
    final expected = [
      Division(division: 'Develop', names: ['A', 'B']),
      Division(division: 'Management', names: ['C', 'D']),
      Division(division: 'Sales', names: ['E'])
    ];
    final container = ProviderContainer(overrides: [
      memberListStreamProvider.overrideWithValue(AsyncValue.data(members))
    ]);
    final futureDivisionList = container.read(divisionListProvider);
    final divisionList = await futureDivisionList;
    expect(divisionList, expected);
  });
}
```

# 全体ソースコード

この記事を書くために作成したアプリの全体ソースコードは[こちら](https://github.com/tfandkusu/observe_firestore_flutter)にあります。

# 補足

## Firebase Authenticationと組み合わせる場合

[Firebase Authentication](https://firebase.google.com/docs/auth?hl=ja)での認証状態に依存するStreamProviderを作る場合はこちらの記事もご参照ください。

[RiverpodでFirestoreとFirebase AuthをStreamProviderで組み合わせる](https://qiita.com/tfandkusu/items/e6446008dee2ae375105)