Add Assert.AreEquivalent for deep structural comparison#8266
Conversation
Adds `Assert.AreEquivalent<T>` and `Assert.AreNotEquivalent<T>` for structural (deep) equality checks of object graphs, addressing the long-standing request in #4776. Design follows xUnit's `Assert.Equivalent` more than FluentAssertions' `BeEquivalentTo`, in line with MSTest's preference for a small, focused API surface that can grow without breaking changes: - 8 new public overloads (4 each for `AreEquivalent` and `AreNotEquivalent`), with optional `bool strict` and standard `CallerArgumentExpression` plumbing. No options bag for now — it can be layered on later as a pure addition. - Comparison rules: - Reference-equal values (and both null) are equivalent. - "Primitive-like" types (numerics, string, bool, char, enums, DateTime, DateTimeOffset, TimeSpan, Guid, Uri, Type, Version, DateOnly/TimeOnly/Half/Int128/UInt128 by name) → `Equals`. - Hybrid `IEquatable<T>` shortcut: when the *static* declared type implements `IEquatable<self>`, dispatch via the interface method (so explicit interface implementations work too). Plain `object.Equals` overrides are ignored. - Dictionaries (`IDictionary`, `IDictionary<,>`, or `IReadOnlyDictionary<,>`) are compared by key set with values compared recursively. Lookups are routed through the source dictionary so its `IEqualityComparer<TKey>` is preserved. - Other enumerables (excluding string) are compared element-by-element in iteration order (xUnit-compatible). Element type is taken from the declared `IEnumerable<T>` so the `IEquatable<T>` shortcut still applies for nested values. - Other reference types are compared by recursing into all public instance properties and public instance fields, sorted by name for stable diagnostics. - Reference cycles are tracked via persistent bidirectional ref-equality maps; the same mechanism enforces graph topology (the same expected reference can never be paired with two different actual references). - User-defined `IEquatable<T>.Equals`, member getters, and dictionary accesses (`TryGetValue`, `ContainsKey`, enumeration) that throw are caught and surfaced as a structured failure. - `strict: true` additionally rejects extra public members on `actual` (when runtime types differ) and any extra dictionary keys on `actual`. Failure messages use the existing structured assertion infrastructure (RFC 012): a top-line summary, a `Mismatch at '<dotted-path>': <reason>` locator, and `expected:` / `actual:` evidence with the values rendered through `AssertionValueRenderer`. `AssertFailedException.ExpectedText` and `ActualText` are populated for any mismatch that can be rendered as two values. Implementation lives in two files: - `Assert.AreEquivalent.cs` — public surface and structured-message reporting helpers. - `Assert.AreEquivalent.Comparer.cs` — `EquivalenceComparer` (the recursive walker), `MemberAccessor`/`MemberLookup` caches keyed by runtime type, `DictionaryView`/`GenericDictionaryView` abstractions that defer lookups to the source dictionary, `EquivalenceMismatch` carrying path + reason + rendered values. 15 new strings added to `FrameworkMessages.resx`; sibling XLFs are regenerated via `dotnet msbuild /t:UpdateXlf`. Tests use the in-repo `TestFramework.ForTestingMSTest.TestContainer` with AwesomeAssertions. 62 tests cover primitives/null/strings/dates/ enums/Guid, identical and different POCOs (with dotted-path failure locators), public fields, anonymous types, ignored private fields, collections (length/element/order), nested collections, dictionaries (equal, missing key, different value, extra key non-strict vs strict, non-string keys, case-insensitive comparer respected, custom `IReadOnlyDictionary<,>`-only types), `IEquatable<T>` (regular, explicit interface impl, value-type, throws), plain `Equals` override ignored, self-cycles and cross-cycles via property and field, topology mismatch detection, value-type topology skip, member-getter throws on expected and actual sides, dictionary-access throws, strict-mode extra member rendering (alphabetical), user message and call-site expression rendering, plus broad `AreNotEquivalent` mirroring. All tests pass on net9.0 and net48. Full TestFramework.UnitTests suite (954 tests) green. Builds clean under `/p:TreatWarningsAsErrors=true` across netstandard2.0, net462, net8.0, net9.0, and the WinUI test target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new Assert.AreEquivalent<T> / Assert.AreNotEquivalent<T> API to MSTest's Assert class for deep, structural object-graph comparison (closes #4776). The implementation mirrors xUnit's Assert.Equivalent, supports IEquatable<T> shortcut, dictionaries, enumerables, cycle/topology detection, and an opt-in strict: true mode that rejects extra members/keys on actual. Failure messages are surfaced through the existing structured-message infrastructure.
Changes:
- New public API: 4 overloads each of
Assert.AreEquivalent<T>andAssert.AreNotEquivalent<T>registered inPublicAPI.Unshipped.txt. - New internal
EquivalenceComparer(recursive walker with reflection caches, dictionary views, andIEquatable<T>dispatch) plus 15 new localized strings (FrameworkMessages.resx+ 13 XLF locales). - 62-test
AssertTests.AreEquivalentTestspartial covering primitives, POCOs, collections, dictionaries,IEquatable, cycles, topology, and strict mode.
Show a summary per file
| File | Description |
|---|---|
| src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs | Public API surface, structured failure-message reporting helpers. |
| src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs | Internal recursive comparer, member/dictionary/IEquatable caching, mismatch records. |
| src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt | Registers the 8 new public method overloads. |
| src/TestFramework/TestFramework/Resources/FrameworkMessages.resx | 17 new localizable strings for equivalence summaries and mismatch reasons. |
| src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.*.xlf (13 locales) | Auto-generated XLF entries (state="new") for the new strings. |
| test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs | 62 unit tests with TestContainer + AwesomeAssertions. |
Copilot's findings
- Files reviewed: 18/18 changed files
- Comments generated: 8
Evangelink
left a comment
There was a problem hiding this comment.
| # | Dimension | Verdict |
|---|---|---|
| 8 | Defensive Coding at Boundaries | 🟡 1 MAJOR |
✅ 20/21 dimensions clean.
- Defensive Coding —
ToListdoesn't catch user-thrown enumeration exceptions; raw exceptions escape the public API instead of becoming a structuredAssertFailedException
Overall: This is a well-designed, well-implemented feature. The implementation is thorough — reflection caching, cycle/topology detection, per-entry exception wrapping in dictionaries, IEquatable shortcut, proper PublicAPI.Unshipped.txt entries, localization via .resx only, 62 tests covering edge cases. The single finding is that IEnumerable enumeration exceptions are not caught while dictionary enumeration exceptions (via ForEachEntry) and member-getter exceptions (via CompareMembers) are — a straightforward oversight to fix.
Generated by Expert Code Review (on open) for issue #8266 · ● 18.3M
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…th RFC 012 - Use null-coalescing return in CompareEnumerables to satisfy IDE0046. - Use conditional throw in FailIfEnumeratedPastIndexEnumerable to satisfy IDE0046. - Rename evidence label 'notExpected:' to 'not expected:' to match RFC 012 convention used by other negated assertions (AreNotEqual, IsNotInstanceOfType). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…parer hardening
- Rephrase AreNotEquivalent summary as 'Expected values to be structurally different.'
to match RFC 012's negated-verb convention (mirroring AreNotEqual).
- Plumb evidence labels ('expected:'/'actual:' vs 'not expected:'/'actual:') through
ReportAssertEquivalenceMismatch so future comparison-failure factories that populate
expected/actual text won't leak the wrong label on the AreNotEquivalent path.
- Deduplicate shadowed members in GetMembers: prefer most-derived DeclaringType so
'new'-shadowed properties/fields are resolved deterministically regardless of
Reflection metadata ordering. Added regression tests.
- Exclude UnitTestAssertException (Assert.Fail/Inconclusive) from blanket exception
filters around member getters, enumeration, and dictionary ops so framework
exceptions raised by user code propagate instead of being rewritten as comparison
failures.
- Remove dead 't.IsPointer' branch from IsPrimitiveLike.
- Document the dead null guard in GenericDictionaryAccessors.Enumerate (kept for
defense against custom IEnumerable implementations yielding boxed null).
- Add comment explaining why non-generic IDictionary takes precedence over the
generic interfaces in TryCreateDictionaryView.
- Extend AreEquivalent XML <remarks> primitive-like list with DateOnly, TimeOnly,
Half, Int128, UInt128 (matches existing implementation on supporting TFMs).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- TIE catches in CompareEnumerables/CompareMembers/dictionary access now use a shared ThrowIfAssertException helper that unwraps and rethrows UnitTestAssertException (e.g., Assert.Fail) when a user-supplied member getter, enumerator, or equality operator throws one through reflection. Previously such framework exceptions were silently rewritten as MemberAccessFailure / DictionaryAccessFailure / EnumerationFailure mismatches. - Replace the misleading second shadowed-member test with a same-type 'new int' shadowing fixture that actually exercises the dedup logic: if the wrong accessor is chosen, both sides resolve to the base default and the derived mismatch is silently swallowed. - Add AreEquivalent_MemberGetterCallsAssertFail_PropagatesAsAssertFailedException regression test that pins the new ThrowIfAssertException behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r assertion A naive 'throw assertEx;' rewrites Exception.StackTrace with the rethrow site, hiding the user's actual Assert.Fail call inside the property getter / dictionary indexer / IEquatable.Equals. Capture-and-throw via ExceptionDispatchInfo keeps the original throw frame, which is the most actionable signal for diagnosing nested-assert failures. Extended the regression test to verify the stack trace contains the user fixture name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEquivalent<T>(T? expected, T? actual, bool strict, string? message = "", string! expectedExpression = "", string! actualExpression = "") -> void | ||
| static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEquivalent<T>(T? expected, T? actual, string? message = "", string! expectedExpression = "", string! actualExpression = "") -> void | ||
| static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreNotEquivalent<T>(T? notExpected, T? actual, bool strict, string? message = "", string! notExpectedExpression = "", string! actualExpression = "") -> void | ||
| static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreNotEquivalent<T>(T? notExpected, T? actual, string? message = "", string! notExpectedExpression = "", string! actualExpression = "") -> void |
| "Assert.AreEquivalent", | ||
| expectedExpression, | ||
| actualExpression, | ||
| "<expected>", | ||
| "<actual>", | ||
| "expected:", | ||
| "actual:"); | ||
| } | ||
|
|
||
| [DoesNotReturn] | ||
| private static void ReportAssertAreNotEquivalentComparisonFailed(EquivalenceMismatch mismatch, bool strict, string? userMessage, string notExpectedExpression, string actualExpression) | ||
| { | ||
| string summary = strict | ||
| ? FrameworkMessages.AreNotEquivalentComparisonFailedSummaryStrict | ||
| : FrameworkMessages.AreNotEquivalentComparisonFailedSummary; | ||
|
|
||
| ReportAssertEquivalenceMismatch( | ||
| mismatch, | ||
| summary, | ||
| userMessage, | ||
| "Assert.AreNotEquivalent", |
OneLocBuild commit 60e6270 removed the AreEquivalent translations from all FrameworkMessages.*.xlf files even though the corresponding entries were left in FrameworkMessages.resx (PR #8266). This puts every PR's CI build into an out-of-sync state. Regenerating with 'msbuild /t:UpdateXlf' restores the entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What
Adds
Assert.AreEquivalent<T>andAssert.AreNotEquivalent<T>for deep structural equality comparison of object graphs, addressing the long-standing request in #4776.Why
MSTest currently has:
Assert.AreEqual— reference /Equals/IEqualityComparer<T>-based, useless for POCOs with defaultobject.Equals.CollectionAssert.AreEqual— ordered element-by-element.CollectionAssert.AreEquivalent— set-equivalence.There's no way to assert that two arbitrary object graphs are structurally equal. Users have been reaching for FluentAssertions'
BeEquivalentTo, but its license recently changed to commercial. xUnit has hadAssert.Equivalentfor a while.Design
API shape follows xUnit's
Assert.Equivalentmore than FluentAssertions'BeEquivalentTo, in line with MSTest's preference for a small focused API surface that can grow without breaking changes:AreEquivalentandAreNotEquivalent), with optionalbool strictand standardCallerArgumentExpressionplumbing. No options bag for now — it can be layered on later as a pure addition.<remarks>):null) are equivalent.string,bool,char, enums,DateTime,DateTimeOffset,TimeSpan,Guid,Uri,Type,Version, plusDateOnly/TimeOnly/Half/Int128/UInt128when present) →Equals.IEquatable<T>shortcut: when the static declared type implementsIEquatable<self>, dispatch via the interface method (so explicit interface implementations work too). Plainobject.Equalsoverrides are intentionally ignored — recursion proceeds.IDictionary,IDictionary<,>, orIReadOnlyDictionary<,>) are compared by key set with values recursive. Lookups are routed through the source dictionary so itsIEqualityComparer<TKey>is preserved (e.g.,StringComparer.OrdinalIgnoreCase).string) are compared element-by-element in iteration order. Element type is taken from the declaredIEnumerable<T>so theIEquatable<T>shortcut still applies for nested values. (For order-insensitive comparison useCollectionAssert.AreEquivalenton the collection itself.)IEquatable<T>.Equals, member getters, or dictionary access (TryGetValue/ContainsKey/enumeration) are caught and surfaced as a structured failure rather than escapingAssertFailedException.strict: trueadditionally rejects extra public members onactual(when runtime types differ) and any extra dictionary keys onactual.Failure messages use the existing structured assertion infrastructure (RFC 012): a top-line summary, a
Mismatch at '<dotted-path>': <reason>locator (e.g.Order.Items[2].Priceor["x-trace-id"]), andexpected:/actual:evidence rendered throughAssertionValueRenderer.AssertFailedException.ExpectedText/ActualTextare populated for any mismatch that can be rendered as two values.Naming considered
Assert.AreEquivalentwas deliberately chosen overAreDeeplyEqual/AreStructurallyEqualto match xUnit and the wording in #4776, even thoughCollectionAssert.AreEquivalentalready means set-equivalence. The two APIs live on different types and accept different parameter types, so there's no compile-time conflict.Implementation
Two new files:
src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs— public surface, structured-message reporting helpers. Mirrors the style ofAssert.AreEqual.cs/Assert.AreSame.cs.src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs— internalEquivalenceComparer(recursive walker),MemberLookup(cached per type, with both sorted array and O(1) name-keyed dict),DictionaryView/NonGenericDictionaryView/GenericDictionaryView/GenericDictionaryAccessors(cached reflection that defers lookups to the source dictionary),EquivalenceMismatch(structured failure record).15 new strings added to
FrameworkMessages.resx; sibling XLFs regenerated viadotnet msbuild ... /t:UpdateXlf. 8 new entries inPublicAPI.Unshipped.txt.Tests
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs— 62 tests, TestContainer + AwesomeAssertions, covering: primitives/null/strings/dates/enums/Guid; identical and different POCOs (with dotted-path failure locators); public fields; anonymous types; ignored private fields; collections (length/element/order); nested collections; dictionaries (equal, missing key, different value, extra key non-strict vs strict, non-string keys, case-insensitive comparer respected, customIReadOnlyDictionary<,>-only types);IEquatable<T>(regular, explicit interface implementation, value-type, throws); plainEqualsoverride is ignored; self-cycles and cross-cycles via property and field; topology mismatch detection (shared-vs-distinct); value-type topology skip; member-getter throws on expected and actual sides; dictionary-access throws; strict-mode extra-member rendering (alphabetical); user message and call-site expression rendering; plus broadAreNotEquivalentmirroring (collections, dictionaries, cycles, IEquatable, call-site expression).Verification
dotnet build src/TestFramework/TestFramework -c Debug /p:TreatWarningsAsErrors=true— clean acrossnetstandard2.0,net462,net8.0,net9.0.dotnet build test/UnitTests/TestFramework.UnitTests -c Debug /p:TreatWarningsAsErrors=true— clean across all TFMs (incl. WinUI).AreEquivalenttests pass on net9.0 and net48.TestFramework.UnitTestssuite (954 tests) green.Out of scope (follow-ups)
EquivalenceOptionsbag (exclude paths, max-depth, ignore-collection-order, configure key/value rendering). Can be added without breaking changes.Assert.AreEquivalentoverAssert.AreEqualwhen the latter is called on POCO types.Closes #4776.