Flutter のテストを三つの層で理解する
Flutter のテストは unit・widget・integration の三層に分かれる。それぞれが何を速く・何を現実に近く検証するのか、依存の差し替え方まで含めて、最小の実コードで整理する。連載の最終回。
ここまでで、起動フロー・Widget ツリー・状態・Riverpod・非同期と、画面のコードを読む道具が そろいました。連載の最後は、そのコードを守る側——テストです。
Flutter のテストは種類がいくつかあって、最初はどれを書けばいいか迷います。ですが分け方の軸は 一つだけ。**「速く・狭く検証するか」対「遅く・現実に近く検証するか」**です。この軸で unit / widget / integration の三層を整理すれば、迷いはなくなります。
§ 01WHYなぜテストを分けるのか
テストには相反する二つの望みがあります。
- 速く・狭く: ミリ秒で終わり、ロジックだけを切り出して検証したい
- 現実に近く: 実際のアプリを動かし、画面遷移や通信までまるごと検証したい
両立はしないので、目的ごとに層を分けます。
- unit … UI に依存しない純粋なロジック
- widget … 単一画面の描画と操作
- integration / E2E … アプリ全体を通したユーザーフロー
そして数のバランスはピラミッドにします。土台に速い unit を厚く、真ん中に widget を適度に、 頂点に遅い E2E を少しだけ。速いテストで大半を守り、遅いテストは要所に絞る、という配分です。
§ 02UNITロジックを単体で検証する
計算、判定、変換、そして Notifier の状態遷移のように、UI を描かずに確かめられるものが unit テストの対象です。レンダリングを伴わないので高速です。
Provider を検証するときは、Widget を立てずに ProviderContainer を使います1ProviderContainer は Widget を介さない Riverpod のスコープ。ProviderScope はそれを Widget ツリーに埋め込んだもので、overrides による差し替えの仕組みは共通。。
test('increment で state が 1 増える', () {final container = ProviderContainer();addTearDown(container.dispose);expect(container.read(counterProvider), 0);container.read(counterProvider.notifier).increment();expect(container.read(counterProvider), 1);});
操作(increment)の後に、状態がどう変わったかを必ず検証します。呼んで終わりにせず、
expect で結果を確かめる——これがテストの最低条件です。
§ 03WIDGET画面を単体で描いて検証する
単一の画面やウィジェットの描画と操作を確かめるのが widget テストです。testWidgets の中で
WidgetTester を使い、ウィジェットをテスト環境に描いて操作します。
pumpWidget(...)… ウィジェットを描くpump()… 1 フレーム進める。pumpAndSettle()… アニメーションが収まるまで進める2pumpAndSettle()はスケジュールされたフレームが尽きるまで進める。無限に続くアニメーションがあると、既定 10 分の timeout まで pump を繰り返した末にFlutterErrorを投げてテストが失敗する。回数を制御したいときはpump()を必要な分だけ呼ぶ。find.byIcon/find.text/find.byKey… 要素を探す(Finder)tester.tap(...)… 操作する
testWidgets('+ ボタンで表示が 1 になる', (tester) async {await tester.pumpWidget(const ProviderScope(child: MaterialApp(home: CounterScreen())),);expect(find.text('0'), findsOneWidget);await tester.tap(find.byIcon(Icons.add));await tester.pump(); // 状態変更後のフレームを進めるexpect(find.text('1'), findsOneWidget);});
操作(tap)の後に pump でフレームを進め、表示が変わったことを検証しています。要素を
探すときは、画面全体からの文字列一致より、Key やセマンティクスで対象を特定するほうが
壊れにくく、意図も明確になります3find.text('保存') のような画面全体の文字列一致は、別の場所に同じ文字列があっても通ってしまう。find.byKey や親要素を起点にした絞り込みで、対象を一意に特定するほうが堅牢。。
§ 04E2Eアプリ全体を通して検証する
integration_test を使うと、実際のアプリを端末やデスクトップで動かし、ログイン → 画面遷移 →
送信、のような一連のフローを端から端まで検証できます。遅く、環境も要りますが、いちばん現実に
近い。
価値があるのは、モックでは再現できないレイヤー間の連鎖です。Provider の更新がルーターを 動かし、画面が切り替わる——こうした配線の正しさは、各層を個別にモックした unit / widget では 保証しきれません。だからこそ E2E は要所のユーザージャーニーに絞って書きます。
§ 05FAKE依存を差し替える
unit / widget を速く・決定的に保つには、通信や DB のような外部依存を偽物に差し替えます。
連載第4回で触れた Provider の overrides が、ここで効いてきます。
await tester.pumpWidget(ProviderScope(overrides: [itemRepositoryProvider.overrideWithValue(FakeItemRepository()),],child: const MaterialApp(home: ItemsScreen()),),);
本物のリポジトリを FakeItemRepository に差し替えれば、ネットワークなしで、狙った状態を
再現してテストできます。「Provider を依存性注入として使う」とは、まさにこの差し替えのこと
でした。
§ 06RULE何をどの層で
| 層 | 何を | 速さ | 使いどころ |
|---|---|---|---|
| unit | 純粋なロジック・状態遷移 | 速い | 計算・判定・Notifier |
| widget | 単一画面の描画と操作 | 中 | 画面単体の UI |
| E2E | アプリ全体のフロー | 遅い | 画面をまたぐ重要な導線 |
原則は 「ロジックは unit、単一画面は widget、画面をまたぐ現実の流れは E2E」。同じことを 検証できるなら、より速い下の層に寄せる——これがピラミッドの意味です。
§ 07SUMMARY連載のまとめ
- テストは speed 対 realism の軸で三層に分ける
- unit … UI 抜きのロジック。
ProviderContainerで Provider も検証 - widget …
testWidgetsで描いて操作し、表示の変化を検証 - E2E …
integration_testで実アプリのフロー。配線の正しさを守る - fake …
overridesで外部依存を差し替え、速く決定的に
これで連載は一周しました。起動 → Widget ツリー → 状態 → Riverpod → 非同期 → mounted → テスト。この 7 本で、Flutter の画面コードと Provider、そしてそれを守るテストを、「なぜそう 書くか」まで含めて読めるはずです。
ここから先の Flutter Hooks、ナビゲーション、Freezed、ローカル保存といったテーマは、また別の 連載で一つずつ掘り下げていきます。
- [1]
ProviderContainerは Widget を介さない Riverpod のスコープ。ProviderScopeはそれを Widget ツリーに埋め込んだもので、overridesによる差し替えの仕組みは共通。 ↩ - [2]
pumpAndSettle()はスケジュールされたフレームが尽きるまで進める。無限に続くアニメーションがあると、既定 10 分の timeout まで pump を繰り返した末にFlutterErrorを投げてテストが失敗する。回数を制御したいときはpump()を必要な分だけ呼ぶ。 ↩ - [3]
find.text('保存')のような画面全体の文字列一致は、別の場所に同じ文字列があっても通ってしまう。find.byKeyや親要素を起点にした絞り込みで、対象を一意に特定するほうが堅牢。 ↩