Skip to content

Add support for collapsed error messages - #2651

Merged
natebosch merged 29 commits into
masterfrom
collapse-failure-messages
Sep 4, 2026
Merged

natebosch merged 29 commits into
masterfrom
collapse-failure-messages

Conversation

@natebosch

@natebosch natebosch commented May 20, 2026 •

Copy link
Copy Markdown
Member

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 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:

  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 an 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. When full single-line collapsing
is not possible, child clauses and Actual: lines still collapse where
supported 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.

@github-actions github-actions Bot added the package:checks Issues related to pkg:checks label May 20, 2026
@github-actions

github-actions Bot commented May 20, 2026 •

Copy link
Copy Markdown

PR Health

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

@natebosch
natebosch force-pushed the collapse-failure-messages branch 6 times, most recently from dccf79c to ccbd58e Compare May 20, 2026 03:14
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.
@natebosch
natebosch marked this pull request as ready for review August 18, 2026 22:14
@natebosch
natebosch requested a review from a team as a code owner August 18, 2026 22:14
@natebosch
natebosch requested review from brianquinlan and lrhn August 18, 2026 22:14

@brianquinlan brianquinlan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! Love the examples.

@lrhn lrhn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread pkgs/checks/lib/src/checks.dart Outdated
Rejection? rejection, {
required bool thisContextFailed,
}) {
final clause = _clauses.singleOrNull;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Wouldn't it be awesome if return was an expression:

   final clause = _clauses.singleOrNull ?? (return null);

😁 )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not the first time I've wanted that, but it's close 😄

Comment thread pkgs/checks/lib/src/checks.dart Outdated
if (clause.isLeaf) {
expandedCollapsed = (childExpected, childActual);
} else {
final rootLabel = _label().single;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Confusing to read, could _label be named _labelFactory or _labelFunction?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about _labelCallback?

Done.

Comment thread pkgs/checks/lib/src/checks.dart Outdated
);
}

/// Computes the uncollapsed, multi-line [FailureDetail] for this context and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noun-phrased function, drop the "Computes". (Which is better than "Returns", but not more necessary.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread pkgs/checks/lib/src/checks.dart Outdated
FailureDetail(_expected(), -1, -1);
FailureDetail detail(_TestContext failingContext, Rejection? rejection) {
final collapsedExpected = _predicateNoun?.call();
final collapsedActualString = collapsedExpected != null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Remove String from name to match the others. And to not repeat type in name.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread pkgs/checks/lib/src/checks.dart Outdated
: null;
final collapsedDetails = collapsedActualString != null
? (collapsedExpected!, collapsedActualString)
: null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

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.
@natebosch
natebosch merged commit 18ef597 into master Sep 4, 2026
71 checks passed
@natebosch
natebosch deleted the collapse-failure-messages branch September 4, 2026 17:13
copybara-service Bot pushed a commit to dart-lang/sdk that referenced this pull request Sep 4, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package:checks Issues related to pkg:checks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants