From 7c077d321cbe1a011bea1d9a3ec38cfa8a5ae59b Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Mon, 22 Jun 2026 23:55:53 +0000 Subject: [PATCH] Allow similar use between sync and async checks Towards #2639 Originally async expectations which extract a value also returned a `Subject` like synchronous extensions still do, but they were changed to accept `AsyncCondition` callback arguments for ergonomics. Add support for synchronous `Condition` callbacks on a few expectations that return subjects for authors who prefer to use the async style for synchronous checks too. Affected APIs: - `Subject.isA` - `Subject.isNotNull` - `Subject.throws` - `Subject.returnsNormally` Restore the `Future` return value from async check extensions that accept an optional `AsyncCondition` argument. This allows usage of other patterns which some authors may feel are better aligned with the sync checks of the original pattern. Affected APIs: - `Subject.completes` - `Subject.throws` - `Subject.emits` - `Subject.emitsError` Refactor tests into finer grained cases and add cases for the new arguments. --- pkgs/checks/CHANGELOG.md | 8 + pkgs/checks/lib/src/checks.dart | 56 ++++-- pkgs/checks/lib/src/extensions/async.dart | 184 +++++++++--------- pkgs/checks/lib/src/extensions/core.dart | 22 ++- pkgs/checks/lib/src/extensions/function.dart | 68 ++++--- pkgs/checks/test/extensions/async_test.dart | 18 ++ pkgs/checks/test/extensions/core_test.dart | 76 +++++--- .../checks/test/extensions/function_test.dart | 24 +++ 8 files changed, 276 insertions(+), 180 deletions(-) diff --git a/pkgs/checks/CHANGELOG.md b/pkgs/checks/CHANGELOG.md index a9b98556c..97d23b7b0 100644 --- a/pkgs/checks/CHANGELOG.md +++ b/pkgs/checks/CHANGELOG.md @@ -1,6 +1,14 @@ ## 0.3.2-wip - Require Dart 3.11 +- Updated `Context.nest` to accept an optional named `nestedCondition` argument + which is executed against the nested subject. +- Updated `Subject.isA`, `Subject.isNotNull`, `Subject.throws` (sync), and + `Subject.returnsNormally` to accept an optional `Condition` callback to apply + to the extracted value. +- Updated `Subject.completes`, `Subject.throws` (async), `StreamChecks.emits`, + and `StreamChecks.emitsError` to return `Future` in addition to + accepting an optional `AsyncCondition` callback. - Improve speed of pretty printing for large collections. - Improve formatting for failures involving unexpected exceptions. - Improve formatting for failed String equality checks - indent string diff diff --git a/pkgs/checks/lib/src/checks.dart b/pkgs/checks/lib/src/checks.dart index 1c82228f3..83ade6070 100644 --- a/pkgs/checks/lib/src/checks.dart +++ b/pkgs/checks/lib/src/checks.dart @@ -522,19 +522,25 @@ abstract final class Context { /// /// {@macro callbacks_may_be_unused} /// + /// If [nestedCondition] is passed it will be executed against the resulting + /// `Subject` before returning it. + /// Nesting extensions which are getters can ignore [nestedCondition], + /// and nesting extensions which are methods should prefer to include it. + /// /// ```dart - /// Subject get someDerivedValue => + /// Subject someDerivedValue([Condition? that]) => /// context.nest(() => ['has someDerivedValue'], (actual) { /// if (_cannotReadDerivedValue(actual)) { /// return Extracted.rejection( /// which: ['cannot read someDerivedValue']); /// } /// return Extracted.value(_readDerivedValue(actual)); - /// }); + /// }, nestedCondition: that); /// ``` Subject nest( Iterable Function() label, Extracted Function(T) extract, { + Condition? nestedCondition, bool atSameLevel = false, }); @@ -544,16 +550,16 @@ abstract final class Context { /// [Extracted.rejection] describing the problem. Otherwise it should return /// an [Extracted.value]. /// - /// In contrast to [nest], subsequent expectations need to be passed in - /// [nestedCondition] which will be applied to the subject for the extracted - /// value. - /// /// {@macro label_description} /// /// {@macro description_lines} /// /// {@macro callbacks_may_be_unused} /// + /// If [nestedCondition] is passed it will be executed against the resulting + /// `Subject` before returning it. + /// Async nesting extensions which are methods should prefer to include it. + /// /// {@macro async_limitations} /// /// Extensions which use `nestAsync` should always make that call @@ -561,8 +567,8 @@ abstract final class Context { /// in a synchronous context can be reported synchronously. /// /// ```dart - /// Future someAsyncResult( - /// [AsyncCondition resultCondition]) { + /// Future> someAsyncResult( + /// [AsyncCondition? resultCondition]) { /// return context.nestAsync(() => ['has someAsyncResult'], (actual) async { /// if (await _asyncOperationFailed(actual)) { /// return Extracted.rejection(which: ['cannot read someAsyncResult']); @@ -571,11 +577,11 @@ abstract final class Context { /// }, resultCondition); /// } /// ``` - Future nestAsync( + Future> nestAsync( Iterable Function() label, - FutureOr> Function(T) extract, + FutureOr> Function(T) extract, [ AsyncCondition? nestedCondition, - ); + ]); } /// A property extracted from a value being checked, or a rejection. @@ -786,6 +792,7 @@ final class _TestContext implements Context, _ClauseDescription { Subject nest( Iterable Function() label, Extracted Function(T) extract, { + Condition? nestedCondition, bool atSameLevel = false, }) { final result = _value.map((actual) => extract(actual)._fillActual(actual)); @@ -804,11 +811,13 @@ final class _TestContext implements Context, _ClauseDescription { context = _TestContext._child(value, label, this); _clauses.add(context); } - return Subject._(context); + final subject = Subject._(context); + nestedCondition?.call(subject); + return subject; } @override - Future nestAsync( + Future> nestAsync( Iterable Function() label, FutureOr> Function(T) extract, [ AsyncCondition? nestedCondition, @@ -817,7 +826,7 @@ final class _TestContext implements Context, _ClauseDescription { return _nestAsync(label, extract, nestedCondition); } - Future _nestAsync( + Future> _nestAsync( Iterable Function() label, FutureOr> Function(T) extract, AsyncCondition? nestedCondition, @@ -835,7 +844,9 @@ final class _TestContext implements Context, _ClauseDescription { final value = result._value ?? _Absent(); final context = _TestContext._child(value, label, this); _clauses.add(context); - await nestedCondition?.call(Subject._(context)); + final subject = Subject._(context); + await nestedCondition?.call(subject); + return subject; } finally { outstandingWork.complete(); } @@ -916,18 +927,23 @@ final class _SkippedContext implements Context { Subject nest( Iterable Function() label, Extracted Function(T p1) extract, { + Condition? nestedCondition, bool atSameLevel = false, }) { - return Subject._(_SkippedContext()); + final subject = Subject._(_SkippedContext()); + nestedCondition?.call(subject); + return subject; } @override - Future nestAsync( + Future> nestAsync( Iterable Function() label, - FutureOr> Function(T p1) extract, + FutureOr> Function(T p1) extract, [ AsyncCondition? nestedCondition, - ) async { - // no-op + ]) async { + final subject = Subject._(_SkippedContext()); + await nestedCondition?.call(subject); + return subject; } } diff --git a/pkgs/checks/lib/src/extensions/async.dart b/pkgs/checks/lib/src/extensions/async.dart index cee3d40a6..c83da36b2 100644 --- a/pkgs/checks/lib/src/extensions/async.dart +++ b/pkgs/checks/lib/src/extensions/async.dart @@ -18,21 +18,20 @@ extension FutureChecks on Subject> { /// /// The returned future will complete when the subject future has completed, /// and [completionCondition] has optionally been checked. - Future completes([AsyncCondition? completionCondition]) { - return context.nestAsync(() => ['completes to a value'], (actual) async { - try { - return Extracted.value(await actual); - } catch (e, st) { - return Extracted.rejection( - actual: ['a future that completes as an error'], - which: [ - ...prefixFirst('threw ', postfixLast(' at:', literal(e))), - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }, completionCondition); - } + Future> completes([AsyncCondition? completionCondition]) => + context.nestAsync(() => ['completes to a value'], (actual) async { + try { + return Extracted.value(await actual); + } catch (e, st) { + return Extracted.rejection( + actual: ['a future that completes as an error'], + which: [ + ...prefixFirst('threw ', postfixLast(' at:', literal(e))), + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, completionCondition); /// Expects that the `Future` never completes as a value or an error. /// @@ -79,30 +78,30 @@ extension FutureChecks on Subject> { /// /// The returned future will complete when the subject future has completed, /// and [errorCondition] has optionally been checked. - Future throws([AsyncCondition? errorCondition]) { - return context.nestAsync( - () => ['completes to an error${E == Object ? '' : ' of type $E'}'], - (actual) async { - try { - return Extracted.rejection( - actual: prefixFirst('completed to ', literal(await actual)), - which: ['did not throw'], - ); - } on E catch (e) { - return Extracted.value(e); - } catch (e, st) { - return Extracted.rejection( - actual: prefixFirst('completed to error ', literal(e)), - which: [ - 'threw an exception that is not a $E at:', - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }, - errorCondition, - ); - } + Future> throws([ + AsyncCondition? errorCondition, + ]) => context.nestAsync( + () => ['completes to an error${E == Object ? '' : ' of type $E'}'], + (actual) async { + try { + return Extracted.rejection( + actual: prefixFirst('completed to ', literal(await actual)), + which: ['did not throw'], + ); + } on E catch (e) { + return Extracted.value(e); + } catch (e, st) { + return Extracted.rejection( + actual: prefixFirst('completed to error ', literal(e)), + which: [ + 'threw an exception that is not a $E at:', + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, + errorCondition, + ); } /// Expectations on a [StreamQueue]. @@ -142,28 +141,27 @@ extension StreamChecks on Subject> { /// /// The returned future will complete when the stream has emitted, errored, or /// ended, and the [emittedCondition] has optionally been checked. - Future emits([AsyncCondition? emittedCondition]) { - return context.nestAsync(() => ['emits a value'], (actual) async { - if (!await actual.hasNext) { - return Extracted.rejection( - actual: ['a stream'], - which: ['closed without emitting enough values'], - ); - } - try { - await actual.peek; - return Extracted.value(await actual.next); - } catch (e, st) { - return Extracted.rejection( - actual: prefixFirst('a stream with error ', literal(e)), - which: [ - 'emitted an error instead of a value at:', - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }, emittedCondition); - } + Future> emits([AsyncCondition? emittedCondition]) => + context.nestAsync(() => ['emits a value'], (actual) async { + if (!await actual.hasNext) { + return Extracted.rejection( + actual: ['a stream'], + which: ['closed without emitting enough values'], + ); + } + try { + await actual.peek; + return Extracted.value(await actual.next); + } catch (e, st) { + return Extracted.rejection( + actual: prefixFirst('a stream with error ', literal(e)), + which: [ + 'emitted an error instead of a value at:', + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, emittedCondition); /// Expects that the stream emits an error of type [E]. /// @@ -180,40 +178,38 @@ extension StreamChecks on Subject> { /// /// The returned future will complete when the stream has emitted, errored, or /// ended, and the [errorCondition] has optionally been checked. - Future emitsError([ + Future> emitsError([ AsyncCondition? errorCondition, - ]) { - return context.nestAsync( - () => ['emits an error${E == Object ? '' : ' of type $E'}'], - (actual) async { - if (!await actual.hasNext) { - return Extracted.rejection( - actual: ['a stream'], - which: ['closed without emitting an expected error'], - ); - } - try { - final value = await actual.peek; - return Extracted.rejection( - actual: prefixFirst('a stream emitting value ', literal(value)), - which: ['closed without emitting an error'], - ); - } on E catch (e) { - await actual.next.then((_) {}, onError: (_) {}); - return Extracted.value(e); - } catch (e, st) { - return Extracted.rejection( - actual: prefixFirst('a stream with error ', literal(e)), - which: [ - 'emitted an error which is not $E at:', - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }, - errorCondition, - ); - } + ]) => context.nestAsync( + () => ['emits an error${E == Object ? '' : ' of type $E'}'], + (actual) async { + if (!await actual.hasNext) { + return Extracted.rejection( + actual: ['a stream'], + which: ['closed without emitting an expected error'], + ); + } + try { + final value = await actual.peek; + return Extracted.rejection( + actual: prefixFirst('a stream emitting value ', literal(value)), + which: ['closed without emitting an error'], + ); + } on E catch (e) { + await actual.next.then((_) {}, onError: (_) {}); + return Extracted.value(e); + } catch (e, st) { + return Extracted.rejection( + actual: prefixFirst('a stream with error ', literal(e)), + which: [ + 'emitted an error which is not $E at:', + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, + errorCondition, + ); /// Expects that the `Stream` emits any number of events before emitting an /// event that satisfies [condition]. diff --git a/pkgs/checks/lib/src/extensions/core.dart b/pkgs/checks/lib/src/extensions/core.dart index 9f3dd734b..1d0bbd2eb 100644 --- a/pkgs/checks/lib/src/extensions/core.dart +++ b/pkgs/checks/lib/src/extensions/core.dart @@ -103,14 +103,17 @@ extension CoreChecks on Subject { /// Expects that the value is assignable to type [T]. /// /// If the value is a [T], returns a [Subject] for further expectations. - Subject isA() { - return context.nest(() => ['is a $R'], (actual) { + Subject isA([Condition? and]) => context.nest( + () => ['is a $R'], + (actual) { if (actual is! R) { return Extracted.rejection(which: ['Is a ${actual.runtimeType}']); } return Extracted.value(actual); - }, atSameLevel: true); - } + }, + nestedCondition: and, + atSameLevel: true, + ); /// Expects that the value is equal to [other] according to [operator ==]. void equals(T other) { @@ -152,12 +155,15 @@ extension BoolChecks on Subject { } extension NullableChecks on Subject { - Subject isNotNull() { - return context.nest(() => ['is not null'], (actual) { + Subject isNotNull([Condition? and]) => context.nest( + () => ['is not null'], + (actual) { if (actual == null) return Extracted.rejection(); return Extracted.value(actual); - }, atSameLevel: true); - } + }, + nestedCondition: and, + atSameLevel: true, + ); void isNull() { context.expect(() => const ['is null'], (actual) { diff --git a/pkgs/checks/lib/src/extensions/function.dart b/pkgs/checks/lib/src/extensions/function.dart index 0fb27f378..fee447e21 100644 --- a/pkgs/checks/lib/src/extensions/function.dart +++ b/pkgs/checks/lib/src/extensions/function.dart @@ -17,26 +17,25 @@ extension FunctionChecks on Subject { /// If this function is async and returns a [Future], this expectation will /// fail. Instead invoke the function and check the expectation on the /// returned [Future]. - Subject throws() { - return context.nest(() => ['throws an error of type $E'], (actual) { - try { - final result = actual(); - return Extracted.rejection( - actual: prefixFirst('a function that returned ', literal(result)), - which: ['did not throw'], - ); - } catch (e, st) { - if (e is E) return Extracted.value(e as E); - return Extracted.rejection( - actual: prefixFirst('a function that threw error ', literal(e)), - which: [ - 'threw an exception that is not a $E at:', - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }); - } + Subject throws([Condition? that]) => + context.nest(() => ['throws an error of type $E'], (actual) { + try { + final result = actual(); + return Extracted.rejection( + actual: prefixFirst('a function that returned ', literal(result)), + which: ['did not throw'], + ); + } catch (e, st) { + if (e is E) return Extracted.value(e as E); + return Extracted.rejection( + actual: prefixFirst('a function that threw error ', literal(e)), + which: [ + 'threw an exception that is not a $E at:', + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, nestedCondition: that); /// Expects that the function returns without throwing. /// @@ -44,19 +43,18 @@ extension FunctionChecks on Subject { /// further expecations on the returned value. /// /// If the function throws synchronously, this expectation will fail. - Subject returnsNormally() { - return context.nest(() => ['returns a value'], (actual) { - try { - return Extracted.value(actual()); - } catch (e, st) { - return Extracted.rejection( - actual: ['a function that throws'], - which: [ - ...prefixFirst('threw ', postfixLast(' at:', literal(e))), - ...indent(LineSplitter.split(st.toString())), - ], - ); - } - }); - } + Subject returnsNormally([Condition? that]) => + context.nest(() => ['returns a value'], (actual) { + try { + return Extracted.value(actual()); + } catch (e, st) { + return Extracted.rejection( + actual: ['a function that throws'], + which: [ + ...prefixFirst('threw ', postfixLast(' at:', literal(e))), + ...indent(LineSplitter.split(st.toString())), + ], + ); + } + }, nestedCondition: that); } diff --git a/pkgs/checks/test/extensions/async_test.dart b/pkgs/checks/test/extensions/async_test.dart index ea0592058..80bf8d8e7 100644 --- a/pkgs/checks/test/extensions/async_test.dart +++ b/pkgs/checks/test/extensions/async_test.dart @@ -40,6 +40,9 @@ void main() { ]), ); }); + test('returns Future and can be awaited', () async { + (await check(_futureSuccess()).completes()).equals(42); + }); }); group('throws', () { @@ -83,6 +86,11 @@ void main() { (it) => it.deepEquals([' completes to an error of type StateError']), ); }); + test('returns Future and can be awaited', () async { + (await check(_futureFail()).throws()) + .has((p0) => p0.message, 'message') + .isNull(); + }); }); group('doesNotComplete', () { @@ -182,6 +190,9 @@ Which: threw 'error' at: await softCheckAsync>(queue, (it) => it.emits()); await check(queue).emitsError(); }); + test('returns Future and can be awaited', () async { + (await check(_countingStream(5)).emits()).equals(0); + }); }); group('emitsError', () { @@ -247,6 +258,13 @@ Which: threw 'error' at: await softCheckAsync>(queue, (it) => it.emitsError()); await check(queue).emits((it) => it.equals(0)); }); + test('returns Future and can be awaited', () async { + (await check( + _countingStream(1, errorAt: 0), + ).emitsError()) + .has((e) => e.message, 'message') + .equals('Error at 1'); + }); }); group('emitsThrough', () { diff --git a/pkgs/checks/test/extensions/core_test.dart b/pkgs/checks/test/extensions/core_test.dart index 5a44da8b3..4a83479a2 100644 --- a/pkgs/checks/test/extensions/core_test.dart +++ b/pkgs/checks/test/extensions/core_test.dart @@ -9,28 +9,46 @@ import '../test_shared.dart'; void main() { group('TypeChecks', () { - test('isA', () { - check(1).isA(); - - check(1).isRejectedBy((it) => it.isA(), which: ['Is a int']); + group('isA', () { + test('happy case', () { + check(1).isA(); + }); + test('failure case', () { + check(1).isRejectedBy((it) => it.isA(), which: ['Is a int']); + }); + test('evaluates condition', () { + check(1).isRejectedBy( + (it) => it.isA((it) => it.isGreaterThan(2)), + which: ['is not greater than <2>'], + ); + }); + test('returns valid subject', () { + check(1).isA().isGreaterThan(0); + }); }); }); group('HasField', () { - test('has', () { - check(1).has((v) => v.isOdd, 'isOdd').isTrue(); - - check(null).isRejectedBy( - (it) => it.has((v) { - Error.throwWithStackTrace( - UnimplementedError(), - StackTrace.fromString('fake trace'), - ); - }, 'foo').isNotNull(), - which: [ - 'threw while trying to read foo: at:', - ' fake trace', - ], - ); + group('has', () { + test('happy case', () { + check(1).has((v) => v.isOdd, 'isOdd').isTrue(); + }); + test('failure case', () { + check(null).isRejectedBy( + (it) => it.has((v) { + Error.throwWithStackTrace( + UnimplementedError(), + StackTrace.fromString('fake trace'), + ); + }, 'foo').isNotNull(), + which: [ + 'threw while trying to read foo: at:', + ' fake trace', + ], + ); + }); + test('returns valid subject', () { + check(1).has((v) => v.isOdd, 'isOdd').isTrue(); + }); }); test('which', () { @@ -92,10 +110,22 @@ void main() { }); }); group('NullabilityChecks', () { - test('isNotNull', () { - check(1).isNotNull(); - - check(null).isRejectedBy((it) => it.isNotNull()); + group('isNotNull', () { + test('happy case', () { + check(1).isNotNull(); + }); + test('failure case', () { + check(null).isRejectedBy((it) => it.isNotNull()); + }); + test('evaluates condition', () { + check(1).isRejectedBy( + (it) => it.isNotNull((it) => it.identicalTo(2)), + which: ['is not identical'], + ); + }); + test('returns valid subject', () { + check(1).isNotNull().identicalTo(1); + }); }); test('isNull', () { check(null).isNull(); diff --git a/pkgs/checks/test/extensions/function_test.dart b/pkgs/checks/test/extensions/function_test.dart index dde160041..4578d43dd 100644 --- a/pkgs/checks/test/extensions/function_test.dart +++ b/pkgs/checks/test/extensions/function_test.dart @@ -35,6 +35,20 @@ void main() { ], ); }); + test('evaluates condition', () { + check(() => throw StateError('oops!')).isRejectedBy( + (it) => it.throws( + (it) => it.has((e) => e.message, 'message').equals('wrong'), + ), + actual: ["'oops!'"], + which: ['differs at offset 0:', ' wrong', ' oops!', ' ^'], + ); + }); + test('returns valid subject', () { + check( + () => throw StateError('oops!'), + ).throws().has((e) => e.message, 'message').equals('oops!'); + }); }); group('returnsNormally', () { @@ -53,6 +67,16 @@ void main() { which: ['threw at:', ' fake trace'], ); }); + test('evaluates condition', () { + check(() => 1).isRejectedBy( + (it) => it.returnsNormally((it) => it.equals(2)), + actual: ['<1>'], + which: ['are not equal'], + ); + }); + test('returns valid subject', () { + check(() => 1).returnsNormally().equals(1); + }); }); }); }