Fix Assert.That to not evaluate expression twice (re-opens #6699)#8307
Conversation
…soft/testfx into dev/amauryleve/assert-that
# 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 resolve the merge conflicts in this pull request |
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Resolved by merge commit |
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>
There was a problem hiding this comment.
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 andEvaluateExpressionlater casts it tobool, producing an unrelatedInvalidCastExceptioninstead 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
ReplaceSubExpressionsWithConstantsdoes not rebuildNewExpression, so the laterDynamicInvokeevaluates the original constructor expression and its arguments again. This leaves expressions likeAssert.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 callNext()once during this traversal and again when the originalNewArrayExpressionis 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
ReplaceSubExpressionsWithConstantshas noListInitExpressioncase, so evaluating the initializer later uses the originalAdd(...)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 asAssert.That(() => predicate(box.Next()))will therefore evaluatebox.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
| // 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); |
- 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>
|
@copilot address review comments |
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
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>
|
Pushed 5775de2 to fix the test failure from the latest CI run:
All 66 |
| case BinaryExpression binaryExpr: | ||
| // Evaluate both operands before evaluating the binary operation | ||
| EvaluateAllSubExpressions(binaryExpr.Left, cache); | ||
| EvaluateAllSubExpressions(binaryExpr.Right, cache); | ||
| hasChildren = true; |
| // For ternary expressions, evaluate all three parts | ||
| EvaluateAllSubExpressions(conditionalExpr.Test, cache); | ||
| EvaluateAllSubExpressions(conditionalExpr.IfTrue, cache); | ||
| EvaluateAllSubExpressions(conditionalExpr.IfFalse, cache); |
| case UnaryExpression unaryExpr: | ||
| // Evaluate the operand before evaluating the unary operation | ||
| EvaluateAllSubExpressions(unaryExpr.Operand, cache); | ||
| hasChildren = true; |
| // 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. |
| 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>
|
@copilot resolve the merge conflicts in this pull request |
|
@copilot resolve the merge conflicts in this pull request |
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Resolved in merge commit |
The merge commit 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. |
Picks up new AssertThatFailedToEvaluate trans-unit from #8307 across all locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
…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>
…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>
Re-opens PR #6699 (which was closed without merging).
Fixes
Assert.Thatto not evaluate the lambda body twice for the assert and for the message. Concretely,Assert.That(() => box.GetValueWithSideEffect() != 0)used to invokeGetValueWithSideEffect()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
EvaluateExpression/EvaluateAllSubExpressionspair that walks the expression tree bottom-up, caches each sub-expression value, and letsExtractDetailsread from the cache instead of recompiling and re-invoking.AssertThatFailedToEvaluateresource (<Failed to evaluate>) for sub-expressions that the cache couldn't resolve.AssertTests.That.cs.Merge notes
This branch was rebased on top of latest
main(commit 6b0bae4). Resolved conflicts:Assert.That.cs: kept the newEvaluateExpression/ cache logic from this branch; adopted main's more efficientStringBuilder.Append(char, int)overload while preserving theMaxConsecutiveParenthesesnamed constant from this branch.FrameworkMessages.resx: keptAssertThatFailedToEvaluatefrom this branch alongside all the new RFC 012 summary entries from main;xlffiles regenerated viadotnet msbuild /t:UpdateXlf.[RequiresDynamicCode(...)]attributes with#if NET7_0_OR_GREATER(the attribute is not available onnetstandard2.0/net462targets 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