💯

【Flutter】RiverpodでTestableなアプリ開発

2022/02/21に公開

更新情報

■ 2022年7月5日(火)
・FlutterSDKのバージョンを3.0.4にアップデートしました。
・ライブラリのバージョンを最新にアップデートしました。

当記事を書いたきっかけ

今まで参加してきた開発のお仕事では
リリースが最優先で、テストコードは優先度が低くなる傾向が多かったです。

そんな中、開発しながらテストコードを書ける仕組みはないか?と
考えていたところ、Riverpodは、ProviderやGetXと比べて、
テストコードが書き易いという噂を何度か耳にしました。

当記事では、Riverpodの公式ドキュメントのTesting を参考にしながら、
通信処理部分のテストコードを書くためのベストな方法を模索してみようと思います。

カウンターアプリをRiverpod化する

今回は、flutter createで自動生成されるカウンターアプリで検証を進めてみようと思います。
まずは、状態管理部分をStatefulWidgetからRiverpodに移行します。
main.dartに書かれていたMyAppMyHomePageは別クラスに切り分けています

pubspec.yaml

testable_riverpod_sample/pubspec.yaml
name: testable_riverpod_sample
description: A Flutter project for testable riverpod sample.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^2.17.5
  flutter: ^3.0.4

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
+ flutter_riverpod: ^1.0.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.1

flutter:
  uses-material-design: true

main.dart

testable_riverpod_sample/lib/main.dart
import 'package:flutter/material.dart';
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
+ import 'package:testable_riverpod_sample/app.dart';

- void main() => runApp(MyApp());
+ void main() => runApp(const ProviderScope(child: App()));

View

testable_riverpod_sample/lib/presentation/counter_page.dart
import 'package:flutter/material.dart';
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
+ import 'package:testable_riverpod_sample/presentation/counter_controller.dart';

