Flutter: テスト — ユニット / ウィジェット / インテグレーションテスト
テストのないコードは時限爆弾 — いつ爆発するか分からず、爆発することだけが確かです。
📋 前提条件: 以下に既に慣れている必要があります
- レッスン5:StatefulWidgetとインタラクション
- レッスン12:状態管理 — Riverpod
1. このレッスンで学ぶこと
- ユニットテスト:testパッケージ + mocktailモック、Notifier / Repositoryロジックのテスト
- ウィジェットテスト:testWidgets + Finder + pumpWidget / pumpAndSettle
- インテグレーションテスト:integration_testパッケージ + 実機エンドツーエンドテスト
- ゴールデンテスト:matchesGoldenFile UI回帰検出
- ShopApp:CartNotifierユニットテスト + 商品一覧ウィジェットテスト + チェックアウトインテグレーションテスト
2. 本番バグのリアルなストーリー
(1) 悩み:手動テストではすべてをカバーできない
BobのShopAppにクーポン機能が追加されました。QAは「正常系」を手動テストして合格しましたが、「期限切れクーポンの使用」というエッジケースを見落としました。リリース後、500人のユーザーが期限切れクーポンで注文を行い、注文金額がマイナスになりデータベースエラーが発生。修正に4時間かかり、1万ドルの損失。同様の問題は月に少なくとも2回発生していました。
(2) 自動テストソリューション
自動テストは毎回のコードコミットで実行され、すべてのエッジケースをカバーし、新しいコードが既存機能を壊さないことを保証します。
DART
import 'package:flutter_test/flutter_test.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// CartNotifier / Coupon / Product from ShopApp project, full definition in Lesson 27
class Coupon {
final String code;
final DateTime expiresAt;
const Coupon({required this.code, required this.expiresAt});
}
test('coupon expired should show error', () {
final cart = CartNotifier();
cart.addItem(product);
final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
expect(result, isFalse);
expect(cart.error, contains('expired'));
});
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 成果:バグ率80%低下
Bobがテスト体制を確立後、200以上のテストが毎コミットで自動実行され、本番バグ率は月5件から月1件に低下しました。
3. テストピラミッド
graph TD
TP[Testing Pyramid] --> UT[Unit Tests: 70%]
TP --> WT[Widget Tests: 20%]
TP --> IT[Integration Tests: 10%]
UT --> |mocktail| CN3[CartNotifier]
WT --> |testWidgets| PV3[ProductListView]
IT --> |integration_test| CK2[Checkout Flow]
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| 層 | 割合 | 速度 | 依存関係 | テストスコープ |
|---|---|---|---|---|
| ユニットテスト | 70% | ミリ秒 | Mock | 純粋ロジック(Notifier/Repository) |
| ウィジェットテスト | 20% | 秒 | テスト環境 | UIレンダリング + インタラクション |
| インテグレーションテスト | 10% | 分 | 実機/エミュレータ | 完全なユーザーフロー |
4. ユニットテスト
(1) プロジェクト設定
YAML
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
build_runner: ^2.4.0
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:CartNotifierユニットテスト
DART
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// ProductRepository / CartNotifier from ShopApp project, full definition in Lesson 27
// Simplified Product (full definition in Lesson 26 domain/entities/product.dart)
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified CartNotifier (full definition in Lesson 27)
class CartNotifier {
List<_CartItem> state = [];
String? error;
void addItem(Product product) {
final idx = state.indexWhere((i) => i.product.id == product.id);
if (idx >= 0) {
state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
} else {
state = [...state, _CartItem(product: product, quantity: 1)];
}
}
void updateQuantity(int productId, int quantity) {
if (quantity <= 0) { removeItem(productId); return; }
state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
}
void removeItem(int productId) {
state = state.where((i) => i.product.id != productId).toList();
}
void clear() { state = []; }
double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}
class _CartItem {
final Product product;
final int quantity;
_CartItem({required this.product, required this.quantity});
}
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
group('CartNotifier', () {
late CartNotifier cart;
setUp(() {
cart = CartNotifier();
});
test('initial state is empty', () {
expect(cart.state, isEmpty);
});
test('addItem adds product to cart', () {
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.product, testProduct);
expect(cart.state.first.quantity, 1);
});
test('addItem increments quantity if product exists', () {
cart.addItem(testProduct);
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.quantity, 2);
});
test('updateQuantity updates item quantity', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 5);
expect(cart.state.first.quantity, 5);
});
test('updateQuantity removes item when quantity is 0', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 0);
expect(cart.state, isEmpty);
});
test('removeItem removes product from cart', () {
cart.addItem(testProduct);
cart.removeItem(testProduct.id);
expect(cart.state, isEmpty);
});
test('clear empties the cart', () {
cart.addItem(testProduct);
cart.addItem(anotherProduct);
cart.clear();
expect(cart.state, isEmpty);
});
test('total calculates correct sum', () {
cart.addItem(testProduct); // price: 99.99
cart.addItem(anotherProduct); // price: 49.99
expect(cart.total, closeTo(149.98, 0.01));
});
});
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:Repositoryユニットテスト(Mock付き)
DART
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';
// ⚙️ Dependencies: mocktail: ^1.0.0, dio: ^5.0.0 (add to dev_dependencies / dependencies)
// ProductRepository / DioClient from ShopApp project, full definition in Lesson 27
class MockDioClient extends Mock implements DioClient {}
void main() {
group('ProductRepository', () {
late ProductRepository repo;
late MockDioClient mockClient;
setUp(() {
mockClient = MockDioClient();
repo = ProductRepository(mockClient);
});
test('getProducts returns list of products', () async {
// Arrange
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenAnswer((_) async => Response(
data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
statusCode: 200, requestOptions: RequestOptions(path: '/products'),
));
// Act
final products = await repo.getProducts();
// Assert
expect(products.length, 1);
expect(products.first.name, 'Laptop');
verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
});
test('getProducts throws on network error', () async {
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));
expect(() => repo.getProducts(), throwsA(isA<DioException>()));
});
});
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. ウィジェットテスト
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:商品カードウィジェットテスト
DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Product / ProductCard from ShopApp project, full definition in Lessons 26/27
// Simplified Product
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified ProductCard
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onAddToCart;
const ProductCard({super.key, required this.product, required this.onAddToCart});
@override
Widget build(BuildContext context) {
return Card(
child: Column(children: [
Text(product.name),
Text('\$${product.price.toStringAsFixed(2)}'),
if (product.price < 50) const Text('SALE'),
IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
]),
);
}
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
void main() {
group('ProductCard', () {
testWidgets('displays product name and price', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () {},
)),
));
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('\$99.99'), findsOneWidget);
});
testWidgets('calls onAddToCart when button tapped', (tester) async {
var tapped = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () => tapped = true,
)),
));
await tester.tap(find.byIcon(Icons.add_shopping_cart));
expect(tapped, isTrue);
});
testWidgets('shows SALE badge when price < 50', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
onAddToCart: () {},
)),
));
expect(find.text('SALE'), findsOneWidget);
});
});
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:カートページウィジェットテスト
DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Dependency: flutter_riverpod: ^2.0.0 (add to dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier from ShopApp project
void main() {
group('CartPage', () {
testWidgets('shows empty state when cart is empty', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
expect(find.text('Cart is empty'), findsOneWidget);
});
testWidgets('shows items and total when cart has items', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('Mouse'), findsOneWidget);
expect(find.textContaining('\$149.98'), findsOneWidget);
});
testWidgets('increments quantity on plus tap', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
// Find first increment button and tap
await tester.tap(find.byIcon(Icons.add).first);
await tester.pumpAndSettle();
// Verify quantity changed
expect(find.text('2'), findsOneWidget);
});
});
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. インテグレーションテスト
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:チェックアウトフローエンドツーエンドテスト
DART
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';
// ⚙️ Dependency: integration_test SDK (add to dev_dependencies)
// app (ShopApp main entry) from project lib/main.dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Checkout Flow', () {
testWidgets('complete purchase end-to-end', (tester) async {
app.main();
await tester.pumpAndSettle();
// 1. Browse products
expect(find.text('ShopApp'), findsOneWidget);
// 2. Add product to cart
await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
await tester.pumpAndSettle();
// 3. Navigate to cart
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
// 4. Verify cart has item
expect(find.textContaining('\$'), findsWidgets);
// 5. Tap checkout
await tester.tap(find.text('Checkout'));
await tester.pumpAndSettle();
// 6. Fill checkout form
await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
await tester.pumpAndSettle();
// 7. Submit order
await tester.tap(find.textContaining('Pay'));
await tester.pumpAndSettle();
// 8. Verify order confirmation
expect(find.text('Order Confirmed'), findsOneWidget);
});
});
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
7. テストベストプラクティス
| プラクティス | 説明 |
|---|---|
| AAAパターン | Arrange → Act → Assert |
| テストの独立性 | setUp/setUpEachで初期化、テスト間の依存なし |
| 外部依存のMock化 | ネットワーク/データベース/プラットフォームAPIをMockで置き換え |
| 説明的なテスト名 | test('addItem increments quantity when product exists') |
| ウィジェットテストでpumpAndSettleを使用 | すべてのアニメーションと非同期処理の完了を待機 |
❓ よくある質問
Q ユニットテストとウィジェットテストの違いは何ですか?
A ユニットテストはUIをレンダリングせず — ロジックのみテストします。ウィジェットテストはテスト環境でウィジェットをレンダリングし、UIとインタラクションを検証します。
Q mocktailとmockitoの違いは何ですか?
A mocktailはmockitoのDartネイティブ版 — コード生成が不要で、APIがシンプルです。mocktailが推奨されます。
Q pumpとpumpAndSettleの違いは何ですか?
A pumpは1フレームを実行、pumpAndSettleは新しいフレームがなくなるまで(アニメーション完了/非同期終了まで)すべてのフレームを実行します。
Q CIでインテグレーションテストはどう実行しますか?
A Androidエミュレータで
flutter test integration_test/を使用します。iOSにはmacOSランナーが必要です。Q ゴールデンテストはどう保守しますか?
A 最初の実行でゴールデンファイルが生成され、以降の実行ではそれと比較します。UI変更時は
--update-goldensで更新します。クロスプラットフォームのレンダリング差異に注意してください。Q テストでRiverpodプロバイダーはどうオーバーライドしますか?
A
ProviderScope(overrides: [myProvider.overrideWith(...)])を使用して、実際のプロバイダーをテスト版に置き換えます。📖 まとめ
- テストピラミッド:70%ユニット + 20%ウィジェット + 10%インテグレーション
- ユニットテストはmocktailで外部依存をMock化、AAAパターンで整理
- ウィジェットテストはtestWidgets + FinderでUIとインタラクションを検証
- インテグレーションテストは完全なユーザーフローのエンドツーエンド検証を提供
- Riverpodプロバイダーオーバーライドでテスト内の依存を置き換え
📝 練習問題
- 基本 (⭐):CartNotifierの5つのユニットテストを書いてください:初期空状態、追加、重複追加、削除、クリア。
- 中級 (⭐⭐):ProductCardのウィジェットテストを書いてください:商品名/価格の表示、カート追加ボタンタップでコールバック発火、SALEバッジの表示。
- チャレンジ (⭐⭐⭐):完全なテストスイートを実装してください:CartNotifierユニットテスト(8ケース以上)+ CartPageウィジェットテスト(5ケース以上)+ チェックアウトインテグレーションテスト(完全なユーザーフロー)。