【Flutter】RiverpodでTestableなアプリ開発
更新情報
■ 2022年7月5日(火)
・FlutterSDKのバージョンを3.0.4にアップデートしました。
・ライブラリのバージョンを最新にアップデートしました。
当記事を書いたきっかけ
今まで参加してきた開発のお仕事では
リリースが最優先で、テストコードは優先度が低くなる傾向が多かったです。
そんな中、開発しながらテストコードを書ける仕組みはないか?と
考えていたところ、Riverpodは、ProviderやGetXと比べて、
テストコードが書き易いという噂を何度か耳にしました。
当記事では、Riverpodの公式ドキュメントのTesting を参考にしながら、
通信処理部分のテストコードを書くためのベストな方法を模索してみようと思います。
カウンターアプリをRiverpod化する
今回は、flutter create
で自動生成されるカウンターアプリで検証を進めてみようと思います。
まずは、状態管理部分をStatefulWidget
からRiverpod
に移行します。
※ main.dart
に書かれていたMyApp
やMyHomePage
は別クラスに切り分けています
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
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
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
+ 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
...前略...
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
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
copyWith
やfromJson
やtoJson
を使いたかったので、freezed
を使用しています。
DateTimeConverter
はこちらの記事を参考にさせて頂きました。
・【Flutter】freezedでフィールドに自分で作った型やDateTimeがあるとfromJsonが作れない問題
また、状態管理でStateNotifier
を使用する場合は、
直接、state
のフィールドを更新するとViewが更新されない場合があるようなので、
freezed
等でimmutable
なモデルにしておいた方が良いみたいです(勉強になりました)
この辺りは、こちらの記事を参考にさせて頂きました。
・Flutter/Dartにおけるimmutableの実践的な扱い方
L StateNotifierはimmutableなstate値であることが前提となっている
+ 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.when
でWidget
を出し分けています。
...前略...
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側に知らせるために、_currentCountProvider
はFutureProvider
にして、
counterProvider
のState
はAsyncValue<Count?>>
にしています。
Riverpodの初期化中の非同期処理はこちらのIssueを参考にさせて頂きました。
・Initialize StateNotifierProvider with async data #57
今回、ログイン機能は実装していないので、
全てのユーザーで最終カウントを共用する仕様にしています。
そのため、1種類のcountId
をConstants
クラスに定義して参照する形にしています。
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
を入れています。
また、テストコードを書く時にProviderScope
のoverrides
で、
通信処理の結果をダミーデータに差し替えたいので、
countRepositoryProvider
で外部から参照できるようにしています。
+ 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を差し替える
テストコードでは、以下の懸念点を避けるために
別システムの通信などに依存しない形で実装する事が大切とされているそうです。
- テストの実行が遅くなるかもしれない
- 別システムが予期せぬ結果を返すかもしれない
- 別システムを使用して起こり得る全ての成功と失敗を洗い出せないかもしれない
この辺りは、Flutterの公式ドキュメントにも書かれていました。
・Mock dependencies using Mockito
そのため、今回のテストコードでは、Riverpodの機能を使用して、
Firestoreの通信処理をダミーデータに差し替えられるようにします。
widget_test.dart
ProviderScope
のoverrides
に
countRepositoryProvider.overrideWithValue(FakeCountRepository())
を渡す事で、
countRepositoryProvider
が参照しているRepository
を
CountRepository
からFakeCountRepository
に差し替えています。
FakeCountRepository
のgetCount
が5
を返すようにしているので、
ローディングが終わった後のテストコードは5
をexpect
するようにしています。
test
フォルダ内のファイルパスは
package:testable_riverpod_sample/
のような形式では
参照できないので、相対パスで参照しています。
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
するためのダミーデータを定義しています。
+ 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]
getCount
でFuture.value(DummyCount.initialValue)
をreturn
する事により、
通信処理の結果をダミーデータに差し替えています。
+ 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テストが成功しました!
今後の方針
今後は、以下の方針でTestableなアプリ開発を目指していこうと思います!
-
Repository
の追加や更新を行う時はFakeRepository
の追加や更新も行う -
Model
の追加や更新を行う時はDummyModel
の追加や更新も行う -
View
やController
の追加や更新を行う時はWidgetテストの追加や更新も行う
最後に
GitHubにサンプルコードを上げていますので、必要な方は、参考にして下さい🙋🏻♂️
「Flutter好きが集まる朝もく会」というFlutterの朝活勉強会を定期開催しております!
「ご質問がある方」や「一緒に黙々と作業したい方」は参加してみて頂けると嬉しいです♫
-
差し替え元の
Repository
を継承していないと以下のエラーが出て、差し替えられないです。
↩︎
Discussion