关于 testing-strategy
供 Flutter 和 Dart 团队使用的测试策略技能,按代码层和行为选择测试方式,涵盖确定性测试、内存数据库及无头 Riverpod 测试,并指出需人工检查的路径。
主要功能
- 01选择成本最低的合适测试层
- 02注入并固定时钟以保证确定性
- 03使用带种子的模糊测试和独立预言机
- 04使用内存 Drift 测试数据层
- 05通过 ProviderContainer 驱动 Notifier
- 06记录需要人工测试的路径
适用场景
- 01不启动组件树测试纯领域逻辑
- 02检查数据库约束和迁移
- 03可重复地测试依赖时间的行为
- 04规划设备特定行为的人工检查
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill testing-strategy安装后在 Claude Code 中输入/testing-strategy确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/allocate_property_test.dartin_memory_drift_test.dartnotifier_container_test.dartreferences/coverage-and-budget.mdproperty-and-fakes.mdtest-layers.mdscripts/check_test_hygiene.shrun_tests.sh详细介绍
测试策略
测试是你在构建时能够掌控的唯一正确性手段:根据代码设计测试套件,而不是遵循数十年前的比例;确保每个测试都断言行为,并且构建要么通过,要么失败。适用于 test/ 和 integration_test/ 下的所有测试。
阅读与当前任务相关的参考资料:
references/test-layers.md— 各层的测试框架、导入、拆解、边界情况表(纯核心、内存中的 Drift、无头 Notifier、集成)。references/property-and-fakes.md— 优先使用假对象而非模拟对象、由枚举驱动的假对象状态、带种子的模糊测试、独立判定器、用于验证某类故障不会发生的测试。references/coverage-and-budget.md— 文件级覆盖率下限、修正覆盖率虚高的方法、测试套件耗时预算,以及人工通过后的移交流程。
提交 PR 前运行 scripts/check_test_hygiene.sh 和 scripts/run_tests.sh。
Golden、RTL 和无障碍小组件测试机制见 widget-golden-and-a11y-testing;本技能规范以下所有像素之外的内容。
不可妥协的规则
- 根据代码设计测试套件,而不是遵循测试金字塔。 70/20/10 这个数字源自 2011 年的一种测试规模经验法则(其作者称这些数字是“随手编出来的”),从未用于描述 Flutter 的单元测试/Widget 测试/集成测试分类。选择能断言该行为的最低成本层级:凡是能表示为
f(input) -> output的内容,都应使用单元测试或属性测试,绝不使用pumpWidget。通过 Widget 树驱动纯逻辑会更慢、更不稳定,也会掩盖出问题的层级。 - 将业务规则放在不依赖 Flutter 的包中,并注入
Clock。 领域计算应放在纯 Dart 中,不导入任何 Flutter/插件/IO 内容,并使用package:test(而非flutter_test)进行测试。唯一的时间类型是package:clock中的Clock——绝不使用DateTime.now(),也不自定义ClockService。纯core读取环境中的clock.now(),并在测试中使用withClock(Clock.fixed(t), …)固定时间;Riverpod/功能代码读取注入的clockProvider,并使用clockProvider.overrideWithValue(Clock.fixed(t))覆盖(该接缝由value-objects-money-and-units负责)。此限制通过结构保证:纯包不声明 Flutter SDK 约束。 - 断言不变量,而不只是示例。 每种转换都要有往返测试(
decode(encode(x)) == x),并针对中间值/边界值设置舍入基准测试;每项普遍性声明都要通过带种子的模糊测试循环(for (var seed = 0; seed < N; seed++))或属性测试验证,并与独立预言机比对——绝不能使用正在测试的生产函数。将生成的输入打印在reason:中,让失败本身成为最小复现。整数只使用==;双精度数使用closeTo(x, 1e-9)。 - 使用真正的内存引擎测试数据层。 使用
NativeDatabase.memory(),绝不使用模拟 DAO——模拟 DAO 无法验证 SQL、约束、索引或迁移。仅在数据层之上模拟仓储。使用addTearDown(db.close),并同步关闭流,避免响应式.watch()流泄漏并出现“Timer still pending”。 - 对于自己维护的代码,优先使用裸
implements的伪对象,而不是模拟对象。class FakeX implements X(不继承带有noSuchMethod的父类)会让接口变更成为编译错误,将风险(状态,而非调用顺序)建模为字段,也可作为契约文档。仅对真正的外部依赖使用mocktail;绝不使用mockito/@GenerateMocks(需要代码生成,且没有空安全方面的优势)。 - 在
setUpAll中,为传给any()/captureAny()的每种自定义类型调用registerFallbackValue。mocktail 缺少回退值时会在运行时报错,而不是在编译时——这是最常见的 mocktail 故障。 - 使用
ProviderContainer在无界面环境中驱动 Notifier。 通过overrideWith覆盖 provider 依赖;测试状态时绝不泵入 Widget。断言暴露的AsyncValue/状态,通过.notifier驱动操作,并在拆卸时释放容器。 - 设置一道端到端验收门槛。 用一个真实场景断言精确的预期结果,并验证守恒不变量(各部分之和等于整体)。这是证明各部分能组合工作的测试。在基础函数内部加入运行时断言保险丝(
assert(sum(result) == total))作为保障——发布版中没有开销,出现问题时立即捕获。 - 通过审查差异而非覆盖率门槛,确保不可恢复错误相关文件达到最低覆盖要求。 不设自动百分比门槛——覆盖率只作为公开报告(由
ci-pipeline-and-gates负责)。相反,对少数几个出现缺口就会悄无声息地丢失数据的文件(迁移、金额/分配基础函数、带有线格式陷阱的解析器)保持 100% 的最低覆盖率——通过阅读差异而非统计行数来落实。设置目录级或全局百分比门槛只会鼓励华而不实的测试和不断排除文件。首先修复覆盖率虚高的问题:flutter test --coverage会遗漏没有被任何测试导入的文件,因此报告数字会夸大安全性。 - 绝不对无限时长的动画(启动画面、闪光占位、加载转圈)调用
pumpAndSettle()——这会卡到 10 分钟超时。使用带fakeAsync的定时pump(Duration)。 - 保持测试套件运行迅速,并将结构上无法测试的路径交给人工检查。 耗时几分钟的测试套件会被跳过,而被跳过的套件也不会受到信任。模拟器无法复现的内容(真实音频、不同 OEM 设备、没有 Flutter 引擎的原生界面)应列入命名明确的发布前人工检查,而不是伪造绿色结果——证明不了任何问题的绿色测试,比坦承存在缺口更糟。
根据代码选择测试层级(package:test 与 flutter_test)
纯逻辑通过 dart test 运行,无需绑定 Widget;只有 UI 和插件包装器测试需要 flutter_test。
// test/domain/money_test.dart — pure Dart, `dart test`, no flutter_test import.
import 'package:test/test.dart';
import 'package:app_core/app_core.dart';
void main() {
// Round-trip: encoding is lossless across the ISO-4217 exponent range.
test('minor-units round-trip for exponents 0, 2, 3', () {
for (final currency in [Currency.jpy, Currency.usd, Currency.kwd]) {
final money = Money(amountMinor: 12345, currency: currency);
expect(Money.parse(money.format(), currency), equals(money));
}
});
}
注入时钟;在测试中固定时间
生产代码读取 clock.now();测试会冻结时间,让依赖时间的逻辑保持确定性——不依赖系统时钟,也不会偶发失败。
// Production: a Reminder is due when its target time has passed.
bool isDue(Reminder r) => !clock.now().isBefore(r.dueAt);
// test/domain/reminder_test.dart
test('reminder is due exactly at its due instant', () {
final due = DateTime.utc(2026, 1, 1, 9);
withClock(Clock.fixed(due), () {
expect(isDue(Reminder(dueAt: due)), isTrue);
});
});
对于由 provider 提供的代码,注入 clockProvider,并在容器测试中使用 clockProvider.overrideWithValue(Clock.fixed(due)) 冻结时钟——使用同一个 Clock 和同一个固定时刻,通过 Riverpod 驱动,而不是使用环境区域。
使用独立预言机进行带种子的模糊测试
二十行 package:test 代码就能实现过时的属性测试包所提供的功能,并将生成的输入作为内置的最小复现。预言机必须独立于被测函数。
// test/domain/allocate_test.dart
test('allocate: parts always sum to the input (fuzz)', () {
final rng = Random(0xC0FFEE);
for (var seed = 0; seed < 500; seed++) {
final n = rng.nextInt(20) + 1;
final total = rng.nextInt(100000);
final weights = List.generate(n, (_) => rng.nextInt(500));
final parts = allocate(amountMinor: total, weights: weights);
expect(parts.fold(0, (a, b) => a + b), equals(total),
reason: 'seed=$seed total=$total weights=$weights'); // its own repro
}
});
使用真实的内存 Drift,绝不使用模拟 DAO
// test/data/order_dao_test.dart
void main() {
late AppDatabase db;
setUp(() => db = AppDatabase(NativeDatabase.memory()));
tearDown(() => db.close()); // reactive .watch() streams must not leak timers
test('CHECK constraint rejects a negative quantity', () {
expect(
() => db.orderDao.insertItem(const OrderItem(quantity: -1)),
throwsA(isA<SqliteException>()), // proves the real constraint, not a stub
);
});
}
使用 ProviderContainer 进行无头 Notifier 测试
// test/features/cart/cart_notifier_test.dart
void main() {
test('addItem publishes the new total', () async {
final container = ProviderContainer(
overrides: [orderRepositoryProvider.overrideWith((ref) => _FakeOrderRepo())],
);
addTearDown(container.dispose);
await container.read(cartNotifierProvider.future);
await container.read(cartNotifierProvider.notifier).addItem(_anItem);
final state = container.read(cartNotifierProvider);
expect(state, isA<AsyncData<CartState>>());
expect(state.requireValue.totalMinor, 1299);
});
}
ProviderContainer.test()(Riverpod 3.x)会自动释放;如果可用,这是首选形式。否则,使用 addTearDown(container.dispose)。
使用 fakeAsync 驱动计时器
test('debounced search fires once after the window', () {
fakeAsync((async) {
final calls = <String>[];
final debouncer = Debouncer(const Duration(milliseconds: 300));
debouncer.run(() => calls.add('a'));
async.elapse(const Duration(milliseconds: 299));
expect(calls, isEmpty);
async.elapse(const Duration(milliseconds: 1));
expect(calls, ['a']);
});
});
反模式
- 测试金字塔倒置——通过
pumpWidget驱动纯粹的f(input) -> output逻辑。速度较慢、更容易偶发失败,还会掩盖出错的层。 - 领域逻辑可访问
DateTime.now()/ 环境Random()。 这会让“相同输入 -> 相同输出”无法测试;应注入Clock和种子。 - 在纯 Dart 包中导入
flutter_test。 这会破坏结构边界,并拖慢最快的测试层级;该包不应声明 Flutter SDK 约束。 - 用模拟 DAO 代替数据库。 这无法验证 SQL、约束、索引或迁移。请使用
NativeDatabase.memory()。 - 对 double 使用
==,或只用一个碰巧符合预期的示例来验证普遍性结论,而不使用独立预言机进行模糊测试循环。 - 提供虚假契约的 fake(永远不会失败的依赖)——这样的测试反映的是理想路径桩件,而不是真实行为。Fake 必须能暴露失败。
- 对自定义
any()参数未调用registerFallbackValue——会导致编译器无法捕获的运行时抛错。 - 对无限期指示器调用
pumpAndSettle()——会在 10 分钟超时后挂起。 - 全局/目录级覆盖率百分比门槛——这会奖励徒有其表的测试,而且原始数字会虚高。为无法恢复的文件设定底线;其余部分公开报告。
- 对结构上无法测试的路径进行绿灯测试(真实音频、原生图块、设备差异)——这比承认测试缺口更糟,因为它会阻止任何人手动检查。
- 没有有意义
expect的测试、测试之间共享可变状态,或使用test('works')这类含糊名称。
完成定义
- 每项测试都放在能够验证它的最低成本层级;纯逻辑放在
package:test下,只有当 UI/插件接缝是测试重点时,才使用 Widget/集成测试。 - 领域逻辑读取
clock.now(),绝不使用DateTime.now();测试通过withClock固定时间。 - 每种转换都有往返测试和边界舍入黄金测试;每项普遍性声明都通过带种子的模糊测试/属性测试验证,并使用独立预言机,
在
reason:中回显输入。 - 数据层使用
NativeDatabase.memory()测试,而不是使用模拟的 DAO; 在 teardown 中调用db.close()。 - 自有协作者使用纯
implements假对象;mocktail仅用于外部依赖,并为每种自定义的any()类型调用registerFallbackValue。 - 通过
ProviderContainer覆盖项测试 Notifier,并在 teardown 中释放;测试状态时不泵入 Widget。 - 一个验收关卡应精确断言真实场景和守恒不变量;基础操作应带有运行时
assert熔断检查。 - 覆盖率下限设在无法恢复的 Bug 文件上;修复覆盖率虚高问题 (包含未测试文件);剔除生成文件。
- 不对无限动画调用
pumpAndSettle();使用fakeAsync驱动计时器。 - 测试套件应在时间预算内完成;所有结构上无法测试的路径都应在手动发布前检查清单中列出,而不是伪造通过状态。
相关技能
- 有关黄金测试流程、溢出/RTL 矩阵和诚实说明的无障碍限制,请参阅
widget-golden-and-a11y-testing。 - 有关此技能测试的
allocate()基础操作和Clock注入,请参阅value-objects-money-and-units。 - 有关内存测试所验证的 DAO/值对象接缝,请参阅
persistence-drift。 - 有关将正向迁移流程及其 from→to 路径纳入 100% 覆盖率下限,请参阅
run-migration。 - 有关穷尽式 switch 测试所断言的 sealed
Result/Failure,请参阅error-handling-typed-results。 - 有关静默审查清单要针对的 Future 丢弃漏洞,请参阅
async-safety。 - 有关假对象所替代的可注入端口,请参阅
service-boundary-and-native。 - 有关如何将这些测试流程和 grep 检查接入关卡,请参阅
ci-pipeline-and-gates。 - 有关在输出必须跨设备和版本复现时,如何通过冻结的指纹表固定带种子的生成器,请参阅
seeded-determinism-and-golden-vectors。
参考资料
- Flutter 测试概览 — https://docs.flutter.dev/testing/overview
- Riverpod 测试 — https://riverpod.dev/docs/essentials/testing
- Drift 测试(NativeDatabase.memory)— https://drift.simonbinder.eu/docs/testing/
package:clock— https://pub.dev/packages/clockpackage:fake_async— https://pub.dev/packages/fake_asyncpackage:mocktail— https://pub.dev/packages/mocktail