Skip to content

Report MSTest assertion expected/actual through a platform property - #10353

Merged
Amaury Levé (Evangelink) merged 5 commits into
mainfrom
dev/amauryleve/ubiquitous-journey
Jul 31, 2026
Merged

Report MSTest assertion expected/actual through a platform property#10353
Amaury Levé (Evangelink) merged 5 commits into
mainfrom
dev/amauryleve/ubiquitous-journey

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Summary

The terminal's Expected: / Actual: block and the assert.expected / assert.actual protocol fields have been dead code for MSTest. Both paths that build a failed TestNode replace the original assertion exception with a synthetic one carrying only a message and stack trace string (MSTestTestNodeException, VSTestException), and the only channel for the structured values was Exception.Data, which those synthetic exceptions do not have. So all five consumers read null and MSTest users never saw the diff block, even though the renderer was fully implemented.

Verified with an A/B run of samples/Playground on Assert.AreEqual(5, 2):

 Assert.AreEqual(5, 2) <- before: block absent
Expected <- after
5
Actual
2

Approach

Add an AssertionFailureProperty to Microsoft.Testing.Platform so the values are a first-class property instead of riding on Exception.Data:

  • Capture - MSTest's TestResult records the values as strings when TestFailureException is assigned, for the same reason ExceptionMessage / ExceptionStackTrace are recorded: the exception instance does not survive an AppDomain boundary and the adapter reports from these strings. The chain is walked because the adapter always wraps the assertion in TestFailedException, so the outermost exception is never the one holding the values.
  • Produce - MSTestTestNodeConverter publishes the property alongside the failed state.
  • Consume - the terminal, the MSBuild extension, the dotnet test pipe and both server-mode serializers prefer the property and fall back to Exception.Data for producers that have not been updated. The choice is all-or-nothing per node so the two halves of a diff always come from the same producer.

