最后核验:2026-09-08

本篇结论

把业务规则写成明确的输入和预期结果,再交给测试工具自动比较。同步结果用 expect,同步异常把调用包进函数,Future 和 Stream 用 await expectLater 等待匹配完成。测试要能在行为错误时失败,不能只运行代码或打印“通过”。

本篇为第 11~14 课的现有业务逻辑补上 14 个具名测试,复用 app/ 已有的 flutter_test,不增加依赖,也不把测试需求变成重写业务代码的理由。

学完你能做到

  • 把正常输入、无效输入和边界值转成测试用例。
  • 使用 test、group 和 expect 组织可定位的验证。
  • 正确检查同步异常和 Future 失败。
  • 检查 Stream 的事件顺序、错误与结束。
  • 单独运行某项测试,并读懂失败报告。
  • 区分业务单元测试与 Widget、设备验证的范围。

开始前检查

先完成 第 14 课 。本篇测试直接使用前几课的纯 Dart 示例,相关结构为:

examples/lesson_11_functions_and_classes.dart
examples/lesson_12_collections_and_patterns.dart
examples/lesson_13_async_and_errors.dart
examples/lesson_14_streams.dart
app/pubspec.yaml
app/test/widget_test.dart
app/test/lesson_15_business_logic_test.dart

本次实际验证环境为 Flutter 3.47.2、Dart 3.13.2,app/pubspec.lock 中的 matcher 为 0.12.20。沿用 第 08 课 的 FLUTTER_UPSTREAM_HOME。

app/pubspec.yaml 已有以下开发依赖,无需执行添加依赖的命令:

dev_dependencies:
  flutter_test:
    sdk: flutter

Flutter 官方测试教程说明了 test、group 和测试文件命名约定;本项目使用已有的 flutter_test 导出接口来运行这些业务测试。依据见 Flutter 单元测试入门 和 test API 。

01 先把规则写成可观察的结果

第 12~14 课已有独立验证脚本。本课用测试框架表达其中的代表性规则,让失败报告指出是哪项业务行为出了问题。旧脚本继续作为对应章节的运行入口。

业务问题 输入或操作 要验证的结果
标题是否合法 首尾空格、空白、长度 1/80/81 清洗成功,或抛出指定错误
优先级是否越界 0、1、3、4 两个端点通过,越界失败
待办如何排序 不同优先级、同优先级、已完成记录 指定标题顺序,源列表保持原顺序
结果能否被修改 清空源列表、修改结果列表 结果独立,禁止修改
异步加载是否成功 有效、空、无效记录 有效列表、空列表、格式错误
订阅是否完整 数据、错误、结束事件 指定事件顺序,且确实结束

标题长度边界用 ASCII 字符 A 验证,沿用既有的 String.length 规则,不把这个测试扩大解释为用户可见字符或 emoji 的计数方案。

预期值来自业务规则。本例直接写 ['A', 'C', 'B'],不在测试里重新执行一遍同样的排序算法来生成“预期”,否则相同的错误可能同时出现在两边。

02 把第一个规则写成 test

打开 完整测试文件 。测试通过以下路径引用业务代码:

import 'package:flutter_test/flutter_test.dart';

import '../../examples/lesson_11_functions_and_classes.dart';
import '../../examples/lesson_12_collections_and_patterns.dart';
import '../../examples/lesson_13_async_and_errors.dart' show loadIdeas;
import '../../examples/lesson_14_streams.dart' show watchIdeaSnapshots;

这些相对导入用于当前课程仓库,测试需要与 examples/ 一起保留。它们不会执行示例文件的 main()。本篇不把示例移动到 app/lib/,应用业务模型正式落地时再确定归属。

测试文件的 main() 注册用例;每个测试名称说明具体行为:

test('标题去掉首尾空格', () {
  final title = normalizeTitle('  学习 Dart  ');
  expect(title, '学习 Dart');
});

准备输入、执行操作、检查结果,是阅读测试的基本顺序。这里的结果必须精确等于 学习 Dart。expect(actual, matcher) 的第一个参数是实际值,第二个参数是预期值或匹配器,见 expect API 。

相关测试放进同一个 group。本篇使用「标题与对象边界」「集合业务规则」「Future 结果」「Stream 事件」四组。每项测试独立创建自己的可变输入,不依赖前一个测试留下的状态。

03 同步异常要把调用交给匹配器

空白标题应该抛出 StateError:

test('空白标题被拒绝', () {
  expect(() => normalizeTitle('   '), throwsStateError);
});

() => normalizeTitle(' ') 是一个尚未执行的无参数函数。匹配器调用它并检查抛出的错误。如果直接把 normalizeTitle(' ') 作为第一个参数,异常会在 expect 接管之前抛出,测试无法按预期验证这个错误。

没有对应快捷匹配器时,用 throwsA(isA<FormatException>()) 检查异常类型。throwsA 支持抛出异常的无参数函数,以及以错误完成的 Future,见 throwsA API 。

一个测试可以包含属于同一规则的几个边界断言:

test('标题允许 1 和 80 个 ASCII 字符,拒绝 81 个', () {
  expect(normalizeTitle('A'), 'A');
  expect(normalizeTitle('A' * 80), hasLength(80));
  expect(() => normalizeTitle('A' * 81), throwsStateError);
});

不只测试“太长时报错”,还要验证最大合法值确实能通过。否则把上限错误地改成 79,也可能通过只有失败输入的测试。

04 集合测试要覆盖对调用方的影响

完整测试先给出顺序为 B、C、A、Done 的输入,其中 Done 已完成,A 与 C 的优先级相同。执行后检查:

final result = pendingIdeas(source);

expect(result.map((idea) => idea.title), ['A', 'C', 'B']);
expect(source.map((idea) => idea.title), ['B', 'C', 'A', 'Done']);
source.clear();
expect(result, hasLength(3));
expect(() => result.clear(), throwsUnsupportedError);

这组检查分别回答:筛选排序是否正确、是否修改输入、是否共享列表容器、是否允许修改结果。仅检查结果数量为三,会漏掉排序和输入被改写的问题。

标签测试同样比较清洗后的具体值,而不只检查集合长度。空集合和全已完成集合则验证结果为空,不需要为了每个输入都创建一套辅助框架。

05 异步测试必须把完成交给测试框架

成功路径可以先 await 拿到数据,再检查内容。失败路径直接把 Future 交给异步匹配器:

test('无效记录通过 Future 传播格式错误', () async {
  await expectLater(loadIdeas([null]), throwsA(isA<FormatException>()));
});

测试回调使用 async,expectLater 返回一个代表匹配过程的 Future,本例显式等待它。这样测试里的后续步骤不会在匹配完成前继续执行。接口语义见 expectLater API 。

不要用“睡眠 200 毫秒后看看变量是否改变”替代等待真实结果。本例直接等待 loadIdeas(),不把机器运行速度当成正确性的判断条件,也没有访问真实网络。

本篇使用普通 test。已有的计数器页面测试使用 testWidgets,需要 WidgetTester 操作界面;业务函数没有 Widget,不需要 pumpWidget 或额外搭建页面。

06 用事件匹配器检查 Stream

下面的流把第一份未完成快照变为第二份已完成快照,待办数量应从 1 变为 0,然后结束:

test('待办数量依次变化并正常结束', () async {
  const idea = {'title': 'A', 'priority': 1, 'isCompleted': false};
  final counts = watchIdeaSnapshots([
    [idea],
    [
      {...idea, 'isCompleted': true},
    ],
  ]).map((ideas) => pendingIdeas(ideas).length);

  await expectLater(counts, emitsInOrder([1, 0, emitsDone]));
});

emitsInOrder 逐个匹配事件;末尾的 emitsDone 额外要求流结束。如果只写 [1, 0],只证明匹配了这个前缀,无法排除后面还有多余事件。依据见 emitsInOrder 与 emitsDone 。

检查错误事件时,用 emitsError(isA<FormatException>())。完整文件验证了「先收到 1,再收到格式错误,最后结束」,也验证空流与一次空列表事件不同。错误事件匹配规则见 emitsError API 。

每项测试重新创建流。不要把一个单订阅流放在所有测试共用的位置,再让多个测试争用它。

07 运行一项、一组和全部测试

从仓库根目录先检查格式,然后进入应用目录:

"$FLUTTER_UPSTREAM_HOME/bin/dart" format --output=none --set-exit-if-changed \
  app/test/lesson_15_business_logic_test.dart
cd app
"$FLUTTER_UPSTREAM_HOME/bin/flutter" analyze
"$FLUTTER_UPSTREAM_HOME/bin/flutter" test \
  test/lesson_15_business_logic_test.dart --reporter expanded

本篇文件预期通过 14 项测试。--reporter expanded 便于逐行查看结果。若只运行某项或某组,可使用:

"$FLUTTER_UPSTREAM_HOME/bin/flutter" test \
  test/lesson_15_business_logic_test.dart --plain-name "标题去掉首尾空格"
"$FLUTTER_UPSTREAM_HOME/bin/flutter" test \
  test/lesson_15_business_logic_test.dart --plain-name "Future 结果"

两条命令应分别通过 1 项和 3 项。命令路径相对于当前 app/,不要在仓库根目录直接照抄。文件命名和筛选命令依据见 Flutter 单元测试入门 ;本篇参数另经本机命令运行核验。

最后运行应用全部测试:

"$FLUTTER_UPSTREAM_HOME/bin/flutter" test --reporter expanded

本次基线为 15 项通过:本课 14 项业务测试,加原有 1 项 Widget 测试。结果包含 All tests passed!,退出状态为 0。测试并发输出的先后不是业务行为,不要求终端日志顺序固定。

这些命令可能检查并解析已有依赖。大陆环境若下载受阻,沿用 第 03 课 的可信镜像设置;Flutter 官方列出的 CFUG 镜像由中国 Flutter 用户组维护,PUB_HOSTED_URL 用于包下载,FLUTTER_STORAGE_BASE_URL 用于 Flutter 构建产物,不能混为一个下载源。维护方与适用范围于 2026-09-08 核对 Flutter 中国网络说明 。本课无需新增包或升级锁定版本。

08 让测试真实失败一次,再恢复

在第一项测试中,临时把预期字符串 学习 Dart 改为 错误预期,保留被测函数不变,然后运行:

"$FLUTTER_UPSTREAM_HOME/bin/flutter" test \
  test/lesson_15_business_logic_test.dart \
  --plain-name "标题去掉首尾空格" --reporter expanded
echo $?

本次实际复现的关键报告为:

Expected: '错误预期'
  Actual: '学习 Dart'
Some tests failed.

退出状态为 1。报告同时给出测试名和文件位置,应先比较期望与实际行为,再判断是业务实现错了,还是测试预期错了。

这次故意改错的是测试,因此恢复预期为 学习 Dart,重新运行全部测试并确认通过。最终仓库没有保留错误预期,也没有添加 skip、忽略规则或通过捕获测试失败来绕过验证。

这个练习证明匹配器能发现本次差异,不等于测试已覆盖所有业务问题。日后修复真实缺陷时,先添加能复现它的测试,观察失败,再修实现并运行相关回归。

三端差异

  • Track A:使用 Flutter 3.47.2、Dart 3.13.2 实际执行本课测试;测试对象是现有纯 Dart 函数,运行器使用项目已有 Flutter SDK。
  • iOS/Android:单元测试在主机测试环境运行,不代表设备行为已验证。另行完成 iOS Simulator 构建和 Android debug APK 回归构建;本课没有新增页面或执行新的设备交互。
  • Track B:本机未安装 Flutter-OH,本课测试及其测试框架未在该轨道执行,不宣称已经通过鸿蒙兼容验证。

预期结果

  • 得到一个含 14 项业务测试的文件,随 flutter test 自动运行。
  • 同步结果、同步异常、Future 和 Stream 都有可定位的验证。
  • 故意错误的预期能使测试失败,恢复后全部 15 项测试通过。
  • 原有课程示例与应用依赖保持既有实现和版本。

常见问题

纯 Dart 逻辑为什么用 flutter test

测试对象和运行工具是两件事。项目已经依赖 flutter_test,本课借用它运行普通 test,避免再创建包或增加依赖。将来把业务逻辑提取为独立 Dart 包时,再按那个包的需求选择测试入口。

每个函数都必须写一项测试吗

优先覆盖有业务意义的结果、边界和失败,而不是机械对应函数数量。本课一项排序测试同时检查筛选、排序和输入隔离,因为它们共同构成调用方依赖的行为。

为什么不比较两个 SparkIdea 是否相等

当前类没有定义按字段判断相等的规则。本课直接检查标题、数量和完成状态等业务字段,不为了测试新增对象相等性实现。

测试通过是否表示三端都能运行

不是。主机业务测试、构建验证和设备行为验证各自回答不同问题。单元测试不能证明插件、签名、页面布局或平台权限正确。

完成检查

  • 我能先写出有效、无效和边界输入的预期。
  • 我会给测试命名,并用 group 组织相关行为。
  • 我知道同步异常为什么要包在无参数函数中。
  • 我会等待 Future 和 Stream 的匹配完成。
  • 我知道事件前缀匹配与流结束验证的差别。
  • 我已复现一次测试失败,并恢复到全部通过。

一手来源