跳转到正文
YGJoon 技术博客

Flutter Widget 测试详解

发布于:
·1.5k 字 · 约 4 分钟·作者:
testWidgets 搭测试环境,pumpWidget 渲染组件,Finder 定位元素、pumpAndSettle 等动画收尾,UI 交互一次测全
Flutter Widget 测试详解文章封面
目录

简介

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);
});

最佳实践

  1. 等动画结束用 tester.pumpAndSettle(),别用固定 sleep/Future.delayed:固定延时让测试又慢又 flaky——机器快了白白等,机器慢了照样超时挂。pumpAndSettle 会等帧调度队列空下来才返回,动画多长都不怕。
  2. 找元素用语义稳定的定位器:find.byKey(Key('login_button')) 比 find.text('登录') 更稳——文案一改(比如”登录”→“立即登录”)文本定位全挂。Key 是契约,文本是展示。
  3. 每个测试只做一件事:一个 testWidgets 断言一个行为。实战案例里 Tab 顺序改错被抓到,就是因为”渲染三个 Tab”和”点击切换 Tab”写在不同用例里——混在一起时一个挂掉都看不出是哪一步。
  4. 带依赖的页面在测试里原样包环境:用了 GetX 就包 GetMaterialApp,用了 ScreenUtil 就包 ScreenUtilInit(实战案例里的两个坑)。widget 测试失败第一反应应该是”测试环境跟生产不一致”,而不是”业务逻辑错了”。
  5. 别测框架已经测过的东西:TextField 能不能输入、ListView 能不能滚动,是 Flutter 团队的活。你该测的是你的东西:输入错误邮箱后是否出现你的那行提示文案、提交后 onSubmit 拿到的值对不对。

注意事项

  1. pumpAndSettle 会卡死的两种情况:动画是无限循环的(如无限旋转的 loading)、或者有永远 pending 的 Timer/Future(轮询)。这时测试直接 timeout。做法:无限动画的页面改用 tester.pump(Duration(...)) 手动推几帧,或者给动画加个可注入的开关。
  2. tester.tap 前确认元素可见:被键盘挡住、需要滚动的按钮,直接 tap 会点到空气或抛异常。先 await tester.ensureVisible(finder) 再 tap——登录页小屏手机上键盘弹起挡住按钮是真踩过的坑。
  3. 测试间别共享可变状态:controller、全局单例、Get.put 注册的依赖,放在 setUp 里每次新建。否则用例 A 留下的登录态会让用例 B 莫名通过,单独跑 B 又挂——CI 上随机红绿最难查。
  4. 资源要释放:TextEditingController 在 dispose 里 dispose(正文示例就是这么写的),GetX controller 用完 Get.delete。widget 测试跑几百个用例,泄漏的监听器会让后面的测试越来越慢。
  5. 别过度测试:一个表单的 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 测试,可以提高应用的可靠性和可维护性。


分享这篇文章



相关文章

上一篇
Flutter 单元测试详解
下一篇
Flutter 动画曲线详解
YGJoon 的头像

YGJoon

记录 Flutter、Web 与原生移动端工程实践。更多背景见关于页,源码与项目见GitHub。