The wire format is deliberately unchanged: same protocol keys, same key ordering, same pipe field ids, so no protocol doc or golden-string churn. What the property adds is the ability to emit those keys when there is no exception at all, which Exception.Data structurally cannot express (relevant for FailedTestNodeStateProperty(string explanation), added in #2536 precisely so frameworks can fail without an exception).

Reporting policy worth a careful look

Values are reported only when the result contains exactly one assertion failure carrying a comparison:

Assertion failures carrying expected/actualBehaviour
0no property
1reported, regardless of how many other failures occurred
2+suppressed; per-assertion values remain in the failure message

Assert.Scope() throws a single AssertFailedException wrapping an AggregateException of every collected failure. Since assert.expected / assert.actual carry no label, two competing comparisons cannot both be reported and whichever survived would look authoritative next to a message saying "N assertion(s) failed". The count is accumulated across the whole result because TestFailureException can be assigned more than once (TestInitialize plus TestCleanup).

Failures that carry no comparison (Assert.Fail, or any non-assertion exception) deliberately do not count, so a cleanup Assert.Fail does not hide the body's diff while a cleanup throw would not. Note Assert.IsTrue / IsFalse do carry a comparison (true / false), so a scope mixing AreEqual and IsTrue is suppressed.

A list-shaped property that could carry N diffs properly is the better long-term answer, but it is a much larger public API surface and is out of scope here.

Notes for reviewers

  • The VSTest bridge path still loses the values; it would need a VSTest-side carrier to benefit.
  • Both serializers gained a property lookup, gated behind the O(1)SingleOrDefault<TestNodeStateProperty>() state check so discovery, in-progress and passed nodes never pay for a linked-list walk.
  • SerializerUtilities.TestNodeSerializers.cs picked up a UTF-8 BOM on line 1 (the file had none). That matches the repo's stated encoding policy for rewritten .cs files, but happy to drop it if the unrelated diff line is unwanted.

Validation

Full build clean. Unit tests: Microsoft.Testing.Platform.UnitTests 1940 on net9.0 and 1899 on net462 (covering both the System.Text.Json and Jsonite serializers), TestFramework.UnitTests 1502, MSTestAdapter.PlatformServices.UnitTests 1062, Microsoft.Testing.Extensions.UnitTests 848, MSTestAdapter.UnitTests 64, Microsoft.Testing.Platform.MSBuild.UnitTests 20.

Acceptance tests after -pack: OutputTests and SoftAssertionTests 13/13 across net462, net8.0 and net10.0, including new guards that the block is rendered for a single-failure scope and is not rendered for a multi-failure one.

The terminal's Expected/Actual block and the assert.expected / assert.actual
protocol fields were dead code for MSTest. Both production paths replace the
original assertion exception with a synthetic one carrying only a message and
stack trace string (MSTestTestNodeException, VSTestException), and the only
channel for the structured values was Exception.Data, which those synthetic
exceptions do not have. Every consumer therefore read null.
Add an AssertionFailureProperty to Microsoft.Testing.Platform that carries the
values as a first-class property instead of riding on Exception.Data. MSTest's
TestResult captures them as strings when TestFailureException is assigned, so
they survive an AppDomain boundary exactly like ExceptionMessage does, and the
adapter publishes the property alongside the failed state.
All five existing consumers prefer the property and fall back to Exception.Data
for producers that have not been updated. The choice is all-or-nothing so the
two halves of a diff always come from the same producer. The wire format is
unchanged: same protocol keys, same ordering, same pipe field ids.
Values are only reported when the result contains exactly one assertion failure
carrying a comparison. Assert.Scope throws a single AssertFailedException
wrapping an AggregateException of every collected failure, and assert.expected /
assert.actual carry no label, so two competing comparisons cannot both be
reported and the survivor would look authoritative. Failures with no comparison
(Assert.Fail, or any non-assertion exception) do not count, so a cleanup
Assert.Fail does not hide the body's diff.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01779869-b141-4244-aac9-d4f2c1da3a1a
CopilotAI review requested due to automatic review settings July 31, 2026 07:00

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

Pull request overview

Adds structured MSTest assertion values to MTP so reporters can display expected/actual diffs without relying on Exception.Data.

Changes:

  • Introduces and publishes AssertionFailureProperty.
  • Updates terminal, MSBuild, pipe, and JSON consumers.
  • Adds unit and acceptance coverage for assertion reporting.
Show a summary per file
FileDescription
test/UnitTests/TestFramework.UnitTests/TestResultTests.csTests assertion extraction and ambiguity rules.
test/UnitTests/MSTestAdapter.UnitTests/MSTestTestNodeConverterTests.csTests property publication by MSTest.
test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.csTests JSON assertion fields.
test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.csTests pipe consumer extraction.
test/UnitTests/Microsoft.Testing.Platform.UnitTests/Messages/AssertionFailurePropertyTests.csTests the new property API.
test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SoftAssertionTests.csVerifies scoped assertion output.
test/IntegrationTests/MSTest.Acceptance.IntegrationTests/OutputTests.csVerifies terminal diff rendering.
src/TestFramework/TestFramework/InternalAPI/InternalAPI.Unshipped.txtTracks new internal result members.
src/TestFramework/TestFramework/Attributes/TestMethod/TestResult.csCaptures unambiguous assertion values.
src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.csSerializes property values through Jsonite.
src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.TestNodeSerializer.csSerializes property values through System.Text.Json.
src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.csForwards values through the pipe protocol.
src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txtTracks the new public property.
src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.DataConsumption.csSupplies values to terminal reporting.
src/Platform/Microsoft.Testing.Platform/Messages/AssertionFailureProperty.csDefines the structured assertion property.
src/Platform/Microsoft.Testing.Extensions.MSBuild/MSBuildConsumer.csSupplies values to MSBuild reporting.
src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestNodeConverter.csPublishes captured MSTest values.

Review details

Suppressed comments (1)

src/TestFramework/TestFramework/Attributes/TestMethod/TestResult.cs:265

  • The depth cutoff can make a multi-comparison result look unambiguous. For example, if one aggregate branch contains a shallow comparison and another comparison remains beyond MaxDepth, the latter is ignored and this method returns 1, so the shallow pair is published as authoritative. Reaching the cutoff with an unvisited exception should conservatively mark the result ambiguous (or otherwise suppress the pair).
 exception = exception.InnerException;
depth++;
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment threadsrc/TestFramework/TestFramework/Attributes/TestMethod/TestResult.cs Outdated
CopilotAI review requested due to automatic review settings July 31, 2026 07:11
…assertion properties
Math.Min keeps the stored count matching its documented 'capped at 2'
invariant now that the field is serialized.
In the terminal walk a duplicate AssertionFailureProperty now suppresses the
diff instead of last-wins. That walk deliberately replaced SingleOrDefault
with a non-throwing single pass, so it drops the value rather than failing,
which also matches how MSTest suppresses competing comparisons at the source.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01779869-b141-4244-aac9-d4f2c1da3a1a

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

Review details

Suppressed comments (1)

src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.DataConsumption.cs:260

  • When duplicate assertion properties are encountered, the loop deliberately sets assertionFailure to null while leaving hasAssertionFailure true. These checks then fall back to Exception.Data, so a malformed node can still render a legacy diff instead of suppressing the ambiguous values. Gate the fallback on whether any assertion property was seen.
 expected: assertionFailure is not null ? assertionFailure.Expected : failedState.Exception?.Data["assert.expected"] as string,
actual: assertionFailure is not null ? assertionFailure.Actual : failedState.Exception?.Data["assert.actual"] as string,
  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@github-actionsgithub-actionsBot 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.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · sonnet46 150.2 AIC · ⌖ 4.04 AIC · ⊞ 11.8K ·

Serialize_FailedTestNodeWithAssertionFailureProperty_PrefersPropertyOverExceptionData
only checked that the property values were present, so an implementation
emitting both channels would have passed.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01779869-b141-4244-aac9-d4f2c1da3a1a
CopilotAI review requested due to automatic review settings July 31, 2026 07:31

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

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…Data
A duplicate AssertionFailureProperty resolves the local to null to suppress the
diff, but the consumption site tested that null and so fell through to the
legacy Exception.Data values - rendering exactly the arbitrary diff the
suppression exists to prevent. Gate on whether a property was seen instead.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01779869-b141-4244-aac9-d4f2c1da3a1a
CopilotAI review requested due to automatic review settings July 31, 2026 07:41
@Evangelink

Copy link
Copy Markdown
MemberAuthor

Picking up the suppressed comment from the latest review, since it found a real bug and has no thread to reply on:

When duplicate assertion properties are encountered, the loop deliberately sets assertionFailure to null while leaving hasAssertionFailure true. These checks then fall back to Exception.Data, so a malformed node can still render a legacy diff instead of suppressing the ambiguous values. Gate the fallback on whether any assertion property was seen.

Correct, and it defeated the suppression I added in c7a7d82: a duplicate resolved the local to null, and the consumption site tested that null, so it fell straight through to the legacy Exception.Data values - rendering exactly the arbitrary diff the suppression exists to prevent.

Fixed in 9b6c7ec by gating on whether a property was seen rather than on the resolved value:

expected:hasAssertionFailure?assertionFailure?.Expected:failedState.Exception?.Data["assert.expected"]asstring,

Only the terminal path needed this. The other four consumers reject duplicates outright via SingleOrDefault-equivalent checks, so they can never reach the suppressed-null state.

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

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 1
  • Review effort level: Balanced

…zation
TestResult is [Serializable] on .NET Framework, so newly added fields are
required during deserialization by default and a payload written by a build
that predates them would throw SerializationException. OptionalFieldAttribute
lets those payloads default to zero/null instead.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01779869-b141-4244-aac9-d4f2c1da3a1a
CopilotAI review requested due to automatic review settings July 31, 2026 07:51

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

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test quality grade — PR #10353

GradeTestMutationNotesHow to improve
A (90–100)new AssertionFailurePropertyTests.
Constructor_
AllowsEmptyStrings
2/2 killedVerifies empty strings are stored as-is without triggering the null-rejection guard.
A (90–100)new AssertionFailurePropertyTests.
Constructor_
WhenBothValuesAreNull_
Throws
1/1 killedException assertion is self-contained; removing the guard causes the test to fail.
A (90–100)new AssertionFailurePropertyTests.
Constructor_
WhenOnlyActualIsProvided_
KeepsExpectedNull
2/2 killedChecks both fields; swapping assignments or nulling either would fail an assertion.
A (90–100)new AssertionFailurePropertyTests.
Constructor_
WhenOnlyExpectedIsProvided_
KeepsActualNull
2/2 killedChecks both fields; swapping assignments or nulling either would fail an assertion.
A (90–100)new AssertionFailurePropertyTests.
Equals_
WhenDifferentValues_
ReturnsFalse
2/2 killedTwo distinct AreNotEqual calls cover removing either field comparison from Equals.
A (90–100)new AssertionFailurePropertyTests.
Equals_
WhenOtherIsNullOrDifferentType_
ReturnsFalse
2/2 killedNull guard and type guard each get their own assertion.
A (90–100)new AssertionFailurePropertyTests.
Equals_
WhenSameValues_
ReturnsTrue
3/3 killedAreEqual and GetHashCode consistency both verified; always-false Equals mutation caught.
A (90–100)new AssertionFailurePropertyTests.
ToStringIsCorrect
4/4 killedExact string equality catches field swaps, separator changes, and casing mutations.
A (90–100)new DotnetTestDataConsumerTests.
GetTestNodeDetails_
WhenAssertionFailurePropertyIsOneSided_
DoesNotSpliceInExceptionData
2/2 killedExpected value and null Actual both asserted; splicing legacy Actual would fail IsNull.
A (90–100)new DotnetTestDataConsumerTests.
GetTestNodeDetails_
WhenFailedWithAssertionFailureProperty_
PopulatesExpectedAndActual
3/3 killedState, Expected, and Actual all asserted; omitting property extraction fails all three.
A (90–100)new DotnetTestDataConsumerTests.
GetTestNodeDetails_
WhenFailedWithAssertionFailureProperty_
PrefersPropertyOverExceptionData
2/2 killedAreEqual on property values; returning legacy values ("legacy expected"/"legacy actual") fails both.
A (90–100)new FormatterUtilitiesTests.
Serialize_
FailedTestNodeWithAssertionFailureProperty_
EmitsAssertValues
4/4 killedExact JSON equality catches key renames, value swaps, and missing fields.
A (90–100)new FormatterUtilitiesTests.
Serialize_
FailedTestNodeWithAssertionFailureProperty_
PrefersPropertyOverExceptionData
3/3 killedProperty values asserted and DoesNotContain("legacy") proves the property won outright.
A (90–100)new FormatterUtilitiesTests.
Serialize_
OneSidedAssertionFailureProperty_
DoesNotSpliceInExceptionData
3/3 killedEmpty-string actual asserted; DoesNotContain("legacy") blocks any splice from Exception.Data.
A (90–100)new MSTestTestNodeConverterTests.
ToResultTestNode_
AddsAssertionFailureProperty_
WhenOnlyExpectedTextIsKnown
3/3 killedNotBeNull, Expected value, and null Actual all asserted; the ||&& guard mutation is caught because only one side is non-null.
A (90–100)new MSTestTestNodeConverterTests.
ToResultTestNode_
AddsAssertionFailureProperty_
WhenResultCarriesAssertionTexts
3/4 killedNotBeNull, Expected, and Actual asserted; ||&& mutation survives here (both non-null) but is caught by the sibling one-sided test.
A (90–100)new MSTestTestNodeConverterTests.
ToResultTestNode_
DoesNotAddAssertionFailureProperty_
WhenFailureIsNotAnAssertion
2/2 killedBeFalse directly verifies the guard suppresses the property for non-assertion failures.
A (90–100)mod OutputTests.
DetailedOutputIsAsExpected
3/3 killedExisting assertions plus new regex that specifically requires the Expected/Actual block to render.
A (90–100)mod SoftAssertionTests.
ScopeWithMultipleFailures_
TestFailsWithAggregatedMessage
2/2 killedDoesNotMatchRegex ensures the structured block is suppressed when multiple assertions fail.
A (90–100)mod SoftAssertionTests.
ScopeWithSingleFailure_
TestFails
3/3 killedExit code, failure details, and new structured Expected/Actual block all verified.
A (90–100)new TestResultTests.
SettingTestFailureExceptionAfterAMultiFailureScopeShouldNotCaptureAssertionTexts
2/2 killedSaturated count from scope blocks a subsequent single assertion from claiming the diff.
A (90–100)new TestResultTests.
SettingTestFailureExceptionAfterAValueLessAssertionShouldCaptureAssertionTexts
2/2 killedValueless first failure must not consume a comparison slot; values asserted after second set.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldCaptureAssertionTextsFromAggregateException
2/2 killedVerifies InnerExceptions walk on AggregateException; not walking would leave both null.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldCaptureAssertionTextsFromDirectAssertFailedException
2/2 killedDirect assignment path; not extracting either field causes the corresponding assertion to fail.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldCaptureAssertionTextsFromWrappedAssertFailedException
2/2 killedTwo-level wrapping; stopping at outermost exception leaves both values null.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldCaptureAssertionTextsWhenASecondAssertionCarriesNoValues
2/2 killedValueless second assertion must not increment the count; incorrectly counting it suppresses the diff.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldCaptureAssertionTextsWhenScopeReportsASingleFailure
2/2 killedSingle-exception direct path; both fields checked independently.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldNotCaptureAssertionTextsForNonAssertionFailure
2/2 killedNon-assertion exception must yield null texts; erroneous extraction fails both BeNull checks.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldNotCaptureAssertionTextsWhenScopeReportsMultipleFailures
2/2 killedTwo inner assertions with values; removing the count>1 suppression exposes both texts.
A (90–100)new TestResultTests.
SettingTestFailureExceptionShouldStopWalkingBeyondBoundedChainDepth
2/2 killed15-deep chain beyond MaxDepth=10; removing the depth bound or raising it past 15 finds the assertion.
A (90–100)new TestResultTests.
SettingTestFailureExceptionTwiceShouldKeepAssertionTextsWhenSecondFailureIsNotAnAssertion
2/2 killedNon-assertion second failure must not clear texts; erroneous clearing fails both Be assertions.
A (90–100)new TestResultTests.
SettingTestFailureExceptionTwiceWithTwoAssertionsShouldDropAmbiguousAssertionTexts
2/2 killedTwo distinct assertion failures; not accumulating the count leaves a surviving text pair exposed.

This advisory comment was generated automatically. Grades are heuristic and informational — they do not block merging. Re-run with /grade-tests.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · sonnet46 134.9 AIC · ⌖ 8.3 AIC · ⊞ 11.7K · [◷]( · )

@EvangelinkAmaury Levé (Evangelink) added the state/needs-review Awaiting review from the team. label Jul 31, 2026
@Evangelink
Amaury Levé (Evangelink) merged commit eab4f16 into mainJul 31, 2026
35 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the dev/amauryleve/ubiquitous-journey branch July 31, 2026 11:59
github-actionsBot added a commit that referenced this pull request Jul 31, 2026
Replace != null comparisons with 'is not null' pattern matching in
MSBuildConsumer.cs and SerializerUtilities.TestNodeSerializers.cs,
both newly added in PR #10353.
This aligns with the project coding standards enforced across the
Platform codebase.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Amaury Levé (Evangelink) added a commit that referenced this pull request Jul 31, 2026
…10362)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ed3dfd9e-f272-4b5a-b0fe-243680d50305
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

state/needs-reviewAwaiting review from the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Evangelink@0101