mcpField
D
dart-migrate-to-checks-package

dart-migrate-to-checks-package

github.com/flutter测试与质量3.1k14.4k 次安装2 天前更新
代码仓库

关于 dart-migrate-to-checks-package

面向 Dart 开发者的测试迁移指南,帮助将 `package:matcher` 断言改写为 `package:checks`。它列出语法对应关系和容易引入错误的差异,并建议通过静态分析与测试验证迁移。

主要功能

  1. 01逐步迁移测试断言
  2. 02对照常见 Matcher 写法
  3. 03提示集合与正则匹配差异
  4. 04处理同步和异步异常断言
  5. 05通过分析和测试验证结果

适用场景

  1. 01将旧测试改用 `package:checks`
  2. 02分批迁移大型测试套件
  3. 03排查迁移后的编译错误
  4. 04避免集合断言的错误转换

安装

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

$npx skills add https://github.com/flutter/agent-plugins --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 断言库时,请使用此技能。

目录


何时使用此技能

  • 当有人要求“将测试迁移到 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. 迁移文件(分阶段或完整迁移)

对于任何目标测试文件:

  1. 更新导入:
    • 将通用的 import 'package:test/test.dart'; 替换为:
      import 'package:test/scaffolding.dart';
      import 'package:checks/checks.dart';
      
    • 分阶段迁移:如果只想迁移文件中的部分测试用例,或想逐步迁移,请添加:
      import 'package:test/expect.dart'; // 暂时允许使用旧版 expect()
      
  2. 转换断言:按照主要语法差异与注意事项和[ Matcher 到 Checks 的映射表](#matcher 到 checks 的映射表),将旧版 expect 和 expectLater 调用改写为 check 语法。
  3. 通过编译器验证:如果是完整迁移,请移除 import 'package:test/expect.dart'; 这一行。任何尚未迁移的 expect 调用都会立即以编译错误的形式显现,便于查找和修复。
4. 验证与反馈循环
  • 静态分析:对目标包运行静态分析:
    dart analyze
    
    特别注意 .isA<Type>() 上的泛型类型参数,并确保异步断言已正确等待(检查是否有 unawaited_futures 警告)。
  • 运行测试:执行测试,以验证行为和断言运行时逻辑是否正确:
    dart test
    
    如果测试失败,请查看 package: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
isEmptycheck(actual).isEmpty()适用于 String、Map、Iterable
isNotEmptycheck(actual).isNotEmpty()适用于 String、Map、Iterable
isNullcheck(actual).isNull()
isNotNullcheck(actual).isNotNull()
isTrue / truecheck(actual).isTrue()仅适用于不可为 null 的 bool
isFalse / falsecheck(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)),
]);

评论

支持 Markdown