Repository navigation
Add support for collapsed error messages - #2651
Conversation
PR HealthChangelog Entry ✔️
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with |
dccf79c to
ccbd58e
Compare
Checks against a subject are commonly nested against some other subject,
for example the check for an exact list length is in practice an
`equals` expectation checked against an integer which is the "has
length" subject, nested under the subject holding the list instance.
Failure messages have a rigid structure that represents this nesting
pattern and always indent the "clauses" (the descriptions of what was
checked in expectations) following the "label" which describes subject.
For very simple conditions the noise of the structure is greater than
the signal of the description output by the specific failing
expectation. For instance a trivial check for an future completing to an
expected value looks like:
```
Expected: a Future<List<int>> that:
completes to a value that:
is not empty
Actual: a Future<List<int>> that:
completes to a value that:
Actual: []
Which: is empty
```
Add support for collapsing this to.
```
Expected: a Future<List<int>> that completes to a non-empty iterable
Actual: a Future<List<int>> that completes to []
Which: is empty
```
Add a `predicateNoun` callback argument to `expect`, `expectAsync`, and
`expectUnawaited`. Extensions must pass a callback which provides the
"clause" representation like "is empty". Now they may also pass a
callback which provides a predicate noun formed by combining the
clause with a noun for the subject like "an empty iterable".
Add a `addPredicate` callback argument to `nest`, and `nestAsync`.
Extensions must pass a callback which provides a label for the value
they are deriving from the original subject like "completes to a value".
Now they may also pass a callback which takes a collapsed form of the
noun as described by nested conditions, and attaches the predicate it
contributes.
Check for possibilities to collapse failure messages down to predicate
nouns when generating failure messages.
Add predicate noun callbacks to existing extensions where sensible.
Tweak some wording where I noticed inconsistencies.
Use of these new arguments is completely optional for extension
implementations and impacts only the failure message formatting which is
not considered a breaking change.
The user impact is that many simple expectation failures will be more
compact and readable, but the position of the failing expectation in
a series of cascaded expectations may cause a significantly different
output for the same checks usage. It has always been the case that
expectations which follow a failure have no impact on the output,
because they are never reached to run. Now when the first expectation in
a series has a collapsible representation it's failure triggers an
overall collapsed format which will read differently to the expanded
format if any condition other than the first fails.
ccbd58e to
bd2f609
Compare
brianquinlan
left a comment
There was a problem hiding this comment.
This looks goods if you are convinced that the complexity is worth it.
| /// expectation modifying the noun (often just a representation of the expected | ||
| /// value) to be used when collapsing simple expectations. | ||
| /// | ||
| /// Examples of predicate nouns: |
There was a problem hiding this comment.
Very nice! Love the examples.
lrhn
left a comment
There was a problem hiding this comment.
(Can't claim to actually be able to follow the computations, but it looks plausible and it's tested!)
| }); | ||
| context.expect( | ||
| () => prefixFirst('equals ', literal(other)), | ||
| predicateNoun: () => literal(other).singleOrNull, |
There was a problem hiding this comment.
Probably not worth it, but:
Doing literal(other) twice, which calls the potentially expensive prettyPrint.
(It returns an Iterable which is always a List. Just change the return type to List, instead of risking code that depends on it being cheap to iterate and safe to iterate twice, without actually being promised that.)
Could do List<String>? otherLiteral; before the context.expect call and () => prefixFirst('equals ', otherLiteral ??= literal(other)), and
predicateNoun: () => (otherLiteral ??= literal(other)).singleOrNull.
I know this only gets called if a test fails, and it's not like any of the other list-of-string operations are optimized, so it's probably not performance critical. I just don't like seing duplicate work.
It happens a lot that there is an other and both callbacks call literal on it, but it's not every context.expect that has an other value.
(Could have a helper extension function context.expectWithOther(other, (otherLiteral) => prefixFirst('equals ', literal(other)), predicateName: (otherLiteral) => otherLiteral.singleOrNull, ..., or a LazyLiteral(other) with a caching lazyOtherLiteral.literal.)
There was a problem hiding this comment.
I think we can address this later and it will be non-breaking. For now I'll keep them separate since the callbacks are called (hopefully) rarely, and a memoization approach add complexity.
| predicateNoun: () => literal(other).singleOrNull, | ||
| (actual) { | ||
| if (actual == other) return null; | ||
| return Rejection(which: ['is not equal']); |
There was a problem hiding this comment.
I'd keep the are.
Being equal is a binary relation. It needs either two subjects (so s plural "are") or a to <other> subject complement (a term I just looked up, so I hope it's correct).
Unless the "to" gets added by the predicateNoun handling, but I don't really nuderstand the flow flow, and there is no documentation!
There was a problem hiding this comment.
This failure looks like:
Expected: <2>
Actual: <1>
Which: is not equal
I think initially I had this longer and shortened it because it felt redundant:
Expected: <2>
Actual: <1>
Which: is not equal to <2>
I think the "is" reads better, but we can adjust this in a followup. We don't consider is breaking to change the particular wording of failure messages.
| predicateNoun: () => literal(other).singleOrNull, | ||
| (actual) { | ||
| if (identical(actual, other)) return null; | ||
| return Rejection(which: ['is not identical']); |
There was a problem hiding this comment.
"are", unless to $other gets added somewhere.
(And I doubt that, because won't always be "to" that's the right word, so unless it's a default, this function should still mention the "to".)
| () => ['has last element'], | ||
| addPredicate: (predicateNoun) => 'has last element: $predicateNoun', | ||
| (actual) { | ||
| final iterator = actual.iterator; |
There was a problem hiding this comment.
(Unrelated: Could this be using the actual.last instead. It might be more efficient.
It'd have to catch a StateError and recognize that as meaning "no element", which isn't great, or iterate twice, which is also not great.
_I tried testing an iterable, and was surprised when check(myIteralble).last.equals(x) didn't call my last code. I guess you have to do check(() => myIterable.last)..., putting the actual code you want to check inside the check, to be sure. Might need to be documented somewhere.)
| Rejection? rejection, { | ||
| required bool thisContextFailed, | ||
| }) { | ||
| final clause = _clauses.singleOrNull; |
There was a problem hiding this comment.
(Wouldn't it be awesome if return was an expression:
final clause = _clauses.singleOrNull ?? (return null);😁 )
There was a problem hiding this comment.
It's not the first time I've wanted that, but it's close 😄
| if (clause.isLeaf) { | ||
| expandedCollapsed = (childExpected, childActual); | ||
| } else { | ||
| final rootLabel = _label().single; |
There was a problem hiding this comment.
(Confusing to read, could _label be named _labelFactory or _labelFunction?)
There was a problem hiding this comment.
How about _labelCallback?
Done.
| ); | ||
| } | ||
|
|
||
| /// Computes the uncollapsed, multi-line [FailureDetail] for this context and |
There was a problem hiding this comment.
Noun-phrased function, drop the "Computes". (Which is better than "Returns", but not more necessary.)
| FailureDetail(_expected(), -1, -1); | ||
| FailureDetail detail(_TestContext failingContext, Rejection? rejection) { | ||
| final collapsedExpected = _predicateNoun?.call(); | ||
| final collapsedActualString = collapsedExpected != null |
There was a problem hiding this comment.
(Remove String from name to match the others. And to not repeat type in name.)
| : null; | ||
| final collapsedDetails = collapsedActualString != null | ||
| ? (collapsedExpected!, collapsedActualString) | ||
| : null; |
There was a problem hiding this comment.
Hard to follow the logic. Maybe nesting would be better?
(String, String)? collapsedDetails;
if (_predicateNoun?.call() case final collapsedExpected?) {
if (rejection?.actual.singleOrNull case final collapsedActual?) {
collapsedDetails = (collapsedExpected, collapsedActual);
}
}I think it's the same computation, but I find it much easier to read.
(And it doesn't need !, which is a sign that the code has the same logic as the programmer.)
Support collapsing for clauses other than the one that failed.
This reverts commit 5a6813d. I think this is still useful in some cases
Don't need to consider `thisContextFailed` for the `_collapsedDetails` implementation. Either the clause is on the failing path, or the rejection will be null. Add 2 asserts documenting guarantees Fix a bug where non-failing child context with a failing parent context were treated as failing themselves.
Revisions updated by `dart tools/rev_sdk_deps.dart test`. test (https://github.com/dart-lang/test/compare/55b186d..18ef597): 18ef5978 Fri Sep 4 10:13:39 2026 -0700 Nate Bosch Add support for collapsed error messages (dart-lang/test#2651) 6783445c Wed Sep 2 16:46:04 2026 -0700 Nate Bosch Prepare to publish (dart-lang/test#2737) 8de693e1 Tue Sep 1 03:54:15 2026 +0000 dependabot[bot] Bump the github-actions group with 2 updates (dart-lang/test#2744) 575e4dad Mon Aug 31 17:01:46 2026 -0700 Nate Bosch Fix deadlock waiting on serverSocket (dart-lang/test#2740) bd9acf32 Thu Aug 27 22:53:54 2026 -0700 Nate Bosch Unskip a test on windows (dart-lang/test#2741) b575eda9 Thu Aug 27 17:16:16 2026 -0700 Nate Bosch Remove redundant tag (dart-lang/test#2738) d21b06cb Thu Aug 27 16:38:17 2026 -0700 dependabot[bot] Bump the github-actions group across 1 directory with 5 updates (dart-lang/test#2709) 773bc56c Thu Aug 27 16:11:00 2026 -0700 Nate Bosch Use read permissions in test_descriptor and test_process workflows (dart-lang/test#2736) 73e85f1b Wed Aug 26 06:36:41 2026 +0700 herdiyanitdev Precompiled browser tests: stop serving files from outside the precompiled root (dart-lang/test#2724) bbe8f9bf Tue Aug 18 09:02:24 2026 -0700 Kevin Moore Export TestFailure from package:test_api/scaffolding.dart (dart-lang/test#2723) TAG=agy CONV=e6fd1c32-ded3-443f-83e5-2e15b4ffaccb [email protected] Change-Id: Iabc67d46c413470f0d2e5341e719d4edf702a94b Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/546020 Commit-Queue: Nate Bosch <[email protected]> Auto-Submit: Nate Bosch <[email protected]> Reviewed-by: Kevin Moore <[email protected]> Commit-Queue: Kevin Moore <[email protected]>
Checks against a subject are commonly nested against some other subject,
for example the check for an exact list length is in practice an
equalsexpectation checked against an integer which is the "haslength" subject, nested under the subject holding the list instance.
Failure messages have a rigid structure that represents this nesting
pattern and always indent the "clauses" (the descriptions of what was
checked in expectations) following the "label" which describes the
subject.
For very simple conditions the noise of the structure is greater than
the signal of the description output by the specific failing
expectation. For instance a trivial check for a future completing to an
expected value looks like:
Add support for collapsing this to:
Add a
predicateNouncallback argument toexpect,expectAsync, andexpectUnawaited. Extensions must pass a callback which provides the"clause" representation like "is empty". Now they may also pass a
callback which provides a predicate noun formed by combining the clause
with a noun for the subject like "an empty iterable".
Add an
addPredicatecallback argument tonest, andnestAsync.Extensions must pass a callback which provides a label for the value
they are deriving from the original subject like "completes to a value".
Now they may also pass a callback which takes a collapsed form of the
noun as described by nested conditions, and attaches the predicate it
contributes.
Check for possibilities to collapse failure messages down to predicate
nouns when generating failure messages. When full single-line collapsing
is not possible, child clauses and
Actual:lines still collapse wheresupported within the multi-line format.
Add predicate noun callbacks to existing extensions where sensible.
Tweak some wording where I noticed inconsistencies.
Use of these new arguments is completely optional for extension
implementations and impacts only the failure message formatting which is
not considered a breaking change.
The user impact is that many simple expectation failures will be more
compact and readable, but the position of the failing expectation in a
series of cascaded expectations may cause a significantly different
output for the same checks usage. It has always been the case that
expectations which follow a failure have no impact on the output,
because they are never reached to run. Now when the first expectation in
a series has a collapsible representation, its failure triggers an
overall collapsed format which will read differently from the expanded
format if any condition other than the first fails.