Flutter测试实战:从单元测试到集成测试
Flutter测试实战:从单元测试到集成测试
Flutter 项目的测试是个容易被忽视的环节。项目赶进度的时候,测试代码总是被第一个砍掉;项目稳定后,又觉得反正功能都正常,没必要补测试。
结果是:代码改多了心里没底,重构不敢动,bug 越加越多。
整理一下 Flutter 项目的测试实践,包括单元测试、Widget 测试和集成测试,以及怎么在实际项目中落地。
Flutter 测试框架
Flutter 自带 flutter_test 库,基于 Dart 的 test 包。测试文件放在 test/ 目录下:
test/
├── unit/ # 单元测试
├── widget/ # Widget 测试
├── integration/ # 集成测试
└── helpers/ # 测试辅助工具
单元测试:测试业务逻辑
单元测试的对象是纯 Dart 类,和 Flutter UI 无关。适合测试:
- Service 类的业务逻辑
- Repository 类的数据处理
- Model 类的序列化/反序列化
- 工具函数
示例:测试 ProductService
// lib/features/product/services/product_service.dart
class ProductService {
final ProductRepository _repository;
ProductService(this._repository);
Future<List<Product>> getProducts({
required int page,
int pageSize = 20,
String? categoryId,
}) async {
if (page < 1) {
throw ArgumentError('page must be >= 1');
}
final products = await _repository.getProducts(
page: page,
pageSize: pageSize,
);
if (categoryId != null) {
return products.where((p) => p.categoryId == categoryId).toList();
}
return products;
}
double calculateDiscount(Product product, UserLevel level) {
switch (level) {
case UserLevel.vip:
return product.price * 0.8;
case UserLevel.svip:
return product.price * 0.7;
case UserLevel.normal:
return product.price;
}
}
}
对应的单元测试:
// test/unit/product_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/features/product/services/product_service.dart';
import 'package:my_app/features/product/models/product.dart';
import 'package:my_app/features/product/repositories/product_repository.dart';
import 'product_service_test.mocks.dart';
([ProductRepository])
void main() {
late ProductService service;
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
service = ProductService(mockRepository);
});
group('getProducts', () {
test('page < 1 时抛出 ArgumentError', () {
expect(
() => service.getProducts(page: 0),
throwsA(isA<ArgumentError>()),
);
});
test('page = 1 时调用 repository 的正确参数', () async {
when(mockRepository.getProducts(page: 1, pageSize: 20))
.thenAnswer((_) async => []);
await service.getProducts(page: 1);
verify(mockRepository.getProducts(page: 1, pageSize: 20)).called(1);
});
test('传入 categoryId 时过滤结果', () async {
final products = [
Product(id: '1', name: 'A', price: 100, categoryId: 'cat1'),
Product(id: '2', name: 'B', price: 200, categoryId: 'cat2'),
];
when(mockRepository.getProducts(page: 1, pageSize: 20))
.thenAnswer((_) async => products);
final result = await service.getProducts(
page: 1,
categoryId: 'cat1',
);
expect(result.length, 1);
expect(result.first.id, '1');
});
});
group('calculateDiscount', () {
test('VIP 用户 8 折', () {
final product = Product(id: '1', name: 'A', price: 100);
expect(service.calculateDiscount(product, UserLevel.vip), 80);
});
test('SVIP 用户 7 折', () {
final product = Product(id: '1', name: 'A', price: 100);
expect(service.calculateDiscount(product, UserLevel.svip), 70);
});
test('普通用户不打折', () {
final product = Product(id: '1', name: 'A', price: 100);
expect(service.calculateDiscount(product, UserLevel.normal), 100);
});
});
}
用 mockito 生成 mock 类:
flutter pub run build_runner build --delete-conflicting-outputs
单元测试的命名规范
测试方法的命名我倾向用 method_condition_expectedResult 的格式:
test('getProducts_page小于1时抛出ArgumentError')
test('getProducts_传入categoryId时返回过滤后结果')
test('calculateDiscount_VIP用户返回8折价格')
这种命名的好处是测试失败时,从名字就能知道是哪个场景出问题。
Widget 测试:测试 UI 组件
Widget 测试测试的是 Flutter Widget 的渲染和交互。适合测试:
- 自定义组件的渲染逻辑
- 用户交互(点击、输入)的响应
- 状态变化时的 UI 更新
示例:测试 ProductCard Widget
// lib/shared/widgets/product_card.dart
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback? onTap;
const ProductCard({
super.key,
required this.product,
this.onTap,
});
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(imageUrl: product.imageUrl),
SizedBox(height: 8),
Text(product.name),
Text(
'¥${product.price}',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
}
}
对应的 Widget 测试:
// test/widget/product_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/shared/widgets/product_card.dart';
import 'package:my_app/features/product/models/product.dart';
void main() {
group('ProductCard', () {
testWidgets('显示商品名称和价格', (tester) async {
final product = Product(
id: '1',
name: '测试商品',
price: 99.9,
imageUrl: 'https://example.com/image.jpg',
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ProductCard(product: product),
),
),
);
expect(find.text('测试商品'), findsOneWidget);
expect(find.text('¥99.9'), findsOneWidget);
});
testWidgets('点击时调用 onTap', (tester) async {
bool tapped = false;
final product = Product(
id: '1',
name: '测试商品',
price: 99.9,
imageUrl: 'https://example.com/image.jpg',
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ProductCard(
product: product,
onTap: () => tapped = true,
),
),
),
);
await tester.tap(find.byType(InkWell));
await tester.pump();
expect(tapped, true);
});
testWidgets('没有 onTap 时点击不报错', (tester) async {
final product = Product(
id: '1',
name: '测试商品',
price: 99.9,
imageUrl: 'https://example.com/image.jpg',
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ProductCard(product: product),
),
),
);
// 不应该抛出任何异常
await tester.tap(find.byType(InkWell));
await tester.pump();
});
});
}
Widget 测试的关键点
pump()vspumpAndSettle():-pump():让测试框架推进一帧pumpAndSettle():持续 pump 直到所有动画完成(超时则失败)
对于有动画的 Widget,用
pumpAndSettle()会等动画结束,但容易超时;用pump(Duration)可以精确控制时间。find.byTypevsfind.text:优先用find.byType定位,因为文本可能重复出现。模拟网络图片:
CachedNetworkImage会发起真实网络请求,测试时需要 mock:
testWidgets('显示占位图时展示正确内容', (tester) async {
// 用 NetworkImage 而不是 CachedNetworkImage 测试
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Image.network('https://example.com/test.jpg'),
),
),
);
// pump 一次让 Image 进入 loading 状态
await tester.pump();
// 验证 loading 状态(如果有占位图逻辑)
}
集成测试:测试完整流程
集成测试测试的是完整的用户流程,从启动 App 到完成某个操作。Flutter 的集成测试会启动完整的应用实例。
示例:测试登录流程
// test_integration/login_flow_test.dart
import 'package:flutter/material.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
app.main();
await tester.pumpAndSettle();
// 输入手机号
await tester.enterText(
find.byKey(Key('phone_input')),
'13800138000',
);
// 输入验证码
await tester.enterText(
find.byKey(Key('code_input')),
'123456',
);
// 点击登录按钮
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
// 验证跳转到首页
expect(find.byType(AppHomePage), findsOneWidget);
});
testWidgets('错误验证码登录失败并显示错误提示', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(Key('phone_input')),
'13800138000',
);
await tester.enterText(
find.byKey(Key('code_input')),
'000000', // 错误验证码
);
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
// 验证错误提示出现
expect(find.text('验证码错误'), findsOneWidget);
});
});
}
运行集成测试:
flutter test integration_test/login_flow_test.dart
注意:集成测试需要真实设备或模拟器,不能在 headless 环境下运行。
Mock 最佳实践
测试的核心是用 Mock 替换真实依赖,让测试稳定且快速。
http Mock
如果 Service 层依赖 HTTP 接口,用 mockito + http 的 MockClient:
import 'package:http/http.dart' as http;
import 'package:mockito/httputils.dart';
test('getProducts 返回正确数据', () async {
final mockClient = MockClient();
when(mockClient.get(Uri.parse('https://api.example.com/products')))
.thenAnswer((_) async => http.Response(
'[{"id":"1","name":"A","price":100}]',
200,
));
final service = ProductService(ApiClient(client: mockClient));
final products = await service.getProducts();
expect(products.length, 1);
expect(products.first.name, 'A');
}
Repository Mock
对于 Repository 接口,用 mockito 生成 Mock:
([ProductRepository])
void main() {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
test('repository 返回空列表时 service 也返回空列表', () async {
when(mockRepository.getProducts(page: 1, pageSize: 20))
.thenAnswer((_) async => []);
final service = ProductService(mockRepository);
final products = await service.getProducts(page: 1);
expect(products.isEmpty, true);
});
}
测试覆盖率
Flutter 项目可以用 coverage 工具生成测试覆盖率报告:
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
关注几个指标:
- 行覆盖率:有多少行代码被执行
- 分支覆盖率:有多少条件分支被覆盖
通常要求核心业务逻辑的行覆盖率在 80% 以上。
总结
Flutter 测试的几个要点:
- 单元测试:测试 Service、Repository、Model 等纯 Dart 类,用 mockito 隔离依赖
- Widget 测试:测试自定义组件的渲染和交互,确保 UI 按预期工作
- 集成测试:测试完整用户流程,在真实设备或模拟器上运行
- Mock 策略:依赖什么就 mock 什么,确保测试稳定且快速
- 覆盖率:核心逻辑要有覆盖率要求,避免测试盲区
测试写得好不好,有一个简单标准:当你改了一行代码后,测试能告诉你这行代码影响了什么功能。如果测试只是跑得过去但没有价值,那还不如不写。
