简介
Widget 测试用于测试 UI 组件的行为和渲染。本文介绍如何在 Flutter 中编写 Widget 测试。
基本配置
添加依赖
dev_dependencies:
flutter_test:
sdk: flutter
创建测试文件
// test/widget_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/my_widget.dart';
void main() {
testWidgets('MyWidget should display a title', (tester) async {
await tester.pumpWidget(MyWidget(title: 'Hello'));
expect(find.text('Hello'), findsOneWidget);
});
}
测试工具
WidgetTester
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// 构建 Widget
await tester.pumpWidget(MyApp());
// 查找组件
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// 触发点击
await tester.tap(find.byIcon(Icons.add));
// 等待动画完成
await tester.pumpAndSettle();
// 验证结果
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
Finder
// 通过文本查找
find.text('Hello');
// 通过类型查找
find.byType(RaisedButton);
// 通过 Key 查找
find.byKey(Key('my_button'));
// 通过图标查找
find.byIcon(Icons.add);
// 通过语义标签查找
find.bySemanticsLabel('Add');
// 通过工具方法查找
find.widgetWithText(RaisedButton, 'Click me');
完整示例
// 被测试的组件
class LoginForm extends StatefulWidget {
final Function(String, String) onSubmit;
const LoginForm({Key? key, required this.onSubmit}) : super(key: key);
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(
_emailController.text,
_passwordController.text,
);
}
},
child: Text('Login'),
),
],
),
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
// 测试文件
void main() {
group('LoginForm', () {
testWidgets('should show validation errors', (tester) async {
// 构建组件
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {},
),
),
));
// 点击登录按钮
await tester.tap(find.text('Login'));
await tester.pump();
// 验证错误提示
expect(
find.text('Please enter your email'),
findsOneWidget,
);
expect(
find.text('Please enter your password'),
findsOneWidget,
);
});
testWidgets('should call onSubmit with form data', (tester) async {
String? submittedEmail;
String? submittedPassword;
// 构建组件
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
submittedEmail = email;
submittedPassword = password;
},
),
),
));
// 输入表单数据
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'[email protected]',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Password'),
'password123',
);
// 点击登录按钮
await tester.tap(find.text('Login'));
await tester.pump();
// 验证提交的数据
expect(submittedEmail, equals('[email protected]'));
expect(submittedPassword, equals('password123'));
});
});
}
测试异步操作
testWidgets('should show loading indicator', (tester) async {
// 构建组件
await tester.pumpWidget(MyAsyncWidget());
// 验证初始状态
expect(find.byType(CircularProgressIndicator), findsNothing);
// 触发异步操作
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 验证加载状态
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 等待异步操作完成
await tester.pumpAndSettle();
// 验证最终状态
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Done'), findsOneWidget);
});
最佳实践
- 等动画结束用
tester.pumpAndSettle(),别用固定sleep/Future.delayed:固定延时让测试又慢又 flaky——机器快了白白等,机器慢了照样超时挂。pumpAndSettle会等帧调度队列空下来才返回,动画多长都不怕。 - 找元素用语义稳定的定位器:
find.byKey(Key('login_button'))比find.text('登录')更稳——文案一改(比如”登录”→“立即登录”)文本定位全挂。Key 是契约,文本是展示。 - 每个测试只做一件事:一个
testWidgets断言一个行为。实战案例里 Tab 顺序改错被抓到,就是因为”渲染三个 Tab”和”点击切换 Tab”写在不同用例里——混在一起时一个挂掉都看不出是哪一步。 - 带依赖的页面在测试里原样包环境:用了 GetX 就包
GetMaterialApp,用了 ScreenUtil 就包ScreenUtilInit(实战案例里的两个坑)。widget 测试失败第一反应应该是”测试环境跟生产不一致”,而不是”业务逻辑错了”。 - 别测框架已经测过的东西:
TextField能不能输入、ListView能不能滚动,是 Flutter 团队的活。你该测的是你的东西:输入错误邮箱后是否出现你的那行提示文案、提交后onSubmit拿到的值对不对。
注意事项
pumpAndSettle会卡死的两种情况:动画是无限循环的(如无限旋转的 loading)、或者有永远 pending 的 Timer/Future(轮询)。这时测试直接 timeout。做法:无限动画的页面改用tester.pump(Duration(...))手动推几帧,或者给动画加个可注入的开关。tester.tap前确认元素可见:被键盘挡住、需要滚动的按钮,直接 tap 会点到空气或抛异常。先await tester.ensureVisible(finder)再 tap——登录页小屏手机上键盘弹起挡住按钮是真踩过的坑。- 测试间别共享可变状态:controller、全局单例、
Get.put注册的依赖,放在setUp里每次新建。否则用例 A 留下的登录态会让用例 B 莫名通过,单独跑 B 又挂——CI 上随机红绿最难查。 - 资源要释放:
TextEditingController在dispose里 dispose(正文示例就是这么写的),GetX controller 用完Get.delete。widget 测试跑几百个用例,泄漏的监听器会让后面的测试越来越慢。 - 别过度测试:一个表单的 20 种非法输入组合不需要 20 个 widget 测试——校验函数抽成纯 Dart 函数用单元测试覆盖,widget 测试只保留”非法输入→提示出现”一条主线。
实战案例
电商奢侈品专区改版 里有我写过最认真的 widget 测试(test/pages/main/work_order_list_page_test.dart)。工单列表页有”全部/待办/办结”三个 Tab,测试长这样:
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(750, 1334),
builder: (_, __) => const GetMaterialApp(home: WorkOrderListPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('全部工单'), findsOneWidget);
expect(find.text('待办'), findsOneWidget);
两个坑都是真踩的:第一,页面用了 flutter_screenutil,测试里必须包 ScreenUtilInit,不然 .w 适配直接炸;第二,用了 GetX 就得 GetMaterialApp 而不是 MaterialApp,否则 Get.find 找不到 controller。这个测试帮我抓到过一次 Tab 顺序改错的回归——UI 测试的价值不在”测逻辑”,在”防手滑改坏”。
总结
Widget 测试是确保 UI 组件正确性的重要手段。通过编写全面的 Widget 测试,可以提高应用的可靠性和可维护性。
