Skip to content

Fix Assert.That to not evaluate expression twice (re-opens #6699)#8307

Merged
Evangelink merged 20 commits into
mainfrom
dev/amauryleve/assert-that
May 19, 2026
Merged

Fix Assert.That to not evaluate expression twice (re-opens #6699)#8307
Evangelink merged 20 commits into
mainfrom
dev/amauryleve/assert-that

Conversation

@Evangelink
Copy link
Copy Markdown
Member

Re-opens PR #6699 (which was closed without merging).

Fixes Assert.That to not evaluate the lambda body twice for the assert and for the message. Concretely, Assert.That(() => box.GetValueWithSideEffect() != 0) used to invoke GetValueWithSideEffect() once to evaluate the condition, then re-evaluate sub-expressions a second time to build the diagnostic message - producing wrong/confusing values for side-effecting operands. See #6690.

What's in this branch

  • New EvaluateExpression / EvaluateAllSubExpressions pair that walks the expression tree bottom-up, caches each sub-expression value, and lets ExtractDetails read from the cache instead of recompiling and re-invoking.
  • New AssertThatFailedToEvaluate resource (<Failed to evaluate>) for sub-expressions that the cache couldn't resolve.
  • Companion tests in AssertTests.That.cs.

Merge notes

This branch was rebased on top of latest main (commit 6b0bae4). Resolved conflicts:

  • Assert.That.cs: kept the new EvaluateExpression / cache logic from this branch; adopted main's more efficient StringBuilder.Append(char, int) overload while preserving the MaxConsecutiveParentheses named constant from this branch.
  • FrameworkMessages.resx: kept AssertThatFailedToEvaluate from this branch alongside all the new RFC 012 summary entries from main; xlf files regenerated via dotnet msbuild /t:UpdateXlf.
  • Guarded [RequiresDynamicCode(...)] attributes with #if NET7_0_OR_GREATER (the attribute is not available on netstandard2.0 / net462 targets and the branch was previously failing to build on those).

Validation

  • dotnet build src/TestFramework/TestFramework/TestFramework.csproj -c Debug -> clean across all 4 target frameworks (netstandard2.0, net462, net8.0, net9.0).
  • dotnet test test/UnitTests/TestFramework.UnitTests/TestFramework.UnitTests.csproj -- --treenode-filter "/*/*/AssertTests/That*" -> 256/256 passed across net8.0, net8.0-windows10.0.18362.0, net9.0, net48.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Evangelink and others added 9 commits October 8, 2025 11:16
# Conflicts:
#	src/TestFramework/TestFramework/Resources/FrameworkMessages.resx
#	test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs
Resolved conflicts in Assert.That.cs (kept new EvaluateExpression/EvaluateAllSubExpressions cache logic, adopted main's more efficient StringBuilder.Append(char, int) overload), FrameworkMessages.resx (kept AssertThatFailedToEvaluate alongside all new RFC 012 entries from main), and regenerated xlf files via UpdateXlf. Also guarded RequiresDynamicCodeAttribute usages with #if NET7_0_OR_GREATER (attribute is not available on netstandard2.0/net462).
Copilot AI review requested due to automatic review settings May 17, 2026 19:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@Evangelink
Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented May 18, 2026

@copilot resolve the merge conflicts in this pull request

Resolved by merge commit 9889353 on this branch. I merged origin/main, resolved the conflicted files, and pushed the updated branch state.

Use comparer.GetType().Name in Assert.AreAllDistinct failure messages so the comparer line matches the existing assertion-message convention and the TestFramework unit tests across TFMs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 18, 2026 08:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot's findings

Comments suppressed due to low confidence (7)

src/TestFramework/TestFramework/Assertions/Assert.That.cs:150

  • This evaluates both operands for every binary expression before evaluating the parent, which changes C# short-circuit semantics for &&, ||, and ??. For example, Assert.That(() => false && Throw()) used to fail the assertion without invoking the right side, but this traversal evaluates the right side while building the cache and can invoke side effects or throw before the assertion result is computed.
                case BinaryExpression binaryExpr:
                    // Evaluate both operands before evaluating the binary operation
                    EvaluateAllSubExpressions(binaryExpr.Left, cache);
                    EvaluateAllSubExpressions(binaryExpr.Right, cache);
                    hasChildren = true;

src/TestFramework/TestFramework/Assertions/Assert.That.cs:190

  • Conditional expressions are evaluated by visiting both branches up front. That does not match runtime ternary/null-conditional semantics, where only the selected branch is evaluated, so assertions such as flag ? true : Throw() can execute code that should be unreachable and fail before the actual condition result is known.
                case ConditionalExpression conditionalExpr:
                    // For ternary expressions, evaluate all three parts
                    EvaluateAllSubExpressions(conditionalExpr.Test, cache);
                    EvaluateAllSubExpressions(conditionalExpr.IfTrue, cache);
                    EvaluateAllSubExpressions(conditionalExpr.IfFalse, cache);
                    hasChildren = true;

src/TestFramework/TestFramework/Assertions/Assert.That.cs:272

  • This catch also runs for exceptions thrown while evaluating the assertion condition itself. Previously condition.Compile()() propagated those user exceptions; now the root expression can be cached as the "<Failed to evaluate>" string and EvaluateExpression later casts it to bool, producing an unrelated InvalidCastException instead of the original exception.
        catch
        {
            // If evaluation fails (e.g., null reference, division by zero), mark it as failed
            // rather than throwing. This allows us to continue and provide diagnostic information.
            cache[expr] = FrameworkMessages.AssertThatFailedToEvaluate;

src/TestFramework/TestFramework/Assertions/Assert.That.cs:210

  • Constructor arguments are evaluated and cached here, but ReplaceSubExpressionsWithConstants does not rebuild NewExpression, so the later DynamicInvoke evaluates the original constructor expression and its arguments again. This leaves expressions like Assert.That(() => new Foo(box.Next()).Value == 0) still invoking side-effecting arguments twice.
                case NewExpression newExpr:
                    // For object creation, evaluate all constructor arguments
                    foreach (Expression argument in newExpr.Arguments)
                    {
                        EvaluateAllSubExpressions(argument, cache);
                        hasChildren = true;
                    }

src/TestFramework/TestFramework/Assertions/Assert.That.cs:234

  • Array element expressions are evaluated and cached here, but new-array expressions are not rebuilt with cached constants before the parent is invoked. A failing assertion over new[] { box.Next() } will still call Next() once during this traversal and again when the original NewArrayExpression is compiled.
                case NewArrayExpression newArrayExpr:
                    // For array creation, evaluate all element expressions
                    foreach (Expression expression in newArrayExpr.Expressions)
                    {
                        EvaluateAllSubExpressions(expression, cache);
                        hasChildren = true;
                    }

src/TestFramework/TestFramework/Assertions/Assert.That.cs:225

  • Collection initializer arguments are evaluated here, but ReplaceSubExpressionsWithConstants has no ListInitExpression case, so evaluating the initializer later uses the original Add(...) arguments and re-invokes any side-effecting expressions. This means the fix does not cover assertions involving collection initializers.
                case ListInitExpression listInitExpr:
                    // For collection initializers, evaluate the new expression and all initializer arguments
                    EvaluateAllSubExpressions(listInitExpr.NewExpression, cache);
                    foreach (ElementInit initializer in listInitExpr.Initializers)
                    {
                        foreach (Expression argument in initializer.Arguments)
                        {
                            EvaluateAllSubExpressions(argument, cache);
                        }
                    }

                    hasChildren = true;

src/TestFramework/TestFramework/Assertions/Assert.That.cs:201

  • Invocation arguments are evaluated during the cache walk, but invocation expressions are not rebuilt with cached constants before DynamicInvoke. A predicate call such as Assert.That(() => predicate(box.Next())) will therefore evaluate box.Next() during argument traversal and then again when the original invocation expression is executed.
                case InvocationExpression invocationExpr:
                    // For delegate invocations, evaluate the delegate and all arguments
                    EvaluateAllSubExpressions(invocationExpr.Expression, cache);
                    foreach (Expression argument in invocationExpr.Arguments)
                    {
                        EvaluateAllSubExpressions(argument, cache);
                    }

                    hasChildren = true;
  • Files reviewed: 17/17 changed files
  • Comments generated: 3

Comment on lines +61 to +67
// Cache to store evaluated expression values to avoid re-evaluation.
// This is critical for expressions with side effects - we evaluate each sub-expression
// only once and reuse the cached result throughout the assertion process.
var evaluationCache = new Dictionary<Expression, object?>();

// Evaluate the condition expression and cache all sub-expression values
bool result = EvaluateExpression(condition.Body, evaluationCache);
CheckParameterNotNull(collection, "Assert.AreAllDistinct", "collection");
CheckParameterNotNull(comparer, "Assert.AreAllDistinct", "comparer");
AreAllDistinctImpl(collection, comparer, comparerTypeName: comparer.GetType().ToString(), message, collectionExpression);
AreAllDistinctImpl(collection, comparer, comparerTypeName: comparer.GetType().Name, message, collectionExpression);
CheckParameterNotNull(collection, "Assert.AreAllDistinct", "collection");
CheckParameterNotNull(comparer, "Assert.AreAllDistinct", "comparer");
AreAllDistinctImpl(collection.Cast<object?>(), new NonGenericEqualityComparerAdapter(comparer), comparerTypeName: comparer.GetType().ToString(), message, collectionExpression);
AreAllDistinctImpl(collection.Cast<object?>(), new NonGenericEqualityComparerAdapter(comparer), comparerTypeName: comparer.GetType().Name, message, collectionExpression);
Evangelink pushed a commit that referenced this pull request May 18, 2026
- Assert.AreAllDistinct now reports comparer.GetType().Name (matches PR #8307 fix).
- Assert.Throws now passes 'action' as the call-site placeholder so multi-line
  action expressions render as Assert.Throws<T>(<action>) instead of the
  default <value>. The corresponding test was renamed and updated to match
  the new behaviour introduced by the FormatCallSiteExpression refactor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink pushed a commit that referenced this pull request May 18, 2026
Aligns with the same fix in PR #8307 / PR #8262 so the assertion message
shows the comparer's short type name instead of the full namespaced string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Evangelink
Copy link
Copy Markdown
Member Author

@copilot address review comments

Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 18, 2026 13:27
@Evangelink Evangelink review requested due to automatic review settings May 18, 2026 13:27
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 18, 2026 13:32
@Evangelink Evangelink review requested due to automatic review settings May 18, 2026 13:32
When the parent of a member expression is a method call (e.g. `provider.GetBox().Value`), the failure details should only show the leaf access (`provider.GetBox().Value = 1`), not an extra entry for the intermediate side-effecting call (`provider.GetBox() = <ValueBox>`). The exclusion already covered nested MemberExpressions; extend it to MethodCallExpression for the same reason — and to avoid surfacing intermediate side-effecting calls in failure details.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 18, 2026 17:47
@Evangelink
Copy link
Copy Markdown
Member Author

Pushed 5775de2 to fix the test failure from the latest CI run:

That_FieldAccessWithSideEffectingParent_DoesNotEvaluateTwice expected only provider.GetBox().Value = 1 in the failure details, but the actual output also included provider.GetBox() = <ValueBox> for the intermediate method call. AddMemberExpressionToDetails already skipped extracting child variables when the receiver is a MemberExpression (to avoid showing both person and person.Name); extended the same exclusion to MethodCallExpression so we don't surface the intermediate side-effecting receiver as a separate detail entry when it's part of a longer member chain.

All 66 Assert.That unit tests pass locally on net9.0.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 16/16 changed files
  • Comments generated: 5

Comment on lines +189 to +193
case BinaryExpression binaryExpr:
// Evaluate both operands before evaluating the binary operation
EvaluateAllSubExpressions(binaryExpr.Left, cache);
EvaluateAllSubExpressions(binaryExpr.Right, cache);
hasChildren = true;
Comment on lines +229 to +232
// For ternary expressions, evaluate all three parts
EvaluateAllSubExpressions(conditionalExpr.Test, cache);
EvaluateAllSubExpressions(conditionalExpr.IfTrue, cache);
EvaluateAllSubExpressions(conditionalExpr.IfFalse, cache);
Comment on lines +196 to +199
case UnaryExpression unaryExpr:
// Evaluate the operand before evaluating the unary operation
EvaluateAllSubExpressions(unaryExpr.Operand, cache);
hasChildren = true;
Comment on lines +313 to +314
// If evaluation fails (e.g., null reference, division by zero), mark it as failed
// rather than throwing. This allows us to continue and provide diagnostic information.
Comment on lines +296 to +300
Expression replacedExpr = ReplaceSubExpressionsWithConstants(expr, cache);

// Evaluate the replaced expression - this is now safe because all sub-expressions
// that could have side effects have been replaced with their constant values.
object? result = Expression.Lambda(replacedExpr).Compile().DynamicInvoke();
…exception propagation, full rebuild coverage

Single-pass evaluation now honors C# short-circuit semantics so passing assertions never invoke unreached operands or branches:

- BinaryExpression AndAlso/OrElse evaluates Right only when Left's bool result requires it.

- BinaryExpression Coalesce evaluates Right only when Left is null, and rebuilds with the user-supplied Conversion preserved.

- ConditionalExpression evaluates Test then only the selected branch.

- UnaryExpression with NodeType == Quote is treated as a leaf so quoted lambdas in IQueryable scenarios (e.g. items.AsQueryable().Any(i => ...)) pass through untouched instead of being turned into delegates and then failing to rebuild.

ReplaceSubExpressionsWithConstants now covers NewExpression, NewArrayExpression, ListInitExpression, InvocationExpression, IndexExpression and uses each node's Update() helper so metadata such as BinaryExpression.Method/Conversion is preserved. ListInitExpression no longer evaluates its wrapping NewExpression as a standalone instance, which would have caused the constructor to run twice.

Failed sub-expression evaluations now cache an internal reference-identity sentinel (instead of the localized '<Failed to evaluate>' string), so a string-typed leaf can never be silently replaced with that text during a parent rebuild. EvaluateExpression detects that the root cache result is not a bool and falls back to invoking the original lambda once so the user's real exception (e.g. NullReferenceException) surfaces instead of being masked by an InvalidCastException.

Diagnostic extraction walks every branch of a short-circuit/conditional expression so users still see all captured variables in the failure details. The walk pre-populates the cache with safe evaluations; sub-expressions that would throw (e.g. s.Length when s is null) are stored as the sentinel and silently omitted instead of surfaced as '<Failed to evaluate>'. Side effects in short-circuited branches now execute at most once total (only during the diagnostic walk, never during a passing assertion).

New tests cover AndAlso/OrElse/?? short-circuit, ternary lazy branch, NewExpression/NewArrayExpression/Invocation no-double-eval, user exception propagation, and Queryable.Any with a quoted lambda.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Evangelink
Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

@Evangelink
Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 19, 2026 09:03
@Evangelink Evangelink review requested due to automatic review settings May 19, 2026 09:03
Copy link
Copy Markdown
Contributor

Copilot AI commented May 19, 2026

@copilot resolve the merge conflicts in this pull request

Resolved in merge commit 1d6d948: I merged origin/main, fixed conflicts in Assert.That.cs and AssertTests.That.cs, and pushed the updated branch.

Copy link
Copy Markdown
Contributor

Copilot AI commented May 19, 2026

@copilot resolve the merge conflicts in this pull request

The merge commit 1d6d948 successfully resolved all conflicts. However, I notice that PR #8306 (commit 29545ad) has already merged a solution for the same Assert.That issue (#6690) to main.

This branch now contains a different implementation approach than what's in main. If you'd like to close this PR in favor of the already-merged solution, or if you'd prefer to keep working on this branch with a different approach, please let me know how you'd like to proceed.

@Evangelink Evangelink merged commit a041b47 into main May 19, 2026
2 of 11 checks passed
@Evangelink Evangelink deleted the dev/amauryleve/assert-that branch May 19, 2026 09:11
Evangelink added a commit that referenced this pull request May 19, 2026
Picks up new AssertThatFailedToEvaluate trans-unit from #8307 across all locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink added a commit that referenced this pull request May 19, 2026
Resolve conflicts with PR #8307 (different Assert.That fix):
- Accept main's Assert.That.cs architecture (#8307 supersedes the AnalyzeExpression / CaptureRewriter approach this PR was patching)
- Keep regression tests for assignment side-effect preservation and null delegate-typed members (they still validate behavior on the new architecture)
- Drop ManuallyConstructedPreIncrementExpression test (exposes a separate double-evaluation bug in main's handling of PreDecrementAssign that is out of scope for this PR)
- Relax 'ComputeValue()' assertion to nameof(ComputeValue) per review comment 3265122664

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink pushed a commit that referenced this pull request May 19, 2026
…ted reference

Resets PR #8308 onto current main and re-adds the unique contribution from
@jakubjares as a single test plus its helper types.

After PR #8307 was merged via #8306 and PR #8358 added the CA1305 fix, the
old branch was hopelessly diverged from main. Instead of trying to thread
the needle through 16+ source conflicts, this commit takes main's
source/test files verbatim and only adds:

* That_TwoCallsReturningSameMutatedReference_DeduplicatesInDetails
* Shape (helper)
* BoxOfShapes (helper)

The test pins down the current UX limitation: when two calls return the
SAME mutable reference and the comparison is reference-inequality, the
cache stores the same reference for both call expressions; by the time
details are extracted the object has been mutated, both slots resolve to
the same value, and TryAddExpressionValue's same-value dedupe means only
one entry surfaces (`Shape: Circle`). Documenting this behavior gives
us a regression guard if we ever decide to improve the diagnostic.

Replaces the previous `WithMessage("non")` placeholder with the real
expected message. All 76 That_* tests pass.

Co-authored-by: Jakub Jareš <me@jakubjares.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink pushed a commit that referenced this pull request May 19, 2026
…ted reference

Resets PR #8308 onto current main and re-adds the unique contribution from
@jakubjares as a single test plus its helper types.

After PR #8307 was merged via #8306 and PR #8358 added the CA1305 fix, the
old branch was hopelessly diverged from main. Instead of trying to thread
the needle through 16+ source conflicts, this commit takes main's
source/test files verbatim and only adds:

* That_TwoCallsReturningSameMutatedReference_DeduplicatesInDetails
* Shape (helper)
* BoxOfShapes (helper)

The test pins down the current UX limitation: when two calls return the
SAME mutable reference and the comparison is reference-inequality, the
cache stores the same reference for both call expressions; by the time
details are extracted the object has been mutated, both slots resolve to
the same value, and TryAddExpressionValue's same-value dedupe means only
one entry surfaces (`Shape: Circle`). Documenting this behavior gives
us a regression guard if we ever decide to improve the diagnostic.

Replaces the previous `WithMessage("non")` placeholder with the real
expected message. All 76 That_* tests pass.

Co-authored-by: Jakub Jareš <me@jakubjares.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink added a commit that referenced this pull request May 19, 2026
…stacks on #8307) (#8308)

Co-authored-by: Evangelink <amaury@evangelink.net>
Co-authored-by: Jakub Jareš <me@jakubjares.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants