Fix Assert.That: single-pass evaluation (#6690) and consistent method-call display (#6691)#8306
Conversation
…splay Fixes two related issues in MSTest's Assert.That(Expression<Func<bool>>): * Issue #6690: the expression was evaluated twice -- once to determine the boolean result and once per sub-expression for the failure details -- producing misleading values for stateful/side-effecting calls. Replaced the two-pass eval-then-walk approach with a single ExpressionVisitor-based rewriter that records each captured sub-expression's value as a side effect of the single root evaluation. Pure operand reads (variables/properties, array indexers, get_Item/Get) are re-evaluated lazily when short-circuited so the failure message stays informative; arbitrary method calls are intentionally NOT re-evaluated to preserve short-circuit semantics for side-effecting code. * Issue #6691: method-call display names were inconsistent (static methods missed the type prefix; instance methods on \ his\ showed the full namespace). Replaced the ToString-based path with GetMethodCallDisplayName, which builds the name directly from the MethodCallExpression so static methods show \TypeName.Method(...)\, instance methods on captured \ his\`n show \ his.Method(...)\, extension methods show \ eceiver.Method(rest)\, and other instance methods show \object.Method(args)\. Adds 11 new unit tests covering both issues plus regression guards for short-circuit semantics, inherited-method receiver disambiguation, and all four method-call display permutations from #6691. All 750 AssertTests pass and build.cmd succeeds with 0 warnings, 0 errors across netstandard2.0, net462, net8.0, and net9.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR updates Assert.That(Expression<Func<bool>>) to capture failure details during the assertion’s single evaluation and improves method-call display names in assertion messages.
Changes:
- Reworks expression analysis/capture to avoid re-evaluating side-effecting sub-expressions.
- Adds method-call display-name handling for static, instance, and extension calls.
- Adds regression tests for side effects, short-circuiting, and method-call rendering.
Show a summary per file
| File | Description |
|---|---|
src/TestFramework/TestFramework/Assertions/Assert.That.cs |
Implements single-pass capture and method-call display helpers. |
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs |
Adds regression tests for issues #6690 and #6691. |
Copilot's findings
Comments suppressed due to low confidence (1)
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs:1156
- This test also swallows the expected failure without asserting that it happened. If the assertion unexpectedly passes, the test can still pass with CallCount equal to 0, so it does not guard the failing short-circuit scenario it is intended to cover.
try
{
act();
}
catch (AssertFailedException)
{
- Files reviewed: 2/2 changed files
- Comments generated: 2
Evangelink
left a comment
There was a problem hiding this comment.
Review Summary
The fix for #6690 (double evaluation) and #6691 (inconsistent display names) is architecturally sound. The single-pass CaptureRewriter/AnalysisContext design is the right approach to guarantee expressions are evaluated exactly once while still capturing values for the failure message. The IsSafeToReevaluate heuristic is well-documented, the IsCapturedThis exact-type check is correct (and guarded by a regression test), and the #if !NET ReferenceEqualityComparer polyfill compiles correctly on all TFMs because IEqualityComparer<T> is contravariant (in T). The 11 new tests cover the key scenarios.
Findings
| # | Severity | Location | Summary |
|---|---|---|---|
| 1 | MAJOR | line 124 | lambda.Compile() (instrumented, heavier lambda) is now called on every Assert.That() invocation, including passing ones — the old fast path compiled a simpler Func<bool>. Worth a comment at minimum; consider caching per expression-tree site if it becomes a bottleneck. |
| 2 | MINOR | line 155 | Short-circuit fallback still uses Expression.Lambda(expr).Compile().DynamicInvoke() — a typed Func<object?> compile avoids reflection boxing. |
| 3 | MINOR | line 380 | DeclaringType?.Name returns only the innermost class name for nested types; CleanTypeName(dt.FullName ?? dt.Name) would give richer context with no test regressions. |
No public API is added. Localization is not affected. Thread safety, null safety, and cross-TFM compatibility look correct.
Generated by Expert Code Review (on open) for issue #8306 · ● 32.3M
|
@copilot address review comments |
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| MemberExpression => true, | ||
| BinaryExpression { NodeType: ExpressionType.ArrayIndex } => true, | ||
| UnaryExpression { NodeType: ExpressionType.ArrayLength } => true, | ||
| // Indexer-style method calls are conventionally pure reads; the previous implementation | ||
| // evaluated them eagerly too. Keep this limited to actual indexers and multidimensional | ||
| // array reads so arbitrary user-defined `Get(...)` methods are not re-invoked. | ||
| MethodCallExpression { Method.Name: "get_Item" } => true, | ||
| MethodCallExpression { Method.Name: "Get", Method.DeclaringType: { } declaringType } when declaringType == typeof(Array) => true, |
| return $"{typeName}.{methodName}({argsStr})"; | ||
| } | ||
|
|
||
| if (IsCapturedThis(callExpr.Object, callExpr.Method.DeclaringType)) |
| else if (callExpr.Method.Name == "Get" && callExpr.Object is not null && callExpr.Arguments.Count > 0) | ||
| { | ||
| string objectName = GetCleanMemberName(callExpr.Object); | ||
| string indexDisplay = string.Join(", ", callExpr.Arguments.Select(GetIndexArgumentDisplay)); | ||
| string indexerDisplay = $"{objectName}[{indexDisplay}]"; | ||
| TryAddExpressionValue(callExpr, indexerDisplay, details); | ||
| context.AddCapture(callExpr, indexerDisplay); |
|
@copilot address review comments |
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Done in caea834. I addressed the review comments by tightening safe fallback reevaluation to recursively validate child expressions, limiting |
Two test failures surfaced on CI for the latest Assert.That changes: 1. `IsArrayGetMethod` only recognized `System.Array.Get` — multidimensional array indexers (`int[,]`) expose runtime-synthesized `Get` methods declared on the array type itself, so the indexer-style display path was bypassed and the failure message rendered `matrix.Get(row, col)` instead of `matrix[row, col]`. Switched the check to look at `Object.Type.IsArray`. 2. `CompilerGeneratedDisplayClassRegex` did not cross nested-type `+` boundaries, so captured locals inside a nested helper class (e.g. `InheritedMethodProbe` inside `AssertTests`) were rendered as `AssertTests+expected` instead of just `expected`. Allowed the regex to walk the full `A+B+<>c__DisplayClass...` prefix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| case BinaryExpression binaryExpr: | ||
| ExtractVariablesFromExpression(binaryExpr.Left, details, suppressIntermediateValues); | ||
| ExtractVariablesFromExpression(binaryExpr.Right, details, suppressIntermediateValues); | ||
| AnalyzeExpression(binaryExpr.Left, context, suppressIntermediateValues); | ||
| AnalyzeExpression(binaryExpr.Right, context, suppressIntermediateValues); |
| } | ||
|
|
||
| break; | ||
|
|
| && callExpr.Method.IsDefined(typeof(ExtensionAttribute), inherit: false) | ||
| && callExpr.Arguments.Count > 0) | ||
| { | ||
| string receiver = GetCleanMemberName(callExpr.Arguments[0]); |
|
Pushed 9a09645 to fix the two test failures from the latest CI run:
All 77 The MacOS Debug failure on the previous run was a |
|
@copilot address review comments |
… extension-method this detection Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Done in the latest commit. The three review findings are addressed:
|
The test type intentionally exposes a public field so Expression.Assign can target it (Expression.Field requires FieldInfo). Suppress SA1401 around the field rather than reshape the type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| // Assignment-style binary nodes (=, +=, -=, …) require writable left-hand operands. | ||
| // CaptureRewriter would replace the LHS with a Block, making it non-writable and | ||
| // causing the rewritten lambda to fail to compile. Skip these entirely. | ||
| case BinaryExpression binaryExpr when IsAssignmentNodeType(binaryExpr.NodeType): | ||
| break; |
There was a problem hiding this comment.
Addressed in #8352 (follow-up to the merged PR). AnalyzeWritableOperand now recurses into the readable sub-expressions of an assignment LHS (member receiver, index target and arguments) and the RHS is analyzed normally, while the writable storage location itself is still left uncaptured to keep the LHS assignable after CaptureRewriter runs.
| // Unary update nodes (++x, x++, --x, x--) require writable operands. CaptureRewriter | ||
| // would replace the operand with a Block, making it non-writable and causing compilation | ||
| // of the rewritten lambda to fail. Skip these entirely. | ||
| case UnaryExpression unaryExpr when IsUnaryUpdateNodeType(unaryExpr.NodeType): | ||
| break; |
There was a problem hiding this comment.
Addressed in #8352 (follow-up). The same AnalyzeWritableOperand helper now recurses into the readable receiver chain of a unary-update operand (e.g., the container.Inner part of --container.Inner.Value) so failure details still surface those values, while the writable storage location is left uncaptured.
| // Skip Func and Action delegates as they don't provide useful information in assertion failures. | ||
| // Use the static type so we don't have to evaluate the expression at analysis time. | ||
| if (IsFuncOrActionType(memberExpr.Type)) | ||
| { | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Addressed in #8352 (follow-up). The static-type Func/Action skip in AnalyzeMemberExpression is removed; filtering is now applied uniformly at detail-build time using the runtime value's type, restoring the historical behavior where a null delegate-typed member still surfaces as null in details. A regression test (That_NullDelegateTypedMember_StillAppearsInDetails) locks this in.
| // Compile and invoke ONCE. | ||
| // This intentionally pays the rewrite+compile cost on every Assert.That call (including passing ones) | ||
| // to guarantee single-pass evaluation for correctness (#6690). If this ever shows up as a hotspot, | ||
| // we can consider caching the compiled delegate by expression-tree instance. | ||
| var lambda = Expression.Lambda<Func<object?[], bool>>(rewrittenBody, arrayParam); |
There was a problem hiding this comment.
Followed up in #8352 with a more precise trade-off comment explaining why reference-keyed plan caching would not help the common inline Assert.That(() => ...) pattern (each call constructs a fresh Expression<Func<bool>> instance), while leaving the door open to add caching later if a workload that re-uses expression trees shows measurable overhead. No caching is implemented.
…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>
Fixes #6690 and #6691.
Summary
Two related bugs in
Assert.That(Expression<Func<bool>>)produced misleading failure messages. Both originate insrc/TestFramework/TestFramework/Assertions/Assert.That.cs. Since the fixes overlap heavily on the sameMethodCallExpressionhandling logic, they are addressed in a single PR.Issue #6690 — Expression evaluated twice
The previous implementation called
condition.Compile()()to check the boolean result, then walked the expression tree and calledExpression.Lambda(subExpr).Compile().DynamicInvoke()for each interesting sub-expression to build the failure details. For stateful operands the reported value didn't match the value actually compared:Fix
Replaced the two-pass eval-then-walk approach with a single
ExpressionVisitor-based rewriter:AnalyzeExpressionwalks the tree once to identify capture points and their display names.CaptureRewriterrewrites each captured sub-expressioneat slotitoBlock { var t = e; captures[i] = (object)t; t }so each captured value is recorded as a side effect of the single root evaluation.UnsetCapturedistinguishes "not evaluated" (short-circuited / unreached) from "captured null".get_Item/Getindexers — seeIsSafeToReevaluate) are re-evaluated lazily so the failure message stays informative for short-circuited cases likename == "x" && obj.Property == y. Arbitrary method calls are not re-evaluated, preserving short-circuit semantics for the user's side-effecting code (the motivating case for Assert.That evaluates expression twice #6690).Issue #6691 — Inconsistent method-call display names
callExpr.ToString()+ regex cleaning produced inconsistent names:GetAnimal2()Test1.GetAnimal2()this, same typeNamespace.Test1.GetAnimal()this.GetAnimal()zoo.GetAnimal()zoo.GetAnimal()✓GetAnimal2()Zoo.GetAnimal2()Fix
New
GetMethodCallDisplayNameconstructs display names directly from theMethodCallExpressionstructure:receiver.Method(rest)TypeName.Method(args)thisinstance methods →this.Method(args)(detected byIsCapturedThis: aMemberExpressionwhose field is named<>...__this, or aConstantExpressionwhose runtime type exactly matches the declaring type — exact-type rather thanIsInstanceOfTypeavoids mislabeling a base-typed local asthiswhen the asserted method is inherited from a base)objectDisplay.Method(args)Tests
11 new tests added in
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs:That_ExpressionWithSideEffect_EvaluatesOnlyOnce— explicit call-count checkThat_ExpressionWithSideEffect_FailureMessageReflectsFirstEvaluationThat_ExpressionWithSideEffect_AppearingTwice_EvaluatesEachOccurrenceOnceThat_ShortCircuitedExpression_StillReportsBothOperandsThat_ShortCircuitedExpression_DoesNotEvaluateUnreachedSideEffect— locks down that short-circuitedCounter.GetNumber()is NOT re-run by the fallback pathThat_ShortCircuitedPropertyAccess_IsReevaluatedForReporting— locks down the documentedIsSafeToReevaluatetrade-off for propertiesThat_InstanceMethodOnSameType_RendersAsThisThat_StaticMethodOnSameType_RendersWithTypeNameThat_InstanceMethodOnOtherType_RendersWithObjectNameThat_StaticMethodOnOtherType_RendersWithTypeNameThat_InstanceMethodOnLocalOfBaseType_DoesNotRenderAsThis— regression guard for theIsCapturedThisexact-type checkVerification
AssertTestspass (61 existing + 11 new + other Assert categories).build.cmdsucceeds with 0 warnings, 0 errors acrossnetstandard2.0,net462,net8.0,net9.0.<>4__thisdetection,ConstantExpression-as-this, inherited-method receivers, property/indexer fallback, short-circuit semantics) were reviewed by an expert-reviewer pass and all findings were addressed.Notes
AssertExtensions.ReferenceEqualityComparerpolyfill is added (#if !NETgated) becauseSystem.Collections.Generic.ReferenceEqualityCompareris .NET 5+ only.Expression.Compile()/DynamicInvoke(), butAssert.Thatalready required these on the previous code path so this PR neither adds nor removes that concern.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com