开篇:为什么 Flutter 测试比想象中重要
Flutter 的声明式 UI 架构有一个天然优势:UI 是状态的纯函数(UI = f(State))。这意味着只要给定固定的状态输入,UI 输出就是确定的——这使得 Widget 测试不仅可行,而且高度可靠。相比之下,传统的命令式 UI 框架需要模拟复杂的视图层级和生命周期,测试成本成倍增加。
Flutter 测试遵循经典的三层金字塔:底层是大量的单元测试(运行最快、成本最低),中层是 Widget 测试(验证 UI 行为),顶层是少量的端到端集成测试(验证完整用户流程)。本章将在每一层提供完整的代码示例和最佳实践。
一、测试概览与运行
# 运行所有测试
flutter test
# 运行特定文件
flutter test test/user_repository_test.dart
# 带覆盖率报告
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html # 查看覆盖率报告
# Widget 测试(带可视化调试)
flutter test --update-goldens # 更新 Golden 文件
flutter test --plain-name "Counter increments" # 按名称过滤
二、单元测试
2.1 测试纯函数
// lib/calculator.dart
class Calculator {
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
double divide(int a, int b) {
if (b == 0) throw ArgumentError('除数不能为零');
return a / b;
}
}
// test/calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/calculator.dart';
void main() {
group('Calculator', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('add 返回两数之和', () {
expect(calculator.add(2, 3), equals(5));
expect(calculator.add(-1, 1), equals(0));
expect(calculator.add(0, 0), equals(0));
});
test('subtract 返回两数之差', () {
expect(calculator.subtract(5, 3), equals(2));
expect(calculator.subtract(3, 5), equals(-2));
});
test('divide 返回商', () {
expect(calculator.divide(6, 2), equals(3.0));
expect(calculator.divide(5, 2), equals(2.5));
});
test('divide 零除时抛出异常', () {
expect(
() => calculator.divide(5, 0),
throwsArgumentError,
);
});
});
}
2.2 使用 Mockito 模拟依赖
// lib/todo_repository.dart
abstract class TodoApi {
Future<List<Todo>> fetchTodos();
Future<void> createTodo(String title);
}
class TodoRepository {
final TodoApi api;
TodoRepository(this.api);
Future<List<Todo>> getTodos() async {
return await api.fetchTodos();
}
Future<void> addTodo(String title) async {
if (title.isEmpty) throw ValidationError('标题不能为空');
await api.createTodo(title);
}
}
// test/todo_repository_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'todo_repository_test.mocks.dart';
@GenerateMocks([TodoApi])
void main() {
group('TodoRepository', () {
late MockTodoApi mockApi;
late TodoRepository repository;
setUp(() {
mockApi = MockTodoApi();
repository = TodoRepository(mockApi);
});
test('getTodos 返回 API 数据', () async {
// Arrange
final todos = [Todo(id: '1', title: '测试')];
when(mockApi.fetchTodos()).thenAnswer((_) async => todos);
// Act
final result = await repository.getTodos();
// Assert
expect(result, equals(todos));
verify(mockApi.fetchTodos()).called(1);
});
test('addTodo 空标题抛出异常', () async {
expect(
() => repository.addTodo(''),
throwsA(isA<ValidationError>()),
);
verifyNever(mockApi.createTodo(any));
});
test('addTodo 非空标题调用 API', () async {
when(mockApi.createTodo('新任务')).thenAnswer((_) async {});
await repository.addTodo('新任务');
verify(mockApi.createTodo('新任务')).called(1);
});
});
}
2.3 异步代码测试
group('异步测试', () {
test('Future completes with value', () async {
final result = await fetchData();
expect(result, isNotNull);
});
test('Stream emits expected values', () {
final stream = Stream.fromIterable([1, 2, 3]);
expect(
stream,
emitsInOrder([1, 2, 3, emitsDone]),
);
});
test('Stream periodic', () async {
final stream = Stream.periodic(
Duration(milliseconds: 100),
(i) => i,
).take(3);
await expectLater(
stream,
emitsInOrder([0, 1, 2, emitsDone]),
);
});
test('Timeout test', () async {
await expectLater(
slowOperation().timeout(Duration(seconds: 1)),
throwsA(isA<TimeoutException>()),
);
});
});
一句话总结:单元测试是测试金字塔的基石,Mockito 让依赖隔离测试变得简单,而 expect(emitsInOrder) 让 Stream 测试像列表断言一样直观。
三、Widget 测试
3.1 基础 Widget 测试
// lib/counter_page.dart
class CounterPage extends StatefulWidget {
@override
_CounterPageState createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _counter = 0;
void _increment() => setState(() => _counter++);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Center(child: Text('$_counter', key: Key('counter'))),
floatingActionButton: FloatingActionButton(
key: Key('increment'),
onPressed: _increment,
child: Icon(Icons.add),
),
),
);
}
}
// test/counter_page_test.dart
void main() {
testWidgets('Counter increments when button is tapped',
(WidgetTester tester) async {
// 构建 Widget
await tester.pumpWidget(CounterPage());
// 查找初始计数器值
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// 点击按钮
await tester.tap(find.byKey(Key('increment')));
await tester.pump(); // 重建 Widget
// 验证
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
3.2 测试 Provider/Riverpod 集成
// 测试带有状态管理的 Widget
testWidgets('购物车添加商品', (WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: ProductList()),
),
);
// 查找第一个商品
expect(find.text('iPhone 15'), findsOneWidget);
// 点击"加入购物车"
await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
await tester.pumpAndSettle();
// 验证购物车徽标
expect(find.text('1'), findsOneWidget);
});
3.3 表单验证测试
testWidgets('登录表单验证', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: LoginPage()));
// 不填表单直接提交
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 验证错误提示
expect(find.text('请输入邮箱'), findsOneWidget);
expect(find.text('请输入密码'), findsOneWidget);
// 输入无效邮箱
await tester.enterText(find.byKey(Key('email')), 'invalid');
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(find.text('邮箱格式不正确'), findsOneWidget);
// 输入正确信息
await tester.enterText(find.byKey(Key('email')), 'test@example.com');
await tester.enterText(find.byKey(Key('password')), 'password123');
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 错误提示消失
expect(find.text('请输入邮箱'), findsNothing);
});
一句话总结:Widget 测试通过纯 Dart 代码模拟用户交互,无需启动真机或模拟器,运行速度比集成测试快两个数量级。
四、集成测试
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('端到端测试', () {
testWidgets('完整登录流程', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// 在登录页
expect(find.text('登录'), findsOneWidget);
// 输入凭证
await tester.enterText(
find.byKey(Key('email_field')),
'user@example.com',
);
await tester.enterText(
find.byKey(Key('password_field')),
'password123',
);
// 点击登录
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle(Duration(seconds: 2));
// 验证登录成功
expect(find.text('首页'), findsOneWidget);
expect(find.byKey(Key('welcome_message')), findsOneWidget);
});
testWidgets('添加任务流程', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// 导航到任务页
await tester.tap(find.byIcon(Icons.task));
await tester.pumpAndSettle();
// 点击添加按钮
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
// 输入任务
await tester.enterText(
find.byType(TextField).first,
'编写 Flutter 集成测试',
);
// 保存
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
// 验证任务出现在列表
expect(find.text('编写 Flutter 集成测试'), findsOneWidget);
});
});
}
一句话总结:集成测试运行在真机或模拟器上,验证完整用户流程,虽然运行较慢,但对核心路径的保障不可或缺。
五、Golden 测试(UI 回归)
testWidgets('登录页面 Golden 测试', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: LoginPage()));
await tester.pumpAndSettle();
await expectLater(
find.byType(LoginPage),
matchesGoldenFile('goldens/login_page.png'),
);
});
一句话总结:Golden 测试通过像素级对比防止 UI 非预期变更,是设计系统维护的重要工具。
六、性能测试与基准
// 使用 testWidgets 的 timeout 参数控制超时
testWidgets('列表滚动性能', (WidgetTester tester) async {
await tester.pumpWidget(MyApp());
final stopwatch = Stopwatch()..start();
// 执行 100 次滚动
for (int i = 0; i < 100; i++) {
await tester.fling(find.byType(ListView), Offset(0, -200), 1000);
await tester.pumpAndSettle();
}
stopwatch.stop();
print('100 次滚动耗时: ${stopwatch.elapsedMilliseconds}ms');
expect(stopwatch.elapsedMilliseconds, lessThan(5000));
}, timeout: Timeout(Duration(seconds: 30)));
DevTools 性能分析
# 记录性能跟踪
flutter run --profile --trace-skia
# 在 DevTools 中分析:
# 1. Timeline 查看帧绘制时间(目标 < 16ms @ 60fps)
# 2. Memory 检查是否有泄漏
# 3. CPU Profiler 定位热点函数
| 性能指标 | 目标值 | 严重阈值 |
|---|---|---|
| 首帧渲染 | < 100ms | > 500ms |
| 帧时间(60fps) | < 16.67ms | 持续 > 33ms |
| 内存增量 | 稳定 | 持续增长 |
| GC 暂停 | < 5ms | > 20ms |
七、CI/CD 集成测试
# .github/workflows/flutter-test.yml
name: Flutter CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
- run: flutter pub get
- run: flutter analyze --fatal-infos
- run: flutter test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: coverage/lcov.info
fail_ci_if_error: false
Golden 测试 CI 策略:
- 在 CI 中运行 Golden 测试时使用
--update-goldens生成基线 - 将 golden 文件纳入版本控制
- 不同平台(macOS/Linux)渲染可能有 1-2 像素差异,建议固定 CI 环境
八、测试驱动开发(TDD)在 Flutter 中的实践
// 1. 先写测试(失败)
testWidgets('购物车添加商品后显示总价', (tester) async {
await tester.pumpWidget(MaterialApp(home: CartPage()));
await tester.tap(find.text('添加 iPhone'));
await tester.pump();
expect(find.text('总计: ¥5999'), findsOneWidget);
});
// 2. 实现最小代码使测试通过
class CartPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(children: [
TextButton(
onPressed: () {}, // TODO
child: Text('添加 iPhone'),
),
Text('总计: ¥5999'),
]),
);
}
}
// 3. 重构并完善
// ...
TDD 在 Flutter 中尤其有效,因为 Widget 测试可以提供即时的 UI 反馈循环。
FAQ
Q1: 测试运行很慢怎么办?
- 单元测试应该在毫秒级完成
- Widget 测试避免 pumpAndSettle(使用 pump 指定帧数)
- 使用
setUp而非setUpAll避免状态污染 - 将 heavy initialization 移到
setUpAll,在测试间共享
Q2: 如何处理平台通道测试?
setUp(() {
const channel = MethodChannel('com.example.app/device');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getBatteryLevel') return '85%';
return null;
});
});
Q3: 覆盖率目标应该是多少?
| 模块类型 | 目标覆盖率 | 优先级 |
|---|---|---|
| 核心业务逻辑 / Repository | > 80% | P0 |
| ViewModel / BLoC / Controller | > 70% | P0 |
| Widget 交互路径 | > 50% | P1 |
| 工具类 / 扩展方法 | > 60% | P1 |
| Main / App 入口 | > 20% | P2 |
| 第三方封装 | 可选 | P3 |
避免追求 100% 覆盖率(边际收益递减),关注变更 diff 的覆盖率更有意义。
相关阅读
- https://plumephp.com/flutter-state-management/ — 状态管理(含测试策略)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。