Migrating Dart Tests to Package Checks
Use this skill when you need to migrate a Dart test suite from the legacy package:matcher (which is exported by default from package:test/test.dart) to the modern, type-safe, and literate package:checks assertion library.
Contents
- When to Use This Skill
- How to Use This Skill (The Workflow)
- Key Syntax Differences and Pitfalls
- Matcher-to-Checks Mapping Table
- Matchers with No Direct Replacements
- Strategies for Discovery
- Examples
---
When to Use This Skill
- When asked to "migrate tests to checks", "use package:checks", or
"modernize test assertions".
- When updating legacy test suites where static type safety, better
autocomplete in IDEs, and highly detailed failure diagnostics are desired.
---
How to Use This Skill (The Workflow)
Follow this structured workflow to safely and systematically migrate a test suite:
1. Dependency Setup
- Add
package:checksas adev_dependencyinpubspec.yaml:
dart pub add dev:checks
- Remove
package:matcherif it is explicitly listed underdev_dependencies
(it is typically transitively included by package:test, which is fine).
2. Identify and Plan Target Files
- Use the grep patterns in Strategies for Discovery
to locate all test files containing legacy expect or expectLater calls.
- Decide whether to migrate files fully or incrementally.
3. Migrating a File (Incremental or Full)
For any target test file:
- Update Imports:
- Replace the generic
import 'package:test/test.dart';with:
import 'package:test/scaffolding.dart';
import 'package:checks/checks.dart';
- For Incremental Migration: If you only want to migrate some test cases
in the file, or want to migrate one step at a time, add:
import 'package:test/expect.dart'; // Temporarily allows legacy expect()
- Translate Assertions: Rewrite legacy
expectandexpectLatercalls
to check syntax following the Key Syntax Differences and Pitfalls and the Matcher-to-Checks Mapping Table.
- Verify via Compiler: If migrating fully, remove the `import
'package:test/expect.dart'; line. Any remaining un-migrated expect` calls will immediately surface as compiler errors, making them easy to find and fix.
4. Verification and Feedback Loops
- Static Analysis: Run static analysis on the target package:
dart analyze
Pay close attention to generic type parameters on .isA<Type>() and ensure asynchronous expectations are properly awaited (check for unawaited_futures warnings).
- Run Tests: Execute the tests to verify both behavior and correct
assertion runtime logic:
dart test
If a test fails, review the extremely detailed failure output of package:checks to diagnose if the test is genuinely failing or if the expectation was translated incorrectly.
---
Key Syntax Differences and Pitfalls
[!IMPORTANT] A line-for-line translation can sometimes introduce subtle bugs or false passes. Always review these key differences carefully:
1. Collection Equality Pitfall (equals vs deepEquals)
- Legacy Matcher:
expect(actual, expected)or `expect(actual,
equals(expected))` performed a deep equality check if the arguments were collections (Lists, Maps, Sets).
- Package Checks:
.equals(expected)corresponds strictly to
operator ==. Since Dart collections do not override operator == for element-wise comparison, using .equals on a collection will check for identity and almost certainly fail at runtime.
- Remediation: You must replace collection equality assertions with
.deepEquals(expected).
// BEFORE (Matcher)
expect(myList, [1, 2, 3]);
// AFTER (Checks)
check(myList).deepEquals([1, 2, 3]);
2. The reason Parameter is now because
- Legacy Matcher: The explanation was passed as a trailing named
argument reason to expect:
expect(actual, expectation, reason: 'Explanation');
- Package Checks: The explanation is passed as the named argument
because to the check function before the actual subject:
check(because: 'Explanation', actual).expectation();
3. Regular Expression Matching (matches vs matchesPattern)
- Legacy Matcher: The
matches(pattern)matcher automatically converted
a String argument into a RegExp (e.g., matches(r'\d') matched '1').
- Package Checks:
.matchesPattern(pattern)treats aStringargument
as a literal string pattern.
- Remediation: To match using a regular expression, you must explicitly
pass a RegExp object:
// BEFORE (Matcher)
expect(someString, matches(r'\d+'));
// AFTER (Checks)
check(someString).matchesPattern(RegExp(r'\d+'));
4. Property Extraction (TypeMatcher.having vs .has)
- Legacy Matcher: Chained field/property expectations used
TypeMatcher.having(feature, description, matcher):
expect(actual, isA<Person>().having((p) => p.name, 'name', startsWith('A')));
- Package Checks: The
.has(feature, description)extension is
available on all Subjects, takes one fewer argument, and returns a new Subject representing that property. You chain expectations directly off it:
check(actual).isA<Person>().has((p) => p.name, 'name').startsWith('A');
5. Synchronous vs. Asynchronous throws
- Legacy Matcher: In
package:matcher,throwsAbehaved similarly for both
synchronous closures and asynchronous futures when wrapped in expect or expectLater.
- Package Checks: The
.throws<E>()expectation behaves differently and
has different return types depending on whether the subject is synchronous or asynchronous:
- Synchronous (
Subject<T Function()>):.throws<E>()returns a
Subject<E> synchronously. This does not accept a callback argument! You chain or cascade expectations directly off the returned Subject<E>:
// YES (Synchronous chaining)
check(() => triggerSyncError()).throws<ArgumentError>()
..has((e) => e.message, 'message').equals('invalid input');
// NO (Passing a callback to sync throws will cause a compiler error!)
check(() => triggerSync").throws<ArgumentError>((it) => ...); // ERROR!
- Asynchronous (
Subject<Future<T>>):.throws<E>()returns
Future<void>. Because you cannot chain directly off a Future<void>, this requires an inspection callback:
// YES (Asynchronous callback)
await check(triggerAsyncError()).throws<ArgumentError>((it) => it
..has((e) => e.message, 'message').equals('invalid input'));
- Crucial Pitfall: Trying to chain expectations directly after an awaited
asynchronous .throws<E>() (e.g., await check(future).throws<E>().equals(...)) will fail to compile because it returns Future<void>.
6. RegExp / Pattern Equality
- Legacy Matcher: In
package:matcher,expect(myPattern,
equals(RegExp('Hello'))) worked because the matcher comparison rules handled RegExp instances.
- Package Checks:
.equals()uses strict Dart==equality. Since separate
RegExp instances do not satisfy ==, using .equals() will fail at runtime.
- Remediation: Use
.isA<RegExp>()type refinement along with cascades to
assert on the properties of the RegExp object explicitly:
check(myPattern).isA<RegExp>()
..has((r) => r.pattern, 'pattern').equals('Hello')
..has((r) => r.isMultiLine, 'isMultiLine').isTrue();
7. Strict Nullable Boolean Safety (bool? fields)
- Legacy Matcher: Statically,
isTrueandisFalseperformed loose
dynamic checks at runtime, which silently accepted nullable booleans (bool?).
- Package Checks:
.isTrue()and.isFalse()are defined strictly on
Subject<bool> (non-nullable). They are not available on Subject<bool?>.
- Remediation: For fields declared as
bool?, you must either refine the
subject (e.g., .isNotNull().isTrue()) or simply use .equals(true) and .equals(false) which are generic and work on all types:
// If options.flagOutdated is a bool?
check(options.flagOutdated).equals(true);
check(options.flagOutdated).equals(false);
8. Map Key Containment (containsKey vs contains)
- Legacy Matcher: In
package:matcher,contains(key)was used to assert
that a Map contained a specific key.
- Package Checks: Calling
.contains(...)on aSubject<Map>is not
defined and will fail compilation.
- Remediation: Use the map-specific
.containsKey(key)matcher instead:
// BEFORE (Matcher)
expect(myMap, contains('my_key'));
// AFTER (Checks)
check(myMap).containsKey('my_key');
9. Explicit Generic Parameters for Extension Types
- Legacy Matcher:
expect(extensionTypeConst, 3)compiled because of loose
dynamic equality.
- Package Checks: If
QrEciValueis an extension type representation ofint
(e.g., extension type const QrEciValue(int value) implements int), calling .equals(3) on a Subject<QrEciValue> fails because 3 (an int) is not assignable to QrEciValue. Casting with as int will trigger an "Unnecessary cast" static analysis warning because QrEciValue statically implements int.
- Remediation: Explicitly specify the generic type parameter on the
check
function to force checks to treat it as the primitive type:
// YES (Type-safe and warning-free)
check<int>(QrEciValue.iso8859_1).equals(3);
10. Dynamic Map / JSON Lookup Casting
- Legacy Matcher: Loose dynamic typing allowed comparing nested json lookups
statically typed as dynamic directly against lists or maps.
- Package Checks: Strict type safety rejects the implicit assignment of
dynamic to Iterable<Object?> in .deepEquals(...).
- Remediation: Statically cast the dynamic lookup result to a
ListorMap:
// YES (Explicit cast to List)
check(myIterable).deepEquals(json['data']['items'] as List);
---
Matcher-to-Checks Mapping Table
Use this table as a quick reference for direct matcher replacements:
| Legacy Matcher | Package Checks Equivalent | Notes |
|---|---|---|
expect(actual, expected) | check(actual).equals(expected) | Use .deepEquals for collections! |
expect(actual, equals(expected)) | check(actual).equals(expected) | Use .deepEquals for collections! |
isA<T>() | check(actual).isA<T>() | Chaining is supported directly |
same(expected) | check(actual).identicalTo(expected) | Verifies identity |
anyElement(matcher) | check(iterable).any(conditionCallback) | E.g. check(list).any((e) => e.equals(1)) |
everyElement(matcher) | check(iterable).every(conditionCallback) | E.g. check(list).every((e) => e.isGreaterThan(0)) |
hasLength(expected) | check(actual).length.equals(expected) | Works on String, Map, Iterable, etc. |
isNot(matcher) | check(actual).not(conditionCallback) | E.g. check(val).not((it) => it.equals(5)) |
contains(element) | check(actual).contains(element) | Works on String, Iterable (use containsKey for Map!) |
contains(key) (on a Map) | check(map).containsKey(key) | Map key containment |
startsWith(prefix) | check(string).startsWith(prefix) | String only |
endsWith(suffix) | check(string).endsWith(suffix) | String only |
isEmpty | check(actual).isEmpty() | Works on String, Map, Iterable |
isNotEmpty | check(actual).isNotEmpty() | Works on String, Map, Iterable |
isNull | check(actual).isNull() | |
isNotNull | check(actual).isNotNull() | |
isTrue / true | check(actual).isTrue() | Works on non-nullable bool only |
isFalse / false | check(actual).isFalse() | Works on non-nullable bool only |
completion(matcher) | await check(future).completes(conditionCallback) | Must be awaited! |
throwsA(matcher) | await check(future).throws<Type>() | Must be awaited! |
emits(value) | await check(streamQueue).emits(conditionCallback) | Must be awaited! |
emitsThrough(value) | await check(streamQueue).emitsThrough(conditionCallback) | Must be awaited! |
stringContainsInOrder(list) | check(string).containsInOrder(list) | String only |
pairwiseCompare(...) | check(actual).pairwiseMatches(...) |
---
Matchers with No Direct Replacements
Some legacy matchers do not have a one-to-one equivalent in package:checks due to API cleanup. Use these standard workarounds:
1. Specific Error Matchers
- Legacy:
throwsArgumentError,throwsStateError,
throwsUnsupportedError, etc.
- Checks: Use
.throws<T>()with the specific error type:
await check(triggerError()).throws<ArgumentError>();
2. The anything Matcher
- Legacy:
expect(actual, anything) - Checks: Pass an empty condition callback
(_) {}when a condition is
syntactically required:
await check(someFuture).completes((_) {});
3. Specific Numeric Toggles
- Legacy:
isPositive,isNegative,isZero,isNonPositive,
isNonNegative, isNonZero
- Checks: Use explicit comparative expectations:
isPositive$\rightarrow$isGreaterThan(0)isNegative$\rightarrow$isLessThan(0)isZero$\rightarrow$equals(0)isNonNegative$\rightarrow$isGreaterOrEqual(0)
4. Numeric Ranges
- Legacy:
inClosedOpenRange(min, max),inInclusiveRange(min, max),
etc.
- Checks: Chain the boundaries using the cascade operator (
..):
check(actualValue)
..isGreaterOrEqual(min)
..isLessThan(max);
---
Writing Custom Expectations (Replacing Custom Matchers)
When migrating from a legacy codebase, you may encounter custom Matcher subclasses. In package:checks, custom assertions are implemented as extension methods on Subject<T>.
To write custom expectations, you must import the checks context API:
import 'package:checks/context.dart';
1. Simple Custom Expectations (using expect)
Use context.expect to check a property and return a Rejection on failure:
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. Nested Property Extraction (using nest or has)
To extract a property and allow further chained checks, use nest or the simpler has helper:
- Using
has(Recommended for simple, non-failing field access):
extension CustomPersonChecks on Subject<Person> {
Subject<Address> get address => has((p) => p.address, 'address');
}
- Using
nest(For property extraction that can fail or reject):
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. Asynchronous Custom Expectations
If the expectation is asynchronous (e.g. checking a Future or Stream), use context.expectAsync or context.nestAsync and return the resulting 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']);
}
},
);
}
}
---
Strategies for Discovery
Execute these commands in the terminal to identify legacy matchers and files requiring migration:
# 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/
---
Examples
Basic Assertions
Before (Matcher):
expect(someValue, isNotNull);
expect(result, isTrue, reason: 'should be successful');
expect(myString, startsWith('hello'));
After (Checks):
check(someValue).isNotNull();
check(because: 'should be successful', result).isTrue();
check(myString).startsWith('hello');
Collection and Deep Equality
Before (Matcher):
expect(items, [1, 2, 3]);
expect(configMap, equals({'port': 8080}));
After (Checks):
check(items).deepEquals([1, 2, 3]);
check(configMap).deepEquals({'port': 8080});
Chaining and Cascades
Before (Matcher):
expect(someString, allOf([
startsWith('a'),
contains('b'),
endsWith('c'),
]));
After (Checks):
check(someString)
..startsWith('a')
..contains('b')
..endsWith('c');
Complex Property Matching (has)
Before (Matcher):
expect(response, isA<Response>()
.having((r) => r.statusCode, 'statusCode', 200)
.having((r) => r.body, 'body', contains('success')));
After (Checks):
check(response).isA<Response>()
..has((r) => r.statusCode, 'statusCode').equals(200)
..has((r) => r.body, 'body').contains('success');
Asynchronous Futures
Before (Matcher):
expect(fetchData(), completes);
expect(fetchData(), completion(equals('data')));
expect(failingCall(), throwsA(isA<StateError>()));
After (Checks):
await check(fetchData()).completes();
await check(fetchData()).completes((it) => it.equals('data'));
await check(failingCall()).throws<StateError>();
Asynchronous Streams
Before (Matcher):
var queue = StreamQueue(Stream.fromIterable([1, 2, 3]));
await expectLater(queue, emitsInOrder([1, 2, 3]));
After (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)),
]);







