mcpField
D
dart-migrate-to-checks-package

dart-migrate-to-checks-package

github.com/dart-langテスト・品質53218.3k 回インストール2 週間前に更新
リポジトリ

dart-migrate-to-checks-package について

Dart のテストアサーションを package:matcher から package:checks に移行するためのガイドです。手順や構文対応、注意点を示し、型安全性と診断を活用しながらアサーションの動作を保つ方法を扱います。

主な機能

  1. 01全面移行または段階的移行の計画
  2. 02matcher アサーションを checks 構文に変換
  3. 03コレクション比較の移行時の注意点
  4. 04非同期例外と nullable 値への対応
  5. 05解析とテストによる変更の検証

活用シーン

  1. 01既存の Dart テストスイートを更新
  2. 02ファイル単位でアサーションを移行
  3. 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 に移行する

従来の package:matcher(package:test/test.dart からデフォルトでエクスポートされています)から、最新の型安全で読みやすいアサーションライブラリ package:checks へ Dart テストスイートを移行する必要がある場合に、このスキルを使用してください。

目次


このスキルを使用する場面

  • 「テストを checks に移行する」「package:checks を使う」、または 「テストのアサーションを最新化する」よう依頼された場合。
  • 静的な型安全性、IDE でのより優れた自動補完、詳細な失敗診断が求められる、 従来のテストスイートを更新する場合。

このスキルの使い方(ワークフロー)

次の体系的なワークフローに従って、テストスイートを安全かつ段階的に移行してください。

1. 依存関係の設定
  • pubspec.yaml の dev_dependency に package:checks を追加します。
    dart pub add dev:checks
    
  • dev_dependencies に明示的に記載されている場合は、package:matcher を削除します (通常は 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 の対応表に従って、従来の expect と expectLater の呼び出しを check 構文に書き換えます。
  3. コンパイラーでの検証:完全に移行する場合は、import 'package:test/expect.dart'; の行を削除します。移行されていない expect 呼び出しが残っていると、コンパイルエラーとしてすぐに表面化するため、簡単に見つけて修正できます。
4. 検証とフィードバックループ
  • 静的解析:対象パッケージで静的解析を実行します。
    dart analyze
    
    .isA<Type>() のジェネリック型パラメーターに注意し、非同期の期待値が適切に await されていることを確認します(unawaited_futures 警告を確認してください)。
  • テストの実行:テストを実行して、動作とアサーションの実行時ロジックが正しいことを検証します。
    dart test
    
    テストが失敗した場合は、package: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 を直接置き換える際のクイックリファレンスとして、この表を使用してください。

従来の MatcherPackage 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(...)

直接置き換えられない 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)),
]);

コメント