- class MyHomePage extends StatefulWidget {
+ class CounterPage extends ConsumerWidget {
  const CounterPage({Key? key, required this.title}) : super(key: key);
  final String title;

-  
-  State<MyHomePage> createState() => _MyHomePageState();
- }
- 
- class _MyHomePageState extends State<MyHomePage> {
-   int _counter = 0;
- 
-   void _incrementCounter() {
-     setState(() {
-       _counter++;
-     });
-   }
- 
   
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
+   final state = ref.watch(counterProvider);
+   final controller = ref.read(counterProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: Text(title), centerTitle: true),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
            Text(
-             '$_counter',
+             '$state',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
-       onPressed: () => _incrementCounter,
+       onPressed: () => controller.increment(),
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Controller

testable_riverpod_sample/lib/presentation/counter_controller.dart
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
+ 
+ final counterProvider = StateNotifierProvider<CounterController, int>(
+     (ref) => CounterController(ref));
+ 
+ class CounterController extends StateNotifier<int> {
+   CounterController(this.ref) : super(0);
+   final Ref ref;
+ 
+   void increment() => state++;
+ }

最終カウントをFirestoreに保存する

通信処理を実装するためにカウンターアプリをFirestoreに接続し、
最終カウントをFirestoreに保存する処理を実装しています。

pubspec.yaml

testable_riverpod_sample/pubspec.yaml
...前略...
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^1.0.4
+  firebase_core: ^1.19.1
+  cloud_firestore: ^3.2.1
+  freezed_annotation: ^2.0.3
+  json_serializable: ^6.1.4
+  json_annotation: ^4.4.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.1
+  build_runner: ^2.1.7
+  freezed: ^2.0.3+1
...後略...

main.dart

testable_riverpod_sample/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:testable_riverpod_sample/app.dart';
+ import 'package:firebase_core/firebase_core.dart';

- void main() => runApp(const ProviderScope(child: App()));
+ void main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+  await Firebase.initializeApp();
+  runApp(const ProviderScope(child: App()));
+}

Model

copyWithfromJsontoJsonを使いたかったので、freezedを使用しています。

DateTimeConverterはこちらの記事を参考にさせて頂きました。
【Flutter】freezedでフィールドに自分で作った型やDateTimeがあるとfromJsonが作れない問題

また、状態管理でStateNotifierを使用する場合は、
直接、stateのフィールドを更新するとViewが更新されない場合があるようなので、
freezed等でimmutableなモデルにしておいた方が良いみたいです(勉強になりました)

この辺りは、こちらの記事を参考にさせて頂きました。
Flutter/Dartにおけるimmutableの実践的な扱い方
 L StateNotifierはimmutableなstate値であることが前提となっている

testable_riverpod_sample/lib/domain/count.dart
+ import 'package:cloud_firestore/cloud_firestore.dart';
+ import 'package:flutter/foundation.dart';
+ import 'package:freezed_annotation/freezed_annotation.dart';
+ import 'package:testable_riverpod_sample/utility/date_time_converter.dart';
+ 
+ part 'count.freezed.dart';
+ part 'count.g.dart';
+ 
+ 
+ class Count with _$Count {
+   const factory Count({
+     required String countId,
+     required int count,
+     () DateTime? updatedAt,
+   }) = _Count;
+ 
+   factory Count.fromJson(Map<String, dynamic> json) => _$CountFromJson(json);
+ }

View

counterProviderを初期化する時にFirestoreから最終カウントを取得しているので、
取得状況に合わせて、state.whenWidgetを出し分けています。

testable_riverpod_sample/lib/presentation/counter_page.dart
...前略...
  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(counterProvider);
    final controller = ref.read(counterProvider.notifier);

+    Widget _counterText(String text) =>
+        Text(text, style: Theme.of(context).textTheme.headline4);

    return Scaffold(
      appBar: AppBar(title: Text(title), centerTitle: true),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
-           Text(
-            '$state',
-             style: Theme.of(context).textTheme.headline4,
-           ),
+           state.when(
+             loading: () => _counterText('読み込み中...'),
+             error: (error, _) => _counterText('$error'),
+             data: (count) => _counterText('${count?.count ?? 0}'),
+           ),
          ],
        ),
...後略...

Controller

_currentCountProviderでFirestoreから最終カウントを取得しています。
取得状況をView側に知らせるために、_currentCountProviderFutureProviderにして、
counterProviderStateAsyncValue<Count?>>にしています。

Riverpodの初期化中の非同期処理はこちらのIssueを参考にさせて頂きました。
Initialize StateNotifierProvider with async data #57

今回、ログイン機能は実装していないので、
全てのユーザーで最終カウントを共用する仕様にしています。
そのため、1種類のcountIdConstantsクラスに定義して参照する形にしています。

testable_riverpod_sample/lib/presentation/counter_controller.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
+ import 'package:testable_riverpod_sample/domain/count.dart';
+ import 'package:testable_riverpod_sample/repository/count_repository.dart';
+ import 'package:testable_riverpod_sample/utility/constants.dart';

+ final _currentCountProvider = FutureProvider(
+     (ref) => ref.read(countRepositoryProvider).getCount(Constants.countId));

- final counterProvider = StateNotifierProvider<CounterController, int>(
-     (ref) => CounterController(ref));
+ final counterProvider =
+    StateNotifierProvider<CounterController, AsyncValue<Count?>>((ref) {
+  final repo = ref.read(countRepositoryProvider);
+  final currentCount = ref.watch(_currentCountProvider);
+  return CounterController(repo, currentCount);
+});

- class CounterController extends StateNotifier<int> {
+ class CounterController extends StateNotifier<AsyncValue<Count?>> {
- CounterController(this.ref) : super(0);
+ CounterController(this._repo, currentCount) : super(currentCount);

- final Ref ref;
+ final CountRepository _repo;

- void increment() => state++;
+ void increment() {
+   if (state.value == null) return;
+   state = AsyncValue.data(
+     state.value!.copyWith(
+       count: state.value!.count + 1,
+       updatedAt: DateTime.now(),
+     ),
+   );
+   _repo.setCount(state.value!);
+ }
}

Repository

Firestoreとの通信処理(登録と取得)を実装しています。

取得処理が速過ぎて、ローディングが正しく表示される事が確認できなかったので、
今回は、表示確認用に3秒間のFuture.delayedを入れています。

また、テストコードを書く時にProviderScopeoverridesで、
通信処理の結果をダミーデータに差し替えたいので、
countRepositoryProviderで外部から参照できるようにしています。

testable_riverpod_sample/lib/ripository/count_repository.dart
+ import 'package:cloud_firestore/cloud_firestore.dart';
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
+ import 'package:testable_riverpod_sample/domain/count.dart';

+ final countRepositoryProvider = Provider((ref) => CountRepository());

+ class CountRepository {
+   final _store = FirebaseFirestore.instance;
+ 
+   Future<void> setCount(Count count) =>
+      _store.collection('counts').doc(count.countId).set(count.toJson());
+ 
+   Future<Count?> getCount(String countId) async {
+     // ローディングの表示確認用
+     await Future.delayed(const Duration(seconds: 3));
+ 
+     return _store.collection('counts').doc(countId).get().then((snap) {
+       if (snap.data() == null) return null;
+       return Count.fromJson(snap.data()!);
+     });
+   }
+ }

テストコードでRepositoryを差し替える

テストコードでは、以下の懸念点を避けるために
別システムの通信などに依存しない形で実装する事が大切とされているそうです。

  1. テストの実行が遅くなるかもしれない
  2. 別システムが予期せぬ結果を返すかもしれない
  3. 別システムを使用して起こり得る全ての成功と失敗を洗い出せないかもしれない

この辺りは、Flutterの公式ドキュメントにも書かれていました。
Mock dependencies using Mockito

そのため、今回のテストコードでは、Riverpodの機能を使用して、
Firestoreの通信処理をダミーデータに差し替えられるようにします。

widget_test.dart

ProviderScopeoverrides
countRepositoryProvider.overrideWithValue(FakeCountRepository())を渡す事で、
countRepositoryProviderが参照しているRepository
CountRepositoryからFakeCountRepositoryに差し替えています。

FakeCountRepositorygetCount5を返すようにしているので、
ローディングが終わった後のテストコードは5expectするようにしています。

testフォルダ内のファイルパスは
package:testable_riverpod_sample/のような形式では
参照できないので、相対パスで参照しています。

testable_riverpod_sample/test/widget_test.dart
import 'package:flutter/material.dart';
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+ import 'package:testable_riverpod_sample/app.dart';
+ import 'package:testable_riverpod_sample/repository/count_repository.dart';
- import 'package:testable_riverpod_sample/main.dart';

+ import 'repository/fake_count_repository.dart';

void main() {
  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    // Build our app and trigger a frame.
-   await tester.pumpWidget(const MyApp());
+   await tester.pumpWidget(
+     ProviderScope(
+       overrides: [
+         countRepositoryProvider.overrideWithValue(FakeCountRepository())
+       ],
+       child: const App(),
+     ),
+   );

-   // Verify that our counter starts at 0.
+   // The first frame is a loading state.
-   expect(find.text('0'), findsOneWidget);
+   expect(find.text('読み込み中...'), findsOneWidget);
+   expect(find.text('5'), findsNothing);
-   expect(find.text('1'), findsNothing);
+   expect(find.text('6'), findsNothing);
+
+   // Re-render. TodoListProvider should have finished fetching the todos by now
+   await tester.pump();
+
-   // Verify that our counter starts at 0.
+   // Verify that our counter starts at 5.
-   expect(find.text('0'), findsOneWidget);
+   expect(find.text('読み込み中...'), findsNothing);
+   expect(find.text('5'), findsOneWidget);
-   expect(find.text('1'), findsNothing);
+   expect(find.text('6'), findsNothing);

    // Tap the '+' icon and trigger a frame.
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    // Verify that our counter has incremented.
-   expect(find.text('0'), findsNothing);
+   expect(find.text('読み込み中...'), findsNothing);
+   expect(find.text('5'), findsNothing);
-   expect(find.text('1'), findsOneWidget);
+   expect(find.text('6'), findsOneWidget);
  });
}

