Skip to content

Add Assert.AreEquivalent for deep structural comparison#8266

Merged
Evangelink merged 8 commits into
mainfrom
evangelink/assert-areequivalent
May 16, 2026
Merged

Add Assert.AreEquivalent for deep structural comparison#8266
Evangelink merged 8 commits into
mainfrom
evangelink/assert-areequivalent

Conversation

@Evangelink
Copy link
Copy Markdown
Member

What

Adds Assert.AreEquivalent<T> and Assert.AreNotEquivalent<T> for deep structural equality comparison of object graphs, addressing the long-standing request in #4776.

// Pass: deep structural compare of two POCOs
Assert.AreEquivalent(new Order("o-1", new Address("street", "city")),
                     new Order("o-1", new Address("street", "city")));

// Fail with: Mismatch at 'ShippingAddress.City': values are not equal.
//            expected: "city"
//            actual:   "different"
Assert.AreEquivalent(new Order("o-1", new Address("street", "city")),
                     new Order("o-1", new Address("street", "different")));

Why

MSTest currently has:

  • Assert.AreEqual — reference / Equals / IEqualityComparer<T>-based, useless for POCOs with default object.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 had Assert.Equivalent for a while.

Design

API shape 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 (full list in the XML <remarks>):
    • Reference-equal values (and both null) are equivalent.
    • Primitive-like types (numerics, string, bool, char, enums, DateTime, DateTimeOffset, TimeSpan, Guid, Uri, Type, Version, plus DateOnly/TimeOnly/Half/Int128/UInt128 when present) → 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 intentionally ignored — recursion proceeds.
    • Dictionaries (IDictionary, IDictionary<,>, or IReadOnlyDictionary<,>) are compared by key set with values recursive. Lookups are routed through the source dictionary so its IEqualityComparer<TKey> is preserved (e.g., StringComparer.OrdinalIgnoreCase).
    • Other enumerables (excluding string) are compared element-by-element in iteration order. Element type is taken from the declared IEnumerable<T> so the IEquatable<T> shortcut still applies for nested values. (For order-insensitive comparison use CollectionAssert.AreEquivalent on the collection itself.)
    • Other reference types are compared by recursing into all public instance properties (with a public getter, no index parameters) and public instance fields, sorted by name for stable diagnostics.
    • Cycles + topology: tracked via persistent bidirectional ref-equality maps. Re-entering a known pair short-circuits as match. Re-entering with a different counterpart on either side fails as a topology mismatch.
    • User exceptions: thrown by IEquatable<T>.Equals, member getters, or dictionary access (TryGetValue/ContainsKey/enumeration) are caught and surfaced as a structured failure rather than escaping AssertFailedException.
  • 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 (e.g. Order.Items[2].Price or ["x-trace-id"]), and expected: / actual: evidence rendered through AssertionValueRenderer. AssertFailedException.ExpectedText/ActualText are populated for any mismatch that can be rendered as two values.

Naming considered

Assert.AreEquivalent was deliberately chosen over AreDeeplyEqual / AreStructurallyEqual to match xUnit and the wording in #4776, even though CollectionAssert.AreEquivalent already 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 of Assert.AreEqual.cs / Assert.AreSame.cs.
  • src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs — internal EquivalenceComparer (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 via dotnet msbuild ... /t:UpdateXlf. 8 new entries in PublicAPI.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, custom IReadOnlyDictionary<,>-only types); IEquatable<T> (regular, explicit interface implementation, value-type, throws); plain Equals override 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 broad AreNotEquivalent mirroring (collections, dictionaries, cycles, IEquatable, call-site expression).

Verification

  • dotnet build src/TestFramework/TestFramework -c Debug /p:TreatWarningsAsErrors=true — clean across netstandard2.0, net462, net8.0, net9.0.
  • dotnet build test/UnitTests/TestFramework.UnitTests -c Debug /p:TreatWarningsAsErrors=true — clean across all TFMs (incl. WinUI).
  • 62 AreEquivalent tests pass on net9.0 and net48.
  • Full TestFramework.UnitTests suite (954 tests) green.

Out of scope (follow-ups)

  • EquivalenceOptions bag (exclude paths, max-depth, ignore-collection-order, configure key/value rendering). Can be added without breaking changes.
  • Analyzer suggesting Assert.AreEquivalent over Assert.AreEqual when the latter is called on POCO types.

Closes #4776.

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>
Copilot AI review requested due to automatic review settings May 15, 2026 16:10
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.

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> and Assert.AreNotEquivalent<T> registered in PublicAPI.Unshipped.txt.
  • New internal EquivalenceComparer (recursive walker with reflection caches, dictionary views, and IEquatable<T> dispatch) plus 15 new localized strings (FrameworkMessages.resx + 13 XLF locales).
  • 62-test AssertTests.AreEquivalentTests partial 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

Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Copy link
Copy Markdown
Member Author

@Evangelink Evangelink left a comment

Choose a reason for hiding this comment

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

# Dimension Verdict
8 Defensive Coding at Boundaries 🟡 1 MAJOR

✅ 20/21 dimensions clean.

  • Defensive Coding — ToList doesn't catch user-thrown enumeration exceptions; raw exceptions escape the public API instead of becoming a structured AssertFailedException

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

Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs
Evangelink and others added 2 commits May 15, 2026 20:17
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 15, 2026 18:28
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: 18/18 changed files
  • Comments generated: 4

Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs Outdated
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Comment thread src/TestFramework/TestFramework/Resources/FrameworkMessages.resx
Comment thread src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs Outdated
Evangelink and others added 5 commits May 15, 2026 21:04
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>
Copilot AI review requested due to automatic review settings May 16, 2026 10:25
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: 18/18 changed files
  • Comments generated: 2

Comment on lines +5 to +8
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
Comment on lines +268 to +288
"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",
@Evangelink Evangelink merged commit 62079b6 into main May 16, 2026
21 checks passed
@Evangelink Evangelink deleted the evangelink/assert-areequivalent branch May 16, 2026 11:00
Evangelink pushed a commit that referenced this pull request May 16, 2026
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>
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.

Add Assert.AreEquivalent to perform deep comparison of objects

2 participants