dart-migrate-to-checks-package について
Dart のテストを `package:matcher` から `package:checks` に移行するためのガイドです。構文の対応関係や誤変換につながる違いを示し、静的解析とテストで変更を確認します。
主な機能
- 01テストアサーションの段階的な移行
- 02Matcher の一般的な構文との対応
- 03コレクションと正規表現の差異の確認
- 04同期・非同期の例外アサーションへの対応
- 05解析とテストによる変更確認
活用シーン
- 01既存テストを `package:checks` に移行
- 02大規模なテスト群を段階的に更新
- 03移行後のコンパイルエラーの特定
- 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 に移行する
従来の package:matcher(package:test/test.dart からデフォルトでエクスポートされています)から、最新の型安全で読みやすいアサーションライブラリ package:checks へ Dart テストスイートを移行する必要がある場合に、このスキルを使用してください。
目次
このスキルを使用する場面
- 「テストを checks に移行する」「package:checks を使う」、または 「テストのアサーションを最新化する」よう依頼された場合。
- 静的な型安全性、IDE でのより優れた自動補完、詳細な失敗診断が求められる、 従来のテストスイートを更新する場合。
このスキルの使い方(ワークフロー)
次の体系的なワークフローに従って、テストスイートを安全かつ段階的に移行してください。
1. 依存関係の設定
pubspec.yamlのdev_dependencyにpackage:checksを追加します。dart pub add dev:checksdev_dependenciesに明示的に記載されている場合は、package:matcherを削除します (通常は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 の対応表に従って、従来の
expectとexpectLaterの呼び出しをcheck構文に書き換えます。 - コンパイラーでの検証:完全に移行する場合は、
import 'package:test/expect.dart';の行を削除します。移行されていないexpect呼び出しが残っていると、コンパイルエラーとしてすぐに表面化するため、簡単に見つけて修正できます。
4. 検証とフィードバックループ
- 静的解析:対象パッケージで静的解析を実行します。
dart analyze.isA<Type>()のジェネリック型パラメーターに注意し、非同期の期待値が適切にawaitされていることを確認します(unawaited_futures警告を確認してください)。 - テストの実行:テストを実行して、動作とアサーションの実行時ロジックが正しいことを検証します。
テストが失敗した場合は、dart testpackage:checksの非常に詳細な失敗出力を確認し、 テストが実際に失敗しているのか、期待値の変換が誤っていたのかを診断します。
主な構文の違いと注意点
重要
行ごとにそのまま変換すると、微妙なバグや誤った成功判定が発生する場合があります。 次の主な違いを必ず注意深く確認してください。
1. コレクションの等価性に関する注意点(equals と deepEquals)
- 旧 Matcher:
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 に変更されました
- 旧 Matcher:説明は
expectの末尾の名前付き引数reasonとして渡していました。expect(actual, expectation, reason: 'Explanation'); - Package Checks:説明は、実際の対象の前に
check関数へ名前付き引数becauseとして渡します。check(because: 'Explanation', actual).expectation();
3. 正規表現によるマッチング(matches と matchesPattern)
- 旧 Matcher:
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)
- 旧 Matcher:フィールドやプロパティを連鎖して検証するには、
TypeMatcher.having(feature, description, matcher)を使用していました。expect(actual, isA<Person>().having((p) => p.name, 'name', startsWith('A'))); - Package Checks:
.has(feature, description)拡張はすべてのSubjectで使用でき、引数が 1 つ少なく、そのプロパティを表す新しいSubjectを返します。そこから直接検証を連鎖できます。check(actual).isA<Person>().has((p) => p.name, 'name').startsWith('A');
5. 同期と非同期の throws
- 旧 Matcher:
package:matcherのthrowsAは、expectまたはexpectLaterでラップした場合、同期クロージャーと非同期の Future の どちらに対しても同様に動作していました。 - Package Checks:
.throws<E>()の検証は、対象が同期か非同期かによって 動作と戻り値の型が異なります。- 同期(
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 の等価性
- 旧 Matcher:
package:matcherでは、マッチャーの比較ルールが RegExp インスタンスを処理するため、expect(myPattern,equals(RegExp('Hello')))は動作していました。 - 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. Nullable Boolean フィールドの厳密な安全性(bool? フィールド)
- レガシーマッチャー:静的には、
isTrueとisFalseは実行時に緩い 動的チェックを行っていたため、nullable なブール値(bool?)も暗黙的に受け入れていました。 - パッケージのチェック:
.isTrue()と.isFalse()は厳密にSubject<bool>(non-nullable)に対して定義されています。Subject<bool?>では 使用できません。 - 修正方法:
bool?として宣言されたフィールドでは、Subject を絞り込む(例:.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では、Mapに特定のキーが含まれていることを 検証するためにcontains(key)を使用していました。 - パッケージのチェック:
Subject<Map>に対して.contains(...)を呼び出すことは 定義されていないため、コンパイルに失敗します。 - 修正方法:代わりに、Map 固有の
.containsKey(key)マッチャーを使用してください:// BEFORE (Matcher) expect(myMap, contains('my_key')); // AFTER (Checks) check(myMap).containsKey('my_key');
9. 拡張型の明示的なジェネリック型パラメーター
- レガシーマッチャー:動的な等価性判定が緩いため、
expect(extensionTypeConst, 3)は コンパイルできていました。 - パッケージのチェック:
QrEciValueがintの拡張型表現である場合 (例:extension type const QrEciValue(int value) implements int)、Subject<QrEciValue>に対して.equals(3)を呼び出すと、3(int)をQrEciValueに 代入できないため失敗します。as intでキャストすると、QrEciValueは静的にintを実装しているため、「不要なキャスト」という静的解析の警告が発生します。 - 修正方法:チェックでプリミティブ型として扱うように、
check関数にジェネリック型パラメーターを明示的に指定してください:// YES (Type-safe and warning-free) check<int>(QrEciValue.iso8859_1).equals(3);
10. Dynamic Map/JSON ルックアップのキャスト
- レガシーマッチャー:動的型付けが緩いため、静的に
dynamicと型付けされたネストされた json のルックアップ結果を、リストや Map と直接比較できていました。 - パッケージのチェック:厳密な型安全性により、
.deepEquals(...)でdynamicをIterable<Object?>に暗黙的に代入することはできません。 - 修正方法:dynamic のルックアップ結果を
ListまたはMapに静的にキャストしてください:// YES (Explicit cast to List) check(myIterable).deepEquals(json['data']['items'] as List);
Matcher と Checks の対応表
Matcher を直接置き換える際のクイックリファレンスとして、この表を使用してください。
| 従来の Matcher | 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(...) |
直接置き換えられない Matcher
API の整理により、従来の Matcher の中には package:checks に一対一で対応するものがないものがあります。次の標準的な回避策を使用してください。
1. 特定のエラー Matcher
- 従来のもの:
throwsArgumentError、throwsStateError、throwsUnsupportedErrorなど - Checks:特定のエラー型を指定して
.throws<T>()を使用します。await check(triggerError()).throws<ArgumentError>();
2. anything Matcher
- 従来のもの:
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 の置き換え)
従来のコードベースから移行する際、カスタム Matcher サブクラスに遭遇することがあります。package:checks では、カスタムアサーションは Subject<T> の extension メソッドとして実装します。
カスタム期待値を記述するには、checks context 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/
例
基本的なアサーション
変更前(マッチャー):
expect(someValue, isNotNull);
expect(result, isTrue, reason: 'should be successful');
expect(myString, startsWith('hello'));
変更後(チェック):
check(someValue).isNotNull();
check(because: 'should be successful', result).isTrue();
check(myString).startsWith('hello');
コレクションと深い等価性
変更前(マッチャー):
expect(items, [1, 2, 3]);
expect(configMap, equals({'port': 8080}));
変更後(チェック):
check(items).deepEquals([1, 2, 3]);
check(configMap).deepEquals({'port': 8080});
チェーンとカスケード
変更前(マッチャー):
expect(someString, allOf([
startsWith('a'),
contains('b'),
endsWith('c'),
]));
変更後(チェック):
check(someString)
..startsWith('a')
..contains('b')
..endsWith('c');
複雑なプロパティマッチング(has)
変更前(マッチャー):
expect(response, isA<Response>()
.having((r) => r.statusCode, 'statusCode', 200)
.having((r) => r.body, 'body', contains('success')));
変更後(チェック):
check(response).isA<Response>()
..has((r) => r.statusCode, 'statusCode').equals(200)
..has((r) => r.body, 'body').contains('success');
非同期の Future
変更前(マッチャー):
expect(fetchData(), completes);
expect(fetchData(), completion(equals('data')));
expect(failingCall(), throwsA(isA<StateError>()));
変更後(チェック):
await check(fetchData()).completes();
await check(fetchData()).completes((it) => it.equals('data'));
await check(failingCall()).throws<StateError>();
非同期の Stream
変更前(マッチャー):
var queue = StreamQueue(Stream.fromIterable([1, 2, 3]));
await expectLater(queue, emitsInOrder([1, 2, 3]));
変更後(チェック):
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)),
]);