开篇:为什么 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 非预期变更,是设计系统维护的重要工具。
FAQ
Q1: 测试运行很慢怎么办?
- 单元测试应该在毫秒级完成
- Widget 测试避免 pumpAndSettle(使用 pump 指定帧数)
- 使用
setUp而非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: 覆盖率目标应该是多少?
- 核心业务逻辑:> 80%
- UI 层:> 50%(关键交互路径)
- 避免追求 100% 覆盖率(边际收益递减)
相关阅读
- https://plumephp.com/flutter-state-management/ — 状态管理(含测试策略)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。