mcpField
F
flutter-add-widget-test

flutter-add-widget-test

github.com/dhruvanbhalara测试与质量3051 次安装9 周前更新
代码仓库

关于 flutter-add-widget-test

为 Flutter 开发者提供添加 Widget 测试的实践指南,涵盖查找器、交互、帧推进及 BLoC 测试,并整理常见错误与测试流程。

主要功能

  1. 01Widget 测试核心 API
  2. 02基于 Key 的 Widget 查找
  3. 03模拟点击、输入与滚动
  4. 04动画与时间推进策略
  5. 05BLoC 和 Cubit 测试
  6. 06常见问题排查流程

适用场景

  1. 01为交互控件编写状态变更测试
  2. 02验证表单输入和校验结果
  3. 03测试长列表中的屏幕外项目
  4. 04排查动画或布局导致的测试失败

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dhruvanbhalara/skills --skill flutter-add-widget-test

安装后在 Claude Code 中输入/flutter-add-widget-test确认已加载。

详细介绍

目录

设置

  • flutter_test 是 SDK 依赖项,因此无需执行 pub add。
  • 测试文件命名:test/<mirror_path>/<widget>_test.dart。
  • 每个测试文件都以以下内容开头:
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    
  • 所有 Widget 测试都使用 testWidgets('description', (WidgetTester tester) async { ... })。
  • 始终将待测 Widget 包装在 MaterialApp 中,以提供 MediaQuery、Theme 和 Navigator 上下文。

核心 API

API用途
tester.pumpWidget(widget)在测试环境中渲染 Widget
tester.pump()触发单帧
tester.pump(Duration(...))前进指定时长
tester.pumpAndSettle()等待所有动画完成
tester.tap(finder)模拟点击手势
tester.longPress(finder)模拟长按
tester.enterText(finder, 'text')在文本字段中输入内容
tester.drag(finder, Offset(dx, dy))模拟拖动手势
tester.scrollUntilVisible(finder, delta)滚动直到 Widget 可见

查找器模式

使用查找器在测试树中定位 Widget。优先使用基于 Key 的查找器,以提高稳定性。

  • find.byKey(const ValueKey('login_button')):首选。重构后最稳定。
  • find.byType(ElevatedButton):按 Widget 类型查找。如果存在多个实例则会失败。
  • find.text('Submit'):按显示文本查找。避免用于本地化字符串。
  • find.byIcon(Icons.add):按图标数据查找。
  • find.descendant(of: parentFinder, matching: childFinder):嵌套查找。
  • find.ancestor(of: childFinder, matching: parentFinder):反向查找。

Key 命名约定:在可交互的 Widget 上使用 Key('feature_action_id') 格式。

// Production code
ElevatedButton(
  key: const Key('login_submit_button'),
  onPressed: _onSubmit,
  child: const Text('Login'),
)

// Test code
final submitButton = find.byKey(const Key('login_submit_button'));
await tester.tap(submitButton);

交互模式

点击并验证状态变化
testWidgets('increments counter on tap', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: CounterPage()));

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byKey(const Key('increment_button')));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});
输入文本并验证表单
testWidgets('validates email field', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: LoginForm()));

  await tester.enterText(find.byKey(const Key('email_field')), 'invalid');
  await tester.tap(find.byKey(const Key('submit_button')));
  await tester.pumpAndSettle();

  expect(find.text('Enter a valid email'), findsOneWidget);
});
滚动到屏幕外的 Widget
testWidgets('finds item in long list', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: ItemListPage()));

  final listFinder = find.byType(Scrollable);
  final itemFinder = find.byKey(const Key('item_99'));

  await tester.scrollUntilVisible(itemFinder, 500.0, scrollable: listFinder);

  expect(itemFinder, findsOneWidget);
});

Pump 策略

根据场景选择合适的 pump 方法:

场景方法原因
简单的状态变化pump()单帧即可
动画完成pumpAndSettle()等待所有帧
定时动画pump(Duration(milliseconds: 300))前进指定时长
无限动画(例如 CircularProgressIndicator)pump()pumpAndSettle() 会超时
防抖输入pump(Duration(milliseconds: 500))等待防抖时长

警告:无限动画运行时,pumpAndSettle() 会抛出 PumpAndSettleTimedOutException。测试加载状态时请改用 pump()。

使用 BLoC 进行测试

测试依赖 BLoC/Cubit 的 Widget 时:

testWidgets('shows user name from BLoC', (tester) async {
  final mockBloc = MockUserBloc();

  whenListen(
    mockBloc,
    Stream.fromIterable([UserLoaded(User(name: 'Alice'))]),
    initialState: UserInitial(),
  );

  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider<UserBloc>.value(
        value: mockBloc,
        child: const UserProfilePage(),
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(find.text('Alice'), findsOneWidget);
});
  • 使用 bloc_test 包中的 MockBloc / MockCubit。
  • 使用 whenListen() 为状态流响应添加桩。
  • 使用 BlocProvider.value() 将模拟对象注入 Widget 树。

常见问题

错误原因修复方法
No MediaQuery widget ancestor缺少 MaterialApp 包装使用 MaterialApp(home: ...) 包装
A RenderFlex overflowedWidget 超出测试视口使用 SizedBox 或 Expanded 限定尺寸
Vertical viewport was given unbounded heightListView 没有高度约束使用 SizedBox(height: 600) 包装
导航后找不到 Widget缺少 pumpAndSettle()导航后添加 await tester.pumpAndSettle()
PumpAndSettleTimedOutException无限动画正在运行改用 pump(),而不是 pumpAndSettle()

工作流:添加 Widget 测试

任务进度
  • 步骤 1:在生产代码的交互式组件中添加 Key。
  • 步骤 2:在 test/<mirror_path>/<widget>_test.dart 创建测试文件。
  • 步骤 3:用 MaterialApp 包装组件,并调用 tester.pumpWidget()。
  • 步骤 4:使用合适的查找器(优先使用 byKey)。
  • 步骤 5:模拟交互(tap、enterText、drag)。
  • 步骤 6:使用 expect(finder, findsOneWidget) 或状态检查进行断言。
  • 步骤 7:应用 Golden Variant / State Matrix / Interaction Contract 模式(参见 flutter-testing)。
  • 步骤 8:运行 flutter test test/path/to/widget_test.dart。
  • 步骤 9:反馈循环:修复失败 → 重新运行,直到全部通过。

示例

最小组件测试
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/features/counter/counter_page.dart';

void main() {
  group('$CounterPage', () {
    testWidgets('renders initial counter value', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: CounterPage()));

      expect(find.text('0'), findsOneWidget);
      expect(find.byType(FloatingActionButton), findsOneWidget);
    });

    testWidgets('increments counter when FAB is tapped', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: CounterPage()));

      await tester.tap(find.byType(FloatingActionButton));
      await tester.pump();

      expect(find.text('1'), findsOneWidget);
    });
  });
}

评论

支持 Markdown