跳转到正文
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 动画曲线详解