Flutter: 测试 — 单元/Widget/集成测试
没有测试的代码是定时炸弹——你不知道它什么时候炸,只知道一定会炸。
📋 前置知识:需要先掌握以下内容
- 第5课:StatefulWidget 与交互
- 第12课:状态管理 — Riverpod
1. 你将学到
- 单元测试:test 包 + mocktail 模拟;测试 Notifier / Repository 逻辑
- Widget 测试:testWidgets + Finder + pumpWidget / pumpAndSettle
- 集成测试:integration_test 包 + 真机端到端测试
- Golden 测试:matchesGoldenFile UI 回归检测
- ShopApp:CartNotifier 单元测试 + 商品列表 Widget 测试 + 结账集成测试
2. 一个线上 bug 的真实故事
(1) 痛点:手动测试覆盖不了
Bob 的 ShopApp 上线了一个优惠券功能,QA 手动测试了"正常流程"通过,但没测到"优惠券过期后使用"的边界情况。上线后 500 个用户用过期券下单,订单金额变成负数,数据库报错。修复用了 4 小时,损失 10 thousand USD。每个月类似问题至少发生 2 次。
(2) 自动化测试的解法
自动化测试可以在每次代码提交时运行,覆盖所有边界情况,确保新代码不破坏旧功能。
DART
import 'package:flutter_test/flutter_test.dart';
// ⚙️ 依赖:mocktail: ^1.0.0(添加到 dev_dependencies)
// CartNotifier / Coupon / Product 来自 ShopApp 项目,完整定义见第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) 收益:bug 率降低 80%
Bob 建立测试体系后,每次提交自动跑 200+ 测试,上线 bug 率从 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) |
| Widget 测试 | 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';
// ⚙️ 依赖:mocktail: ^1.0.0(添加到 dev_dependencies)
// ProductRepository / CartNotifier 来自 ShopApp 项目,完整定义见第27课
// 简化版 Product(完整定义见第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'});
}
// 简化版 CartNotifier(完整定义见第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';
// ⚙️ 依赖:mocktail: ^1.0.0, dio: ^5.0.0(添加到 dev_dependencies / dependencies)
// ProductRepository / DioClient 来自 ShopApp 项目,完整定义见第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. Widget 测试
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:商品卡片 Widget 测试
DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Product / ProductCard 来自 ShopApp 项目,完整定义见第26/27课
// 简化版 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'});
}
// 简化版 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/状态会因平台略有差异。
:购物车页面 Widget 测试
DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ 依赖:flutter_riverpod: ^2.0.0(添加到 dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier 来自 ShopApp 项目
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';
// ⚙️ 依赖:integration_test SDK(添加到 dev_dependencies)
// app(ShopApp 主入口)来自项目 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') |
| Widget 测试用 pumpAndSettle | 等待所有动画和异步操作完成 |
❓ 常见问题
Q 单元测试和 Widget 测试怎么区分?
A 单元测试不渲染 UI,只测逻辑;Widget 测试在测试环境渲染 Widget,验证 UI 和交互。
Q mocktail 和 mockito 有什么区别?
A mocktail 是 mockito 的 Dart 原生版本,不需要代码生成,API 更简洁。推荐 mocktail。
Q pump 和 pumpAndSettle 有什么区别?
A pump 执行一帧;pumpAndSettle 执行所有帧直到没有新帧(动画结束/异步完成)。
Q 集成测试怎么在 CI 跑?
A 用
flutter test integration_test/ 在 Android 模拟器上跑。iOS 需要在 macOS runner 上。Q Golden 测试怎么维护?
A 首次运行生成 golden 文件,后续运行对比。UI 变化时用
--update-goldens 更新。注意不同平台渲染差异。Q Riverpod 的 Provider 怎么在测试中 override?
A 用
ProviderScope(overrides: [myProvider.overrideWith(...)]) 替换真实 Provider 为测试版本。📖 小节
- 测试金字塔:70% 单元 + 20% Widget + 10% 集成
- 单元测试用 mocktail Mock 外部依赖,AAA 模式组织
- Widget 测试用 testWidgets + Finder 验证 UI 和交互
- 集成测试端到端验证完整用户流
- Riverpod Provider override 在测试中替换依赖
📝 作业
- 基础题(难度⭐):为 CartNotifier 写 5 个单元测试:初始空、添加、重复添加、删除、清空。
- 进阶题(难度⭐⭐):为 ProductCard 写 Widget 测试:显示商品名/价格、点击加购按钮触发回调、显示 SALE 徽标。
- 挑战题(难度⭐⭐⭐):实现完整的测试套件:CartNotifier 单元测试(8+用例) + CartPage Widget 测试(5+用例) + 结账集成测试(完整用户流)。