Flutterの環境切替と配布前チェック(flavor / release build / 権限確認) の次に入れておきたいのが、画面ロジックの自動確認です。止まりやすいのは testWidgets() の書き方そのものではありません。状態更新、依存先、UI が 1 か所に寄り、どこを差し替えればテストしやすくなるか見えないまま書き始める点にあります。この記事では flutter_riverpod を使った検品一覧画面を題材に、画面表示、ボタン押下後の変化確認、Provider 差し替えまでを最小構成で確認します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - Riverpod の最小構成は見たが、Widget テストでどこを確認すればよいかまだ曖昧な人
- 納品前確認や回帰確認のために、画面ロジックの自動確認を先に入れたい人
この記事で到達する状態
flutter_testで画面表示テストを書ける- ボタン押下後の件数変化と活性状態の変化を確認できる
- Riverpod の Provider 差し替えで初期データを入れ替えられる
- Widget テスト、単体テスト、Integration Test の役割差を説明できる
非対象
integration_testを使った画面遷移の確認- カメラ、Bluetooth、secure storage など実機依存プラグインのテスト
- Golden Test
- API 通信や DB を含む結合テスト
今回は Widget テストの入口に絞ります。端末依存や画面遷移まで一度に入れると、何をどこで確認しているのかがぼけやすくなります。まずは「画面が正しく出るか」「操作後に表示が変わるか」「依存先を差し替えられるか」を 1 本で固めるところから始めます。
2. 先に Widget テストの守備範囲を整理する
Widget テストは、実機やエミュレーターを立ち上げずに Widget ツリーを組み立て、UI と状態変化を確認するテストです。見る範囲を先に分けておくと、あとで「どこまでを自動化するか」「どこからは Integration Test へ回すか」を整理しやすくなります。
| 種類 | 主に確認するもの | 今回の扱い |
|---|---|---|
| 単体テスト | 純粋関数、計算、整形、変換 | 8章で切り分け方だけ触れる |
| Widget テスト | 画面表示、ボタン押下、状態反映 | この記事の中心 |
| Integration Test | 画面遷移、実機依存、アプリ全体の流れ | 後続記事へ回す |
今回の流れは次の通りです。
flowchart LR
A[test/widget_test.dart] --> B[ProviderScope override]
B --> C[MyApp]
C --> D[InspectionPage]
D --> E[表示を検証]
A --> F[ボタンを tap]
F --> G[InspectionController]
G --> H[InspectionState]
H --> D
D --> I[件数とボタン状態を再検証]
この図で見ておきたい点は 2 つです。
- 依存先の差し替えは
ProviderScopeの override で行う - 画面の操作は
InspectionControllerを経由して状態へ反映される
差し替え点を Provider に寄せておくと、テスト側は「どのデータで画面を開くか」を毎回作り直せます。逆に、Widget の中で直接 DemoRepository() を生成してしまうと、テストのたびに初期データを入れ替えにくくなります。
3. プロジェクトを作成して依存関係をそろえる
3-1. 環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create warehouse_widget_test
cd warehouse_widget_test
3-3. エミュレーターを起動する
Widget テストだけならエミュレーターは不要です。ただ、この記事では自動テストの前に flutter run で画面を一度見ておくので、先に起動します。
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. flutter_riverpod を追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add flutter_riverpod
flutter_test は flutter create 直後の pubspec.yaml にすでに入っています。今回の追加は flutter_riverpod だけで足ります。
4. lib/main.dart を書き換える
flutter create で生成された lib/main.dart は、いったん次の内容に書き換えます。
このファイルは、検品一覧、未確認件数、確認ボタン、Provider 差し替え点を 1 画面にまとめたサンプルです。注目点は、初期データの取得を inspectionRepositoryProvider に寄せ、UI から切り離していることです。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Widget Test Sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const InspectionPage(),
);
}
}
enum InspectionStatus {
pending('未確認'),
done('確認済み');
const InspectionStatus(this.label);
final String label;
}
class InspectionItem {
const InspectionItem({
required this.id,
required this.shelfLabel,
required this.itemName,
required this.status,
});
final String id;
final String shelfLabel;
final String itemName;
final InspectionStatus status;
bool get isDone => status == InspectionStatus.done;
InspectionItem markDone() {
return InspectionItem(
id: id,
shelfLabel: shelfLabel,
itemName: itemName,
status: InspectionStatus.done,
);
}
}
abstract class InspectionRepository {
List<InspectionItem> loadItems();
}
class DemoInspectionRepository implements InspectionRepository {
const DemoInspectionRepository();
@override
List<InspectionItem> loadItems() {
return const <InspectionItem>[
InspectionItem(
id: 'item-001',
shelfLabel: 'A-01',
itemName: 'ハンディ端末',
status: InspectionStatus.pending,
),
InspectionItem(
id: 'item-002',
shelfLabel: 'B-02',
itemName: '充電ケーブル',
status: InspectionStatus.pending,
),
InspectionItem(
id: 'item-003',
shelfLabel: 'C-03',
itemName: '予備バッテリー',
status: InspectionStatus.done,
),
];
}
}
final Provider<InspectionRepository> inspectionRepositoryProvider =
Provider<InspectionRepository>((Ref ref) {
return const DemoInspectionRepository();
});
class InspectionState {
const InspectionState({
required this.items,
this.pendingOnly = false,
});
final List<InspectionItem> items;
final bool pendingOnly;
int get pendingCount =>
items.where((InspectionItem item) => !item.isDone).length;
int get doneCount => items.length - pendingCount;
List<InspectionItem> get visibleItems {
if (!pendingOnly) {
return items;
}
return items.where((InspectionItem item) => !item.isDone).toList();
}
InspectionState copyWith({
List<InspectionItem>? items,
bool? pendingOnly,
}) {
return InspectionState(
items: items ?? this.items,
pendingOnly: pendingOnly ?? this.pendingOnly,
);
}
}
final NotifierProvider<InspectionController, InspectionState>
inspectionControllerProvider =
NotifierProvider<InspectionController, InspectionState>(
InspectionController.new,
);
class InspectionController extends Notifier<InspectionState> {
@override
InspectionState build() {
final InspectionRepository repository =
ref.watch(inspectionRepositoryProvider);
return InspectionState(items: repository.loadItems());
}
void togglePendingOnly(bool value) {
state = state.copyWith(pendingOnly: value);
}
void markDone(String itemId) {
state = state.copyWith(
items: <InspectionItem>[
for (final InspectionItem item in state.items)
if (item.id == itemId) item.markDone() else item,
],
);
}
}
class InspectionPage extends ConsumerWidget {
const InspectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final InspectionState state = ref.watch(inspectionControllerProvider);
final InspectionController controller =
ref.read(inspectionControllerProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Widgetテストで確認する検品画面'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'表示と状態変化を Widget テストで先に確認します。',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'Repository を Provider に寄せておくと、テスト側で初期データを差し替えやすくなります。',
),
],
),
),
),
const SizedBox(height: 16),
_SummaryCard(state: state),
const SizedBox(height: 16),
SwitchListTile(
key: const Key('pending-only-toggle'),
contentPadding: EdgeInsets.zero,
title: const Text('未確認だけ表示'),
value: state.pendingOnly,
onChanged: controller.togglePendingOnly,
),
const SizedBox(height: 8),
if (state.visibleItems.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('未確認の棚はありません。'),
),
)
else
...state.visibleItems.map(
(InspectionItem item) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _InspectionCard(
item: item,
onMarkDone: item.isDone
? null
: () => controller.markDone(item.id),
),
),
),
],
),
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.state});
final InspectionState state;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
_SummaryItem(
key: const Key('pending-count'),
label: '未確認',
value: '${state.pendingCount} 件',
),
_SummaryItem(
key: const Key('done-count'),
label: '確認済み',
value: '${state.doneCount} 件',
),
],
),
),
);
}
}
class _SummaryItem extends StatelessWidget {
const _SummaryItem({
required super.key,
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Text('$label $value', style: Theme.of(context).textTheme.titleMedium),
],
);
}
}
class _InspectionCard extends StatelessWidget {
const _InspectionCard({
required this.item,
required this.onMarkDone,
});
final InspectionItem item;
final VoidCallback? onMarkDone;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
item.shelfLabel,
style: Theme.of(context).textTheme.titleMedium,
),
Chip(label: Text(item.status.label)),
],
),
const SizedBox(height: 8),
Text(item.itemName),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
key: Key('done-button-${item.id}'),
onPressed: onMarkDone,
child: Text(item.isDone ? '確認済み' : '確認済みにする'),
),
),
],
),
),
);
}
}
コードのポイント
① inspectionRepositoryProvider を差し替え点にしている
final Provider<InspectionRepository> inspectionRepositoryProvider =
Provider<InspectionRepository>((Ref ref) {
return const DemoInspectionRepository();
});
初期データを DemoInspectionRepository へ直接固定せず、Provider から読む形にしています。本番実装では通常データを使い、テストでは FakeInspectionRepository へ差し替えられる構成です。
② 状態変更は InspectionController に寄せている
class InspectionController extends Notifier<InspectionState> {
@override
InspectionState build() {
final InspectionRepository repository =
ref.watch(inspectionRepositoryProvider);
return InspectionState(items: repository.loadItems());
}
void markDone(String itemId) {
state = state.copyWith(
items: <InspectionItem>[
for (final InspectionItem item in state.items)
if (item.id == itemId) item.markDone() else item,
],
);
}
}
ボタン押下後にどこが変わるかを 1 か所へ集めると、テストは UI の見た目と結果確認へ集中できます。Widget 側で直接 setState() とリスト更新を混ぜるより、失敗時に見直す場所も追いやすくなります。
③ Key を付けて finders を安定させている
SwitchListTile(
key: const Key('pending-only-toggle'),
title: const Text('未確認だけ表示'),
value: state.pendingOnly,
onChanged: controller.togglePendingOnly,
)
FilledButton(
key: Key('done-button-${item.id}'),
onPressed: onMarkDone,
child: Text(item.isDone ? '確認済み' : '確認済みにする'),
)
文章だけを Finder に使うと、同じ文字列が別の場所に増えたとき壊れやすくなります。テスト対象として重要な部品には Key を付けておくと、UI 文言を少し直しても壊れにくい Finder を保てます。
5. flutter run で手動確認する
自動テストへ入る前に、まずは画面の意図が合っているかを一度見ておきます。
flutter run
確認する点は次の 3 つです。
未確認 2 件と確認済み 1 件が表示されることA-01とB-02のカードに確認済みにするボタンがあること- ボタンを押すと件数が更新され、押した行のボタンが無効になること
この手動確認を先に 1 回入れておくと、あとで flutter test が失敗したときに「そもそも UI の意図が違う」のか「テストの Finder が違う」のかを切り分けやすくなります。
上から順に、件数カードの表示と一覧カードの表示を確認した状態です。
6. test/widget_test.dart を作成する
続いて、flutter create で生成された test/widget_test.dart は次の内容に書き換えます。
このファイルでは、初期表示、ボタン押下後の変化、Provider 差し替えの 3 本だけを確認します。最初からケースを増やしすぎず、「画面が出る」「操作で状態が変わる」「初期データを差し替えられる」の軸で入口を作る構成です。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:warehouse_widget_test/main.dart';
void main() {
group('InspectionPage', () {
testWidgets('初期表示で件数と一覧が描画される', (
WidgetTester tester,
) async {
await pumpInspectionApp(
tester,
repository: FakeInspectionRepository(
items: const <InspectionItem>[
InspectionItem(
id: 'item-001',
shelfLabel: 'A-01',
itemName: 'ハンディ端末',
status: InspectionStatus.pending,
),
InspectionItem(
id: 'item-002',
shelfLabel: 'B-02',
itemName: '充電ケーブル',
status: InspectionStatus.pending,
),
InspectionItem(
id: 'item-003',
shelfLabel: 'C-03',
itemName: '予備バッテリー',
status: InspectionStatus.done,
),
],
),
);
expect(find.text('Widgetテストで確認する検品画面'), findsOneWidget);
expect(find.byKey(const Key('pending-count')), findsOneWidget);
expect(find.text('未確認 2 件'), findsOneWidget);
expect(find.text('確認済み 1 件'), findsOneWidget);
expect(find.text('A-01'), findsOneWidget);
expect(find.text('B-02'), findsOneWidget);
});
testWidgets('確認ボタン押下で件数とボタン状態が変わる', (
WidgetTester tester,
) async {
await pumpInspectionApp(
tester,
repository: FakeInspectionRepository(
items: const <InspectionItem>[
InspectionItem(
id: 'item-001',
shelfLabel: 'A-01',
itemName: 'ハンディ端末',
status: InspectionStatus.pending,
),
InspectionItem(
id: 'item-002',
shelfLabel: 'B-02',
itemName: '充電ケーブル',
status: InspectionStatus.done,
),
],
),
);
await tester.tap(find.byKey(const Key('done-button-item-001')));
await tester.pump();
expect(find.text('未確認 0 件'), findsOneWidget);
expect(find.text('確認済み 2 件'), findsOneWidget);
final FilledButton button = tester.widget<FilledButton>(
find.byKey(const Key('done-button-item-001')),
);
expect(button.onPressed, isNull);
});
testWidgets('Provider差し替えで初期データを入れ替えられる', (
WidgetTester tester,
) async {
await pumpInspectionApp(
tester,
repository: FakeInspectionRepository(
items: const <InspectionItem>[
InspectionItem(
id: 'special-001',
shelfLabel: '特急レーン',
itemName: '返品伝票',
status: InspectionStatus.pending,
),
],
),
);
expect(find.text('特急レーン'), findsOneWidget);
expect(find.text('返品伝票'), findsOneWidget);
expect(find.text('A-01'), findsNothing);
});
});
}
Future<void> pumpInspectionApp(
WidgetTester tester, {
required InspectionRepository repository,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
inspectionRepositoryProvider.overrideWith((Ref ref) => repository),
],
child: const MyApp(),
),
);
await tester.pump();
}
class FakeInspectionRepository implements InspectionRepository {
FakeInspectionRepository({required this.items});
final List<InspectionItem> items;
@override
List<InspectionItem> loadItems() => items;
}
コードのポイント
① pumpInspectionApp() で override をまとめている
Future<void> pumpInspectionApp(
WidgetTester tester, {
required InspectionRepository repository,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
inspectionRepositoryProvider.overrideWith((Ref ref) => repository),
],
child: const MyApp(),
),
);
}
各テストが毎回 ProviderScope の組み立てを書かずに済む形です。差し替えの入口を helper に寄せておけば、今後 Provider が増えても修正箇所を 1 か所に保てます。
② ボタン押下のあとに pump() して再描画を待つ
await tester.tap(find.byKey(const Key('done-button-item-001')));
await tester.pump();
タップした直後は Widget ツリーの再描画がまだ終わっていません。操作のあとに pump() を入れることで、状態更新後の表示まで検証できます。
③ Provider 差し替えは「別の初期データで開けるか」を見る
await pumpInspectionApp(
tester,
repository: FakeInspectionRepository(
items: const <InspectionItem>[
InspectionItem(
id: 'special-001',
shelfLabel: '特急レーン',
itemName: '返品伝票',
status: InspectionStatus.pending,
),
],
),
);
ここでは外部 API や DB を使わず、Fake 実装だけで差し替えています。入口の記事では「モックライブラリの使い方」よりも、「Provider を差し替えられる設計になっているか」を先に固めるほうが重要になります。
7. flutter test を実行する
次のコマンドで Widget テストを実行します。
flutter test test/widget_test.dart
成功すると、3 件のテストが通過した結果が表示されます。最初に確認したいのは件数そのものより、どのケースで止まったかです。
- 初期表示で止まるなら、Widget ツリーの構成や Finder の対象を確認する
- ボタン押下後で止まるなら、
tap()後にpump()しているかを見る - Provider 差し替えで止まるなら、override 対象が本当に初期データの入口になっているかを見る
VS Code を使う場合は Testing View から個別実行しても構いません。ただ、最初の 1 本は terminal で flutter test を打ち、どのファイルがどう実行されるかを見ておくほうが流れをつかみやすくなります。terminal では +3 と All tests passed! がそろえば、3 件とも通過したと判断できます。
8. テスト対象を切り分ける
Widget テストは便利ですが、全部をここへ入れると保守が重くなります。今回のサンプルを起点にすると、切り分けは次のようになります。
| 置き場所 | 向いている内容 | 今回の例 |
|---|---|---|
| 単体テスト | 純粋関数、変換、並び替え、整形 | 件数集計や表示整形を関数へ切り出した場合 |
| Widget テスト | 画面表示、ボタン押下、状態反映 | 未確認 2 件 の表示、確認ボタン押下後の変化 |
| Integration Test | 画面遷移、実機依存、外部連携 | ログインから一覧表示、カメラ起動、権限ダイアログ |
切り分けで意識したいのは、Widget テストに「画面の責務」を残すことです。
- 文字列整形や複雑な集計は pure Dart の関数へ出す
- 依存先は Provider で差し替えられる形にする
- 実機依存や画面遷移をまたぐ確認は後続の Integration Test へ回す
この分担にしておくと、Widget テストは「画面ロジックの回帰確認」に集中できます。逆に、通信、権限、ルーティングまで一度に背負わせると、失敗時の原因が追いにくくなります。
9. まとめ
Widget テストの入口では、画面表示、ボタン押下後の変化、Provider 差し替えの 3 本をまず固めると進めやすくなります。Riverpod の差し替え点を先に作っておけば、初期データや依存先を変えた確認を増やしやすくなります。次は、FlutterのIntegration Test入門(ログインから一覧表示まで確認する) のように、画面遷移をまたぐ確認へ広げる流れで考えると整理しやすくなります。