DummyModel

テストコードでFakeRepositoryが呼ばれた時に
returnするためのダミーデータを定義しています。

testable_riverpod_sample/test/domain/dummy_count.dart
+ import 'package:testable_riverpod_sample/domain/count.dart';
+ import 'package:testable_riverpod_sample/utility/constants.dart';
+
+ class DummyCount {
+  static Count initialValue = Count(
+     countId: Constants.countId,
+     count: 5,
+     updatedAt: DateTime(2022, 2, 14),
+   );
+ }

FakeRepository

countRepositoryProvider.overrideWithValue(FakeCountRepository())
Repositoryを差し替えるために、CountRepositoryを継承しています[1]

getCountFuture.value(DummyCount.initialValue)returnする事により、
通信処理の結果をダミーデータに差し替えています。

testable_riverpod_sample/test/repository/fake_count_repository.dart
+ import 'package:testable_riverpod_sample/domain/count.dart';
+ import 'package:testable_riverpod_sample/repository/count_repository.dart';
+ 
+ import '../domain/dummy_count.dart';
+ 
+ class FakeCountRepository implements CountRepository {
+   
+   Future<void> setCount(Count count) => Future.value();
+ 
+   
+   Future<Count?> getCount(String countId) =>
+       Future.value(DummyCount.initialValue);
+ }

Widgetテストを実行してみる

FakeRepositoryに差し替えた上で、Widgetテストが成功しました!
2_success_test_2022_0221.png

今後の方針

今後は、以下の方針でTestableなアプリ開発を目指していこうと思います!

  1. Repositoryの追加や更新を行う時はFakeRepositoryの追加や更新も行う
  2. Modelの追加や更新を行う時はDummyModelの追加や更新も行う
  3. ViewControllerの追加や更新を行う時はWidgetテストの追加や更新も行う

最後に

GitHubにサンプルコードを上げていますので、必要な方は、参考にして下さい🙋🏻‍♂️
https://bit.ly/3I4uGKn

「Flutter好きが集まる朝もく会」というFlutterの朝活勉強会を定期開催しております!
「ご質問がある方」や「一緒に黙々と作業したい方」は参加してみて頂けると嬉しいです♫
https://bit.ly/3t0T89k

脚注
  1. 差し替え元のRepositoryを継承していないと以下のエラーが出て、差し替えられないです。
    1_fake_repository_error_2022_0221.png ↩︎

Discussion