关于 dart-migrate-to-checks-package
面向 Dart 开发者的迁移指南,帮助将测试断言从 package:matcher 改为 package:checks。指南提供迁移流程、语法映射和常见陷阱,帮助保持断言行为并采用更强的类型检查与诊断。
主要功能
- 01规划完整或渐进式迁移
- 02将 matcher 断言映射为 checks 语法
- 03避免集合相等性迁移陷阱
- 04处理异步异常与可空值
- 05通过分析和测试验证更改
适用场景
- 01改造旧版 Dart 测试套件
- 02逐个文件迁移断言
- 03排查迁移后的编译或断言错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dart-lang/ skills --skill dart-migrate-to-checks-package安装后在 Claude Code 中输入/dart-migrate-to-checks-package确认已加载。
详细介绍
将 Dart 测试迁移到 Package Checks
当你需要将 Dart 测试套件从旧版 package:matcher(默认由 package:test/test.dart 导出)迁移到现代、类型安全且易读的 package:checks 断言库时,请使用此技能。
目录
- 何时使用此技能
- 如何使用此技能(工作流)
- 主要语法差异与注意事项
- [Matcher 到 Checks 的映射表](#matcher 到 checks 的映射表)
- [没有直接替代项的 Matcher](#没有直接替代项的 matcher)
- 发现策略
- 示例
何时使用此技能
- 当有人要求“将测试迁移到 checks”“使用 package:checks”或“现代化测试断言”时。
- 更新旧版测试套件时,如果需要静态类型安全、IDE 中更好的自动补全,以及更详尽的失败诊断。
如何使用此技能(工作流)
按照以下结构化工作流,安全、系统地迁移测试套件:
1. 设置依赖项
- 在
pubspec.yaml中将package:checks添加为dev_dependency:dart pub add dev:checks - 如果
package:matcher在dev_dependencies中被显式列出,请将其移除(它通常会作为package:test的传递依赖被引入,这没有问题)。
2. 确定目标文件并制定计划
- 使用发现策略中的 grep 模式,找出所有包含旧版
expect或expectLater调用的测试文件。 - 决定是完整迁移文件,还是分阶段迁移。
3. 迁移文件(分阶段或完整迁移)
对于任何目标测试文件:
- 更新导入:
- 将通用的
import 'package:test/test.dart';替换为:import 'package:test/scaffolding.dart'; import 'package:checks/checks.dart'; - 分阶段迁移:如果只想迁移文件中的部分测试用例,或想逐步迁移,请添加:
import 'package:test/expect.dart'; // 暂时允许使用旧版 expect()
- 将通用的
- 转换断言:按照主要语法差异与注意事项和[ Matcher 到 Checks 的映射表](#matcher 到 checks 的映射表),将旧版
expect和expectLater调用改写为check语法。 - 通过编译器验证:如果是完整迁移,请移除
import 'package:test/expect.dart';这一行。任何尚未迁移的expect调用都会立即以编译错误的形式显现,便于查找和修复。
4. 验证与反馈循环
- 静态分析:对目标包运行静态分析:
特别注意dart analyze.isA<Type>()上的泛型类型参数,并确保异步断言已正确等待(检查是否有unawaited_futures警告)。 - 运行测试:执行测试,以验证行为和断言运行时逻辑是否正确:
如果测试失败,请查看dart testpackage:checks极为详尽的失败输出,以判断测试是否确实失败,或断言转换是否有误。
主要语法差异与注意事项
重要
逐行转换有时会引入细微错误或造成误通过。请务必仔细检查以下主要差异:
1. 集合相等性陷阱(equals 与 deepEquals)
- 旧版匹配器:如果参数是集合(List、Map、Set),
expect(actual, expected)或expect(actual, equals(expected))会执行深度相等性检查。 - Package Checks:
.equals(expected)严格对应operator ==。由于 Dart 集合不会重写operator ==来逐元素比较,使用.equals检查集合时会检查对象标识,运行时几乎肯定会失败。 - 解决方法:必须将集合相等断言替换为
.deepEquals(expected)。// BEFORE (Matcher) expect(myList, [1, 2, 3]); // AFTER (Checks) check(myList).deepEquals([1, 2, 3]);
2. reason 参数现在是 because
- 旧版匹配器:说明作为尾部命名参数
reason传递给expect:expect(actual, expectation, reason: 'Explanation'); - Package Checks:说明作为命名参数
because传递给check函数,且位于实际 subject 之前:check(because: 'Explanation', actual).expectation();
3. 正则表达式匹配(matches 与 matchesPattern)
- 旧版匹配器:
matches(pattern)匹配器会自动将String参数转换为RegExp(例如,matches(r'\d')可以匹配'1')。 - Package Checks:
.matchesPattern(pattern)会将String参数视为字面字符串模式。 - 解决方法:若要使用正则表达式进行匹配,必须显式传入
RegExp对象:// BEFORE (Matcher) expect(someString, matches(r'\d+')); // AFTER (Checks) check(someString).matchesPattern(RegExp(r'\d+'));
4. 属性提取(TypeMatcher.having 与 .has)
- 旧版匹配器:链式字段/属性期望使用
TypeMatcher.having(feature, description, matcher):expect(actual, isA<Person>().having((p) => p.name, 'name', startsWith('A'))); - Package Checks:
.has(feature, description)扩展可用于所有Subject,参数少一个,并返回一个表示该属性的新Subject。可直接在其后链接期望:check(actual).isA<Person>().has((p) => p.name, 'name').startsWith('A');
5. 同步与异步 throws
- 旧版匹配器:在
package:matcher中,throwsA在通过expect或expectLater包装时,对同步闭包和异步 future 的行为类似。 - Package Checks:
.throws<E>()期望的行为及返回类型会因 subject 是同步还是异步而异:- 同步(
Subject<T Function()>):.throws<E>()会同步返回一个Subject<E>。它不接受回调参数! 直接在返回的Subject<E>上链接或级联期望:// YES(同步链式调用) check(() => triggerSyncError()).throws<ArgumentError>() ..has((e) => e.message, 'message').equals('invalid input'); // NO(向同步 throws 传递回调会导致编译错误!) check(() => triggerSync").throws<ArgumentError>((it) => ...); // ERROR! - 异步(
Subject<Future<T>>):.throws<E>()会返回Future<void>。由于无法直接在Future<void>上链接操作,因此必须提供检查回调:// YES(异步回调) await check(triggerAsyncError()).throws<ArgumentError>((it) => it ..has((e) => e.message, 'message').equals('invalid input')); - 关键陷阱:尝试在等待异步
.throws<E>()后直接链接期望(例如,await check(future).throws<E>().equals(...))会编译失败, 因为它返回Future<void>。
- 同步(
6. RegExp/Pattern 相等性
- 旧版匹配器:在
package:matcher中,expect(myPattern,equals(RegExp('Hello')))可以正常工作,因为匹配器的比较规则会处理 RegExp 实例。 - Package Checks:
.equals()使用严格的 Dart==相等性。由于不同的RegExp实例不满足==,使用.equals()会在运行时失败。 - 解决方法:结合级联操作使用
.isA<RegExp>()类型细化,显式断言RegExp对象的属性:check(myPattern).isA<RegExp>() ..has((r) => r.pattern, 'pattern').equals('Hello') ..has((r) => r.isMultiLine, 'isMultiLine').isTrue();
7. 严格的可空布尔值安全性(bool? 字段)
- 旧版匹配器:从静态类型来看,
isTrue和isFalse会在运行时执行宽松的动态检查,因此会静默接受可空布尔值(bool?)。 - Package Checks:
.isTrue()和.isFalse()严格定义在Subject<bool>(不可空)上,不适用于Subject<bool?>。 - 解决方法:对于声明为
bool?的字段,必须细化主题(例如,.isNotNull().isTrue()),或者直接使用通用且适用于所有类型的.equals(true)和.equals(false):// If options.flagOutdated is a bool? check(options.flagOutdated).equals(true); check(options.flagOutdated).equals(false);
8. Map 键包含检查(containsKey 与 contains)
- 旧版匹配器:在
package:matcher中,使用contains(key)断言Map包含特定键。 - Package Checks:在
Subject<Map>上调用.contains(...)未定义,会导致编译失败。 - 解决方法:改用 Map 专用的
.containsKey(key)匹配器:// BEFORE (Matcher) expect(myMap, contains('my_key')); // AFTER (Checks) check(myMap).containsKey('my_key');
9. 为扩展类型显式指定泛型参数
- 旧版匹配器:由于相等性检查较为宽松,
expect(extensionTypeConst, 3)可以编译。 - Package Checks:如果
QrEciValue是int的扩展类型表示(例如,extension type const QrEciValue(int value) implements int),在Subject<QrEciValue>上调用.equals(3)会失败,因为3(一个int)不能赋值给QrEciValue。使用as int进行转换会触发“Unnecessary cast”(不必要的类型转换)静态分析警告,因为从静态类型来看,QrEciValue已实现int。 - 解决方法:在
check函数上显式指定泛型参数,强制检查将其视为基本类型:// YES (Type-safe and warning-free) check<int>(QrEciValue.iso8859_1).equals(3);
10. 动态 Map / JSON 查找类型转换
- 旧版匹配器:宽松的动态类型允许将静态类型为
dynamic的嵌套 JSON 查找结果直接与列表或 Map 进行比较。 - Package Checks:严格的类型安全机制会拒绝在
.deepEquals(...)中将dynamic隐式赋值给Iterable<Object?>。 - 解决方法:将动态查找结果显式转换为
List或Map:// YES (Explicit cast to List) check(myIterable).deepEquals(json['data']['items'] as List);
匹配器到检查项映射表
使用此表快速参考可直接替换的匹配器:
| 旧版匹配器 | Package Checks 对应项 | 备注 |
|---|---|---|
expect(actual, expected) | check(actual).equals(expected) | 集合请使用 .deepEquals! |
expect(actual, equals(expected)) | check(actual).equals(expected) | 集合请使用 .deepEquals! |
isA<T>() | check(actual).isA<T>() | 支持直接链式调用 |
same(expected) | check(actual).identicalTo(expected) | 验证对象标识 |
anyElement(matcher) | check(iterable).any(conditionCallback) | 例如:check(list).any((e) => e.equals(1)) |
everyElement(matcher) | check(iterable).every(conditionCallback) | 例如:check(list).every((e) => e.isGreaterThan(0)) |
hasLength(expected) | check(actual).length.equals(expected) | 适用于 String、Map、Iterable 等 |
isNot(matcher) | check(actual).not(conditionCallback) | 例如:check(val).not((it) => it.equals(5)) |
contains(element) | check(actual).contains(element) | 适用于 String、Iterable(Map 请使用 containsKey!) |
contains(key)(用于 Map) | check(map).containsKey(key) | 检查 Map 是否包含键 |
startsWith(prefix) | check(string).startsWith(prefix) | 仅适用于 String |
endsWith(suffix) | check(string).endsWith(suffix) | 仅适用于 String |
isEmpty | check(actual).isEmpty() | 适用于 String、Map、Iterable |
isNotEmpty | check(actual).isNotEmpty() | 适用于 String、Map、Iterable |
isNull | check(actual).isNull() | |
isNotNull | check(actual).isNotNull() | |
isTrue / true | check(actual).isTrue() | 仅适用于不可为 null 的 bool |
isFalse / false | check(actual).isFalse() | 仅适用于不可为 null 的 bool |
completion(matcher) | await check(future).completes(conditionCallback) | 必须使用 await! |
throwsA(matcher) | await check(future).throws<Type>() | 必须使用 await! |
emits(value) | await check(streamQueue).emits(conditionCallback) | 必须使用 await! |
emitsThrough(value) | await check(streamQueue).emitsThrough(conditionCallback) | 必须使用 await! |
stringContainsInOrder(list) | check(string).containsInOrder(list) | 仅适用于 String |
pairwiseCompare(...) | check(actual).pairwiseMatches(...) |
没有直接替换项的匹配器
由于 API 清理,某些旧版匹配器在 package:checks 中没有一对一的对应项。请使用以下标准替代方法:
1. 特定错误匹配器
- 旧版:
throwsArgumentError、throwsStateError、throwsUnsupportedError等。 - Checks:使用
.throws<T>()并指定错误类型:await check(triggerError()).throws<ArgumentError>();
2. anything 匹配器
- 旧版:
expect(actual, anything) - Checks:当语法要求条件回调时,传入空条件回调
(_) {}:await check(someFuture).completes((_) {});
3. 特定数值条件
- 旧版:
isPositive、isNegative、isZero、isNonPositive、isNonNegative、isNonZero - Checks:使用明确的比较断言:
isPositive$\rightarrow$isGreaterThan(0)isNegative$\rightarrow$isLessThan(0)isZero$\rightarrow$equals(0)isNonNegative$\rightarrow$isGreaterOrEqual(0)
4. 数值范围
- 旧版:
inClosedOpenRange(min, max)、inInclusiveRange(min, max)、 等。 - Checks:使用级联运算符(
..)链接边界:check(actualValue) ..isGreaterOrEqual(min) ..isLessThan(max);
编写自定义断言(替换自定义匹配器)
从旧版代码库迁移时,你可能会遇到自定义 Matcher 子类。在 package:checks 中,自定义断言通过 Subject<T> 上的 extension 方法实现。
要编写自定义断言,必须导入 checks 上下文 API:
import 'package:checks/context.dart';
1. 简单的自定义断言(使用 expect)
使用 context.expect 检查属性,并在失败时返回 Rejection:
extension CustomPersonChecks on Subject<Person> {
void isAdult() {
context.expect(
() => ['is an adult (age >= 18)'],
(actual) {
if (actual.age >= 18) return null; // Pass
return Rejection(
which: ['is only ${actual.age} years old'],
);
},
);
}
}
2. 嵌套属性提取(使用 nest 或 has)
要提取属性并允许继续进行链式检查,请使用 nest 或更简单的
has 辅助函数:
- 使用
has(推荐用于简单且不会失败的字段访问):extension CustomPersonChecks on Subject<Person> { Subject<Address> get address => has((p) => p.address, 'address'); } - 使用
nest(用于可能失败或拒绝的属性提取):extension CustomPersonChecks on Subject<Person> { Subject<String> get ssn => context.nest( 'has a valid SSN', (actual) { final ssnValue = actual.ssn; if (ssnValue == null) { return Extracted.rejection(which: ['has no SSN']); } return Extracted.value(ssnValue); }, ); }
3. 异步自定义期望
如果期望是异步的(例如,检查 Future 或 Stream),请使用
context.expectAsync 或 context.nestAsync,并返回生成的 Future:
extension CustomFutureChecks<T> on Subject<Future<T>> {
Future<void> completesNormally() {
return context.expectAsync(
() => ['completes without throwing'],
(actual) async {
try {
await actual;
return null; // Pass
} catch (e) {
return Rejection(which: ['threw $e']);
}
},
);
}
}
发现策略
在终端中执行以下命令,以识别旧版匹配器和需要迁移的文件:
# 1. Find all test files containing legacy expect() or expectLater()
grep -rn "expect(" test/
grep -rn "expectLater(" test/
# 2. Find potential collection equality pitfalls (literal lists or maps)
grep -rn "expect(.*, \[" test/
grep -rn "expect(.*, {" test/
# 3. Find matches() calls (need conversion to RegExp + matchesPattern)
grep -rn "matches(" test/
# 4. Find legacy TypeMatcher.having() calls (which need conversion to .has())
grep -rn "having(" test/
示例
基本断言
之前(Matcher):
expect(someValue, isNotNull);
expect(result, isTrue, reason: 'should be successful');
expect(myString, startsWith('hello'));
之后(Checks):
check(someValue).isNotNull();
check(because: 'should be successful', result).isTrue();
check(myString).startsWith('hello');
集合和深度相等性
之前(Matcher):
expect(items, [1, 2, 3]);
expect(configMap, equals({'port': 8080}));
之后(Checks):
check(items).deepEquals([1, 2, 3]);
check(configMap).deepEquals({'port': 8080});
链式调用和级联
之前(Matcher):
expect(someString, allOf([
startsWith('a'),
contains('b'),
endsWith('c'),
]));
之后(Checks):
check(someString)
..startsWith('a')
..contains('b')
..endsWith('c');
复杂属性匹配(has)
之前(Matcher):
expect(response, isA<Response>()
.having((r) => r.statusCode, 'statusCode', 200)
.having((r) => r.body, 'body', contains('success')));
之后(Checks):
check(response).isA<Response>()
..has((r) => r.statusCode, 'statusCode').equals(200)
..has((r) => r.body, 'body').contains('success');
异步 Future
之前(Matcher):
expect(fetchData(), completes);
expect(fetchData(), completion(equals('data')));
expect(failingCall(), throwsA(isA<StateError>()));
之后(Checks):
await check(fetchData()).completes();
await check(fetchData()).completes((it) => it.equals('data'));
await check(failingCall()).throws<StateError>();
异步 Stream
之前(Matcher):
var queue = StreamQueue(Stream.fromIterable([1, 2, 3]));
await expectLater(queue, emitsInOrder([1, 2, 3]));
之后(Checks):
var queue = StreamQueue(Stream.fromIterable([1, 2, 3]));
await check(queue).inOrder([
(s) => s.emits((e) => e.equals(1)),
(s) => s.emits((e) => e.equals(2)),
(s) => s.emits((e) => e.equals(3)),
]);