Equivalency
Describes how to verify that two objects are equivalent — that is, structurally equal — rather than referentially or strictly equal. Equivalency walks both objects recursively and compares them member by member.
Overview
Equality (IsEqualTo) delegates to object.Equals, which for most reference types means reference equality.
Equivalency instead compares the public state of two objects field by field and property by property, recursing into
nested objects and collections. Two objects are equivalent when every included member compares as equivalent.
class Album(string title)
{
public string Title { get; } = title;
}
Album subject = new("Abbey Road");
await Expect.That(subject).IsEquivalentTo(new Album("Abbey Road"));
await Expect.That(subject).IsNotEquivalentTo(new Album("Revolver"));
Where equivalency is available
Equivalency is exposed on three different surfaces.
Direct on objects
IsEquivalentTo and IsNotEquivalentTo are extension methods on any object. They accept an optional callback to
configure the comparison via EquivalencyOptions<TExpected>.
await Expect.That(album).IsEquivalentTo(expected);
await Expect.That(album).IsEquivalentTo(expected, o => o.IgnoringMember("PlayCount"));
await Expect.That(album).IsNotEquivalentTo(unexpected);
On collection elements
AreEquivalentTo checks every selected element of an IEnumerable<T> (or IAsyncEnumerable<T>) against a single
expected value, using the same equivalency comparison.
IEnumerable<Track> tracks = //...
Track expected = //...
await Expect.That(tracks).All().AreEquivalentTo(expected);
await Expect.That(tracks).AtLeast(2).AreEquivalentTo(expected, o => o.IgnoringMember("Title"));
As a modifier on equality assertions
For expectations that accept a custom equality comparer (IsEqualTo, Contains, StartsWith, EndsWith, HasItem,
All().AreEqualTo(...), …), append .Equivalent() to switch the comparison from Equals to structural equivalency.
await Expect.That(album).IsEqualTo(expected).Equivalent();
IEnumerable<Track> tracks = //...
await Expect.That(tracks).Contains(expected).Equivalent();
await Expect.That(tracks).StartsWith(expected).Equivalent();
await Expect.That(tracks).All().AreEqualTo(expected).Equivalent(o => o.IgnoringCollectionOrder());
Default behaviour
By default, equivalency:
- Compares public fields and public properties.
- Recurses into nested objects.
- Treats primitives,
enum,string,decimal,DateTime,DateTimeOffset,TimeSpanandGuidas value types and compares them withEquals. Everything else is compared by members. - Respects collection order when comparing
IEnumerable<T>. - Detects cyclic references so two graphs that reference themselves do not cause infinite recursion.
- Honours
IEqualityComparerif either side implements it — that comparer wins over the structural walk.
Configuration
All equivalency overloads accept an options callback that receives an EquivalencyOptions (or
EquivalencyOptions<TExpected>) record. The fluent methods are chainable.
await Expect.That(album).IsEquivalentTo(expected, o => o
.IncludingFields(IncludeMembers.Public | IncludeMembers.Internal)
.IgnoringMember("PlayCount")
.IgnoringCollectionOrder());
Ignoring members by name
await Expect.That(album).IsEquivalentTo(expected, o => o.IgnoringMember("PlayCount"));
The match is case-insensitive. For nested members, the path is dot-separated (e.g. "Artist.Name"); for collection
elements, the index is bracketed (e.g. "Tracks[3]").
Ignoring members by predicate
There are four overloads of Ignoring, depending on which information you need:
// by member path and type
await Expect.That(album).IsEquivalentTo(expected, o => o
.Ignoring((memberPath, memberType)
=> memberPath.EndsWith("PlayCount") && memberType == typeof(int)));
// by member path only
await Expect.That(album).IsEquivalentTo(expected, o => o
.Ignoring(memberPath => memberPath == "Artist.Name"));
// by type only
await Expect.That(album).IsEquivalentTo(expected, o => o
.Ignoring(memberType => memberType == typeof(DateTime)));
// by member path, type and reflected MemberInfo
await Expect.That(album).IsEquivalentTo(expected, o => o
.Ignoring((memberPath, _, memberInfo)
=> memberPath.EndsWith("PlayCount") && memberInfo is PropertyInfo));
Including fields and properties
You can change which fields and properties participate in the comparison. Both methods accept an IncludeMembers flags
enum with the values None, Public, Internal and Private.
await Expect.That(album).IsEquivalentTo(expected, o => o
.IncludingFields(IncludeMembers.None) // exclude all fields
.IncludingProperties(IncludeMembers.Public | IncludeMembers.Private));
Default for both is IncludeMembers.Public.
Ignoring collection order
When comparing collections, order matters by default. To disable that:
int[] subject = [1, 2, 3];
int[] expected = [3, 2, 1];
await Expect.That(subject).IsEquivalentTo(expected, o => o.IgnoringCollectionOrder());
Pass false to re-enable ordered comparison if it was disabled globally.
Per-type options with For<T>
You can apply options to a specific member type only. Type-specific options override the top-level options for members of that type.
await Expect.That(album).IsEquivalentTo(expected, o => o
.For<Artist>(x => x.IgnoringMember("BornOn"))
.For<List<Track>>(x => x.IgnoringCollectionOrder()));
Unlike the other fluent methods, For<T> mutates CustomOptions on the options it was called on rather than returning
a copy. That is fine inside a single callback, but means an EquivalencyOptions instance you have already configured
with For<T> should not be reused across separate assertions.
Comparing by value or by members
Each type can be compared either by value (Equals) or by walking its members. The default is determined by the type
itself (see Default behaviour). To override for a specific type:
await Expect.That(album).IsEquivalentTo(expected, o => o
.For<TrackId>(x => x with { ComparisonType = EquivalencyComparisonType.ByValue }));
To change the global rule, replace the DefaultComparisonTypeSelector:
await Expect.That(album).IsEquivalentTo(expected, o => o with
{
DefaultComparisonTypeSelector = type => type == typeof(TrackId)
? EquivalencyComparisonType.ByValue
: EquivalencyDefaults.DefaultComparisonType(type),
});
Customizing the global defaults
You can change the default EquivalencyOptions that are used when no callback is provided, via the
customization API:
using IDisposable scope = Customize.aweXpect.Equivalency().DefaultEquivalencyOptions
.Set(new EquivalencyOptions().IgnoringCollectionOrder());
// All equivalency checks within this scope ignore collection order by default.
Per-property expectations with It.Is<T>()
Equivalency lets you compare against an anonymous expectation object in which individual members assert their own
expectations via It.Is<T>(). Think of it as a playlist filter: each property carries its own criterion rather than a
concrete value.
class Track
{
public string? Title { get; set; }
public int PlayCount { get; set; }
}
Track midnight = new()
{
Title = "Midnight Echo",
PlayCount = 42,
};
await Expect.That(midnight).IsEquivalentTo(new
{
Title = It.Is<string>().That.IsNotEmpty(),
PlayCount = It.Is<int>().That.IsGreaterThan(2),
});
It.Is<T>() (without .That) only asserts that the property has the given type.
Note: because the type cannot be inferred from null, an It.Is<T>().That.IsNull() check still works, but
It.Is<T>().That.IsNotNull() requires the property to be non-null.
Failure messages
Failure messages list each differing member with its full path and the configured options used for the comparison.
For a structural mismatch:
Expected that album
is equivalent to Album {
Title = "Abbey Road",
Artist = Artist { Name = "The Beatles" }
},
but it was not:
Property Artist.Name differed:
Found: "Wings"
Expected: "The Beatles"
Equivalency options:
- include public fields and properties
When the playlist-filter pattern with It.Is<T>() fails, the failure renders each member's expectation inline:
Expected that midnight
is equivalent to { Title = is string that is not empty, PlayCount = is int that is greater than 2 },
but it was not:
Property PlayCount was 1
Equivalency options:
- include public fields and properties