Flutter 测试入门:单元测试与 widget test

《Flutter 测试入门:单元测试与 widget test》

本文做什么,不做什么

给一个普通 Flutter 应用从零配上测试:纯逻辑跑单元测试、界面跑 widget test,最后 integration_test 和 CI 各一段带过。边界先说死:golden 图像比对不展开;CI 只给 GitHub Actions 的关键步骤,不做多平台矩阵;性能测试(integration_test 的 timeline)也不在本文范围。版本口径:Flutter 3.22(2024-05-14 GA,Google I/O 发布,配套 Dart 3.4),flutter_test 与 integration_test 都是 SDK 自带,mock 用 mocktail。看完能拿到的是一套可复制的样板:一个 model 的单元测试、一个计数组件和一个待办项组件的完整 widget 测试,全部带预期输出。

三层测试,各管一段

Flutter 官方把测试分三层,分工搞混是新手最常见的内耗来源:

层次 测什么 跑在哪 速度
单元测试(test) 纯 Dart 逻辑:model、工具函数、状态计算 Dart VM,无界面 毫秒级
widget test 单个组件:渲染、交互、状态更新 假环境(无 GPU、假时钟) 毫秒到秒级
integration test 整个应用:真实设备上走完整流程 真机 / 模拟器 秒到分钟级

我的配比:能用单元测试测的逻辑绝不上 widget test,能用 widget test 测的交互绝不上 integration test——上层测试慢一个量级,坏一个测试的定位成本也贵一个量级。这个层次感在《Flutter 学习路线》阶段四的验收标准里就有雏形:那份验收表里的每一条,其实都该落成用例,本文就是把”落成用例”这件事教会。

第一条单元测试

拿待办应用里的 model 开刀。先看被测代码:

// lib/models/todo.dart
class TodoItem {
  TodoItem({required this.title, DateTime? createdAt})
      : createdAt = createdAt ?? DateTime.now() {
    if (title.trim().isEmpty) {
      throw ArgumentError('标题不能为空');
    }
  }

  final String title;
  final DateTime createdAt;
  bool done = false;

  void toggle() => done = !done;
}

测试文件放 test/ 目录,镜像 lib 结构。flutter_test 自带 test()、group() 和整套 matcher:

// test/models/todo_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:todo_app/models/todo.dart';

void main() {
  group('TodoItem', () {
    test('空白标题拒绝创建', () {
      expect(() => TodoItem(title: '  '), throwsArgumentError);
    });

    test('toggle 翻转完成状态', () {
      final todo = TodoItem(title: '买牛奶');
      expect(todo.done, isFalse);
      todo.toggle();
      expect(todo.done, isTrue);
    });

    test('createdAt 可注入固定值', () {
      final t = DateTime(2024, 6, 1);
      expect(TodoItem(title: 'a', createdAt: t).createdAt, t);
    });
  });
}

跑起来:

flutter test test/models/todo_test.dart

预期输出:

00:01 +3: All tests passed!

三条用例钉住三件事:非法输入的拒绝路径、状态翻转、可注入时间(为将来测”今天到期”这类逻辑留口子)。写用例的顺序我习惯先钉边界再补常规——空白标题这条的价值比”正常创建”那条大,因为它是唯一会炸的地方。语法细节拿不准时翻《Dart 语法要点》。

widget test:pumpWidget、finder 与断言

widget test 的骨架固定三步:pumpWidget 把组件挂上去、finder 找东西、expect 断言。先看被测组件:

// lib/counter_page.dart
class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('$_count', key: const Key('count')),
            IconButton(
              icon: const Icon(Icons.add),
              onPressed: () => setState(() => _count++),
            ),
          ],
        ),
      ),
    );
  }
}

测试:

// test/counter_page_test.dart
void main() {
  testWidgets('初始计数为零', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: CounterPage()));
    expect(find.byKey(const Key('count')), findsOneWidget);
    expect(find.text('0'), findsOneWidget);
  });

  testWidgets('点击加号计数加一', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: CounterPage()));

    expect(find.text('0'), findsOneWidget);
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // 触发一帧重建

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

