Back to listFlutter 测试策略
Site Owner
Published on 2026-05-22
详解Flutter测试金字塔、mockito Mock策略、WidgetTester pump机制、Golden测试及integration_test自动化
Flutter 测试策略 (Testing Strategy)
模块 :模块十 · 架构与工程化
前置知识 :Flutter 基础 Widget、异步编程、Riverpod/Provider
测试金字塔
Flutter 测试遵循经典的测试金字塔原则:
▲
/△\
/ △ \ 集成测试(少量)
/──△──\ 真机/模拟器运行,测试完整流程
/ △ \
/────△────\
/ △ \ Widget 测试(中量)
/ △ \ 不需要真机,测试 UI 组件
/───────△───────\
/ △ \ 单元测试(大量)
/ △ \ 纯 Dart 测试,快速、稳定
────────────────────────
类型 速度 真机 测试范围 数量建议 单元测试 极快(ms) ❌ 函数/类 70% Widget 测试 快(s) ❌ UI 组件 20% 集成测试 慢(min) ✅ 完整流程 10%
单元测试
基础测试结构
// test/features/products/domain/usecases/get_products_usecase_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'get_products_usecase_test.mocks.dart';
@GenerateMocks([ProductRepository]) // 自动生成 Mock 类
void main() {
late GetProductsUseCase useCase;
late MockProductRepository mockRepository;
setUp(() {
// 每个测试前重新初始化(避免状态污染)
mockRepository = MockProductRepository();
useCase = GetProductsUseCase(mockRepository);
});
group('GetProductsUseCase', () {
final tProducts = [
const Product(
id: '1',
name: '测试商品',
price: 99.0,
stockCount: 10,
category: ProductCategory.electronics,
),
];
test('成功时返回商品列表', () async {
// Arrange(准备):设置 Mock 的行为
when(mockRepository.getProducts())
.thenAnswer((_) async => Right(tProducts));
// Act(执行):调用被测代码
final result = await useCase(const GetProductsParams());
// Assert(断言):验证结果
expect(result, Right(tProducts));
verify(mockRepository.getProducts()).called(1); // 验证方法被调用了一次
verifyNoMoreInteractions(mockRepository); // 验证没有其他调用
});
test('服务器失败时返回 ServerFailure', () async {
// Arrange
when(mockRepository.getProducts())
.thenAnswer((_) async => const Left(ServerFailure(message: '500错误')));
// Act
final result = await useCase(const GetProductsParams());
// Assert
expect(result, const Left(ServerFailure(message: '500错误')));
});
test('使用正确的参数调用 repository', () async {
// Arrange
const params = GetProductsParams(page: 2, pageSize: 10);
when(mockRepository.getProducts(page: 2, pageSize: 10))
.thenAnswer((_) async => Right(tProducts));
// Act
await useCase(params);
// Assert:验证参数传递正确
verify(mockRepository.getProducts(page: 2, pageSize: 10)).called(1);
});
});
}
Flutter测试策略—单元测试、Widget测试与集成测试完全指南|新宇宙博客
使用 mocktail(无代码生成的 Mock) // mocktail 不需要 @GenerateMocks 和 build_runner
import 'package:mocktail/mocktail.dart';
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
late GetProductsUseCase useCase;
late MockProductRepository mockRepository;
setUpAll(() {
// 注册 fallback value(对于自定义类型需要)
registerFallbackValue(const GetProductsParams());
});
setUp(() {
mockRepository = MockProductRepository();
useCase = GetProductsUseCase(mockRepository);
});
test('成功时返回商品列表', () async {
// mocktail 语法与 mockito 类似但略有差异
when(() => mockRepository.getProducts(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
)).thenAnswer((_) async => Right([/* ... */]));
final result = await useCase(const GetProductsParams());
expect(result.isRight(), isTrue);
});
}
测试异步代码 // 测试 Stream
test('用户状态 Stream 正确发出事件', () async {
final authRepository = MockAuthRepository();
final controller = StreamController<User?>();
when(() => authRepository.authStateChanges)
.thenAnswer((_) => controller.stream);
final authNotifier = AuthNotifier(authRepository);
// 验证初始状态
expect(authNotifier.state, const AsyncValue<User?>.loading());
// 发出事件
controller.add(const User(id: '1', name: 'Alice'));
// 等待状态更新
await expectLater(
authNotifier.stream,
emitsInOrder([
isA<AsyncData<User?>>()
.having((s) => s.value?.name, 'name', 'Alice'),
]),
);
await controller.close();
});
// 测试 Future(使用 fake_async 控制时间)
import 'package:fake_async/fake_async.dart';
test('超时后抛出 NetworkFailure', () {
fakeAsync((async) {
final repository = ProductRepositoryImpl(/* ... */);
Object? caughtError;
repository.getProducts().catchError((e) => caughtError = e);
// 快进 30 秒(模拟超时)
async.elapse(const Duration(seconds: 30));
expect(caughtError, isA<NetworkFailure>());
});
});
// test/features/products/presentation/widgets/product_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('ProductCard 正确显示商品信息', (tester) async {
// 创建测试 Widget
const product = Product(
id: '1',
name: '测试手机',
price: 2999.0,
stockCount: 5,
category: ProductCategory.electronics,
);
// pump:渲染 Widget(等价于 setState 后的重建)
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: ProductCard(product: product),
),
),
);
// 使用 Finder 定位 Widget
expect(find.text('测试手机'), findsOneWidget);
expect(find.text('¥2999.00'), findsOneWidget);
// 图标存在
expect(find.byIcon(Icons.shopping_cart), findsOneWidget);
// 文字颜色(针对低库存警告)
final stockText = tester.widget<Text>(find.text('库存:5件'));
expect(stockText.style?.color, Colors.orange); // 低库存显示橙色
});
testWidgets('点击加入购物车按钮触发回调', (tester) async {
bool wasPressed = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ProductCard(
product: const Product(/* ... */),
onAddToCart: () => wasPressed = true,
),
),
),
);
// tap:模拟点击
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pump(); // 等待 setState 执行
expect(wasPressed, isTrue);
});
}
pump vs pumpAndSettle testWidgets('pump 和 pumpAndSettle 的区别', (tester) async {
await tester.pumpWidget(const AnimationDemo());
// pump():执行一帧渲染(推进时间 16ms)
// 用于:验证动画中间状态
await tester.pump();
expect(find.text('加载中...'), findsOneWidget);
// pump(Duration):推进指定时间
await tester.pump(const Duration(milliseconds: 500));
// pumpAndSettle():反复执行 pump() 直到没有更多帧需要渲染
// 用于:等待动画/异步操作完成
// ⚠️ 注意:永不结束的动画(repeat)会导致 pumpAndSettle 超时!
await tester.pumpAndSettle();
expect(find.text('加载完成'), findsOneWidget);
});
testWidgets('测试带异步操作的 Widget', (tester) async {
// 对于 Future,使用 pumpAndSettle 等待完成
await tester.pumpWidget(const FutureDataWidget());
// 初始状态:加载中
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 等待 Future 完成(需要 FakeAsync 或 Mock)
await tester.pumpAndSettle();
// 数据加载后的状态
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('数据列表'), findsOneWidget);
});
// test/features/products/presentation/pages/product_list_page_test.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('ProductListPage 加载时显示 loading 状态', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// 覆盖 Provider,返回 Mock 数据
productNotifierProvider.overrideWith(
() => FakeProductNotifier(),
),
],
child: const MaterialApp(home: ProductListPage()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('数据加载成功后显示商品列表', (tester) async {
final fakeProducts = [
const Product(id: '1', name: '商品A', price: 100, stockCount: 10,
category: ProductCategory.electronics),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
productNotifierProvider.overrideWith(
() => FakeProductNotifier(products: fakeProducts),
),
],
child: const MaterialApp(home: ProductListPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('商品A'), findsOneWidget);
expect(find.text('¥100.00'), findsOneWidget);
});
}
// Fake Notifier(比 Mock 更接近真实行为)
class FakeProductNotifier extends _$ProductNotifier {
FakeProductNotifier({this.products});
final List<Product>? products;
@override
Future<List<Product>> build() async {
if (products != null) return products!;
throw ServerFailure(message: '测试错误');
}
}
Golden Test(像素级 UI 回归测试) // test/golden/product_card_golden_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
void main() {
group('ProductCard Golden Tests', () {
// 生成基准图像
testGoldens('正常商品卡片', (tester) async {
await tester.pumpWidgetBuilder(
const ProductCard(
product: Product(
id: '1',
name: '测试商品名称很长的情况下怎么显示',
price: 12999.0,
stockCount: 3,
category: ProductCategory.electronics,
),
),
wrapper: materialAppWrapper(), // 包裹 MaterialApp
surfaceSize: const Size(375, 120), // 指定截图尺寸
);
// 第一次运行:生成基准图像(保存在 test/goldens/)
// 后续运行:对比当前渲染与基准图像,像素不同则失败
await screenMatchesGolden(tester, 'product_card_normal');
});
testGoldens('低库存状态', (tester) async {
await tester.pumpWidgetBuilder(
const ProductCard(
product: Product(
id: '1',
name: '低库存商品',
price: 99.0,
stockCount: 2, // 低库存
category: ProductCategory.food,
),
),
surfaceSize: const Size(375, 120),
);
await screenMatchesGolden(tester, 'product_card_low_stock');
});
});
}
flutter test --update-goldens test /golden/
flutter test test /golden/
集成测试(Integration Test) // integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('用户登录流程', () {
testWidgets('完整登录流程', (tester) async {
app.main(); // 启动真实 App
await tester.pumpAndSettle();
// 验证初始状态:在登录页
expect(find.text('登录'), findsOneWidget);
// 输入邮箱
await tester.enterText(
find.byKey(const Key('email_field')),
'test@example.com',
);
// 输入密码
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
// 点击登录按钮
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle(const Duration(seconds: 5)); // 等待网络请求
// 验证登录成功:跳转到首页
expect(find.text('首页'), findsOneWidget);
expect(find.text('登录'), findsNothing);
});
});
}
flutter test integration_test/app_test.dart
flutter build apk --debug
gcloud firebase test android run \
--type instrumentation \
--app build/app/outputs/flutter-apk/app-debug.apk \
--test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk
测试覆盖率
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
- name: 检查测试覆盖率
run: |
flutter test --coverage
lcov --summary coverage/lcov.info | grep -E "lines.*:" | \
awk '{print $2}' | sed 's/%//' | \
xargs -I {} sh -c 'if [ $(echo "{} < 80" | bc) -eq 1 ]; then echo "覆盖率不足 80%"; exit 1; fi'
测试文件组织规范 test/
├── fixtures/ # 测试数据(JSON 文件)
│ ├── products_response.json
│ └── user_response.json
│
├── helpers/ # 测试辅助工具
│ ├── pump_app.dart # 统一的 pumpApp 封装
│ └── fake_repositories.dart # Fake 实现
│
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ └── repositories/
│ │ │ └── auth_repository_impl_test.dart
│ │ ├── domain/
│ │ │ └── usecases/
│ │ │ └── login_usecase_test.dart
│ │ └── presentation/
│ │ ├── pages/
│ │ │ └── login_page_test.dart
│ │ └── providers/
│ │ └── auth_notifier_test.dart
│ └── products/
│ └── ...(同上结构)
│
└── core/
└── network/
└── dio_client_test.dart
integration_test/
└── app_test.dart
test/golden/
├── goldens/ # 基准截图(提交到 git)
│ ├── product_card_normal.png
│ └── product_card_low_stock.png
└── product_card_golden_test.dart
测试最佳实践
1. Arrange-Act-Assert 模式 test('购物车添加商品成功', () async {
// ── Arrange(准备)──────────────────────────
final cart = ShoppingCart();
const product = Product(id: '1', name: '商品', price: 100, stockCount: 10,
category: ProductCategory.food);
// ── Act(执行)──────────────────────────────
cart.addItem(product, quantity: 2);
// ── Assert(断言)────────────────────────────
expect(cart.items.length, 1);
expect(cart.items.first.quantity, 2);
expect(cart.totalPrice, 200.0);
});
2. 使用 Fixture 加载测试数据 // test/helpers/fixture_reader.dart
String fixture(String name) {
return File('test/fixtures/$name').readAsStringSync();
}
// 在测试中使用
test('从 JSON 正确解析商品', () {
final json = jsonDecode(fixture('product.json')) as Map<String, dynamic>;
final model = ProductModel.fromJson(json);
expect(model.id, '1');
expect(model.name, '测试商品');
});
3. 共用的 pumpApp // test/helpers/pump_app.dart
extension PumpApp on WidgetTester {
Future<void> pumpApp(
Widget widget, {
List<Override> overrides = const [],
}) {
return pumpWidget(
ProviderScope(
overrides: overrides,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: widget,
),
),
);
}
}
// 使用:
testWidgets('ProductCard', (tester) async {
await tester.pumpApp(const ProductCard(/* ... */));
// ...
});
过关标准 ✅ 能解释 pumpAndSettle 和 pump 的区别及各自适用场景
✅ 能用 mockito/mocktail 模拟异步依赖,编写 Use Case 单元测试
✅ 能为一个完整功能模块编写:单元测试(覆盖率 > 80%)+ Widget 测试 + Golden Test
✅ 能在 CI 中集成测试覆盖率检查
✅ 理解 Golden Test 的生成与更新流程