预期输出:

00:01 +2: All tests passed!

常用 finder 就五个,背下来够用大半年:find.text('买牛奶')、find.byType(Checkbox)、find.byIcon(Icons.add)、find.byKey(Key('count'))、find.widgetWithText(ElevatedButton, '提交')。断言后缀配四件套:findsOneWidget、findsNothing、findsNWidgets(2)、findsWidgets(至少一个)。

新手第一坑在这儿:把 await tester.pump() 那行删掉,用例立刻红,报错长这样:

Expected: exactly one matching node in the widget tree
  Actual: WidgetFinder:<zero widgets with text "1">

原因一句话:setState 之后 Flutter 没有立刻重绘,要等下一帧;widget test 里帧不会自己走,得用 pump 手动推。手点应用时感觉不到这个间隙,测试环境替你把它暴露出来了——这不是测试的毛病,是它诚实的证明。

pump 与 pumpAndSettle:异步帧的两把钥匙

pump() 推进一帧,可以带时长:await tester.pump(const Duration(seconds: 2)) 表示把假时钟拨快两秒再渲染一帧。widget test 里时钟是假的,Duration 就是个可以随便拨的旋钮——这是 Flutter 测试体系最讨我喜欢的设计:Future.delayed 一秒的业务逻辑,测试里拨一秒表就过,真实等待为零。

pumpAndSettle() 则是”推帧推到没有动画任务为止”,适合验证动画结束后的最终状态。它的死穴:遇到永不停止的动画(转圈 loading、无限轮播)会一直推到超时,用例像卡死。我的习惯:拿不准就用带时长的 pump,能写清楚”拨多少”就不要”等到不动”。

FutureBuilder 的例子把两件事串起来:

class _GoodsPageState extends State<GoodsPage> {
  late final Future<String> _future;

  @override
  void initState() {
    super.initState();
    _future = Future.delayed(const Duration(seconds: 1), () => '商品 A');
  }
  // build 里 FutureBuilder(future: _future, ...),有数据显示名称,
  // 否则显示'加载中…'
}

testWidgets('加载完成后显示数据', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: GoodsPage()));
  expect(find.text('加载中…'), findsOneWidget); // 初始帧

  await tester.pump(const Duration(seconds: 2)); // 拨过延时
  expect(find.text('商品 A'), findsOneWidget);
});

顺带钉一个真 bug 的复现:如果把 future 挪进 build 里现造,每次重建都是新 Future,测试会永远停在”加载中”——这条用例在生产代码里同样成立,FutureBuilder 的 future 在 build 里现造是常见的隐性 bug,widget test 一 pump 就现形。

待办项组件的完整测试

拿《用 Flutter 做一个本地优先的待办应用》里的列表项组件当靶子——文章里靠手点验收的勾选行为,落成用例就这几行:

// lib/widgets/todo_tile.dart
class TodoTile extends StatelessWidget {
  const TodoTile({super.key, required this.item, required this.onChanged});

  final TodoItem item;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Checkbox(value: item.done, onChanged: (v) => onChanged(v!)),
      title: Text(item.title),
    );
  }
}
// test/widgets/todo_tile_test.dart
void main() {
  testWidgets('未完成项初始不勾选', (tester) async {
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(body: TodoTile(
        item: TodoItem(title: '买牛奶'),
        onChanged: (_) {},
      )),
    ));
    final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
    expect(checkbox.value, isFalse);
  });

  testWidgets('点击勾选回调携带 true', (tester) async {
    bool? reported;
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(body: TodoTile(
        item: TodoItem(title: '买牛奶'),
        onChanged: (v) => reported = v,
      )),
    ));

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

    expect(reported, isTrue);
  });
}

两条用例展示两种断言姿势:读组件配置用 tester.widget<Checkbox>(...)(比靠渲染细节断言稳),验交互副作用用回调捕获。这里有个细节值得点破:回调断言其实不依赖 pump——tap 会同步触发 onChanged;pump 是给后面可能加的界面断言留位子。界面上”勾了没”归 pump 管,回调”传了没”归变量管,两层分开想清楚,用例就不会写玄。

mock:mocktail,不碰代码生成

测到依赖外部数据的组件,给个假仓库。2024 年我的选型是 mocktail:零代码生成、API 靠闭包,对比 mockito 省掉 build_runner 那套流程,写起来顺手得多。装依赖:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
  mocktail: ^1.0.4
// lib/repo.dart
abstract class TodoRepository {
  Future<List<TodoItem>> loadAll();
  Future<void> save(TodoItem item);
}

// test/repo_test.dart
class MockTodoRepository extends Mock implements TodoRepository {}

void main() {
  late MockTodoRepository repo;

  setUp(() {
    repo = MockTodoRepository();
  });

  test('loadAll 返回预置数据', () async {
    when(() => repo.loadAll())
        .thenAnswer((_) async => [TodoItem(title: '买牛奶')]);

    final items = await repo.loadAll();

    expect(items, hasLength(1));
    verify(() => repo.loadAll()).called(1);
  });
}

一个会咬人的细节:when/verify 里用 any() 匹配自定义类型的参数时(比如 verify(() => repo.save(any())).called(1)),mocktail 需要先 registerFallbackValue(TodoItem(...)),否则运行时直接抛 MissingStubError 类的错——第一次撞见会摸不着头脑,记一笔省十分钟。本例用的是精确调用匹配,踩不到这条。

integration test 与 CI,各一段带过

integration_test 同样是 SDK 自带,API 就是 testWidgets 那一套,只是跑在真机或模拟器上:

flutter test integration_test/app_test.dart -d <device_id>

冒烟级用途最划算:应用能启动、首页关键元素在、核心流程走通。全量回归交给上层那两层,别把 widget test 能挡的东西漏到这一层。

CI 的关键步骤,本地全绿是前置条件:

# .github/workflows/test.yml 关键步骤
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2   # action 版本以仓库当前 README 为准
  with:
    flutter-version: '3.22.0'
- run: flutter pub get
- run: flutter test --coverage

--coverage 会生成 coverage/lcov.info。要不要卡覆盖率阈值,我的建议是新项目先不卡,攒够第一批用例、知道正常水位后再设一条低水位线防滑坡——上来就卡 80% 的项目,最后收获的是一堆没有断言的凑数用例。

验收清单

  • flutter test 全绿,输出形如 00:01 +N: All tests passed!;
  • 故意把 toggle 那条断言改成 isFalse,重跑变红——确认测试真的在拦;
  • 删掉点击用例里的 await tester.pump(),用例变红且报 zero widgets——帧推进的理解过关;
  • 把 FutureBuilder 的 future 挪进 build 现造,用例卡在”加载中”——复现这个隐性 bug 并改回去;
  • verify 的 called(1) 改成 called(2) 变红——mock 验证在工作;
  • (可选)integration_test 在模拟器上跑通冒烟用例。

学完之后

这套样板落进《Flutter 学习路线》的位置:阶段四实战篇的验收表(飞行模式可用、杀进程数据不丢、主题记忆)每一条都能翻译成用例——”杀进程数据不丢”的正解就是给持久层写单元测试,而不是靠热重载手点(热重载保留 State,测出来的是内存不是磁盘,实战篇第六节有完整解释)。继续深入三个方向:golden 文件测试(截图比对,UI 回归利器)、integration_test 的性能面(timeline 追帧)、以及状态管理库各自的测试工具—— setState 都测熟了,换 Riverpod 时心智是平移的。

本文数据核对时间:2024-06-24:Flutter 3.22(2024-05-14 Google I/O 发布,配套 Dart 3.4,flutter_test / integration_test 随 SDK 分发);mocktail 1.0.4(pub.dev 1.0.x 系列当前版,2023 年发布);GitHub Actions 的 checkout / flutter-action 版本以各仓库当前 README 为准。API 细节以 docs.flutter.dev/testing 与 pub.dev 文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