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. - Throws an
InvalidOperationExceptionwhen a type has no members to compare, instead of succeeding without verifying anything. Either include the relevant members, compare the type by value, or exclude all members explicitly withIncludeMembers.None.
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 three 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)));
Use IgnoringFields or IgnoringProperties instead of Ignoring to restrict a predicate to one kind of member.
They take the same member path and type, and are never applied to collection elements, which are neither a field nor a
property:
await Expect.That(album).IsEquivalentTo(expected, o => o
.IgnoringProperties((memberPath, _) => memberPath.EndsWith("PlayCount")));
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
Trimming and Native AOT
Equivalency has to know the members of the compared types. Reflection provides them under the JIT, but publishing with trimming or Native AOT enabled removes members that are only reached reflectively, so a comparison would silently verify less than it claims to.
A source generator that ships with the aweXpect package closes this gap: for every call site that passes a value to
IsEquivalentTo, IsNotEquivalentTo, AreEquivalentTo or switches to Equivalent(), it registers the public
fields and properties of the argument's type, of the subject's type and of every type reachable through their
members. The registration runs when your assembly is loaded and needs no configuration. A type that has a
registration is compared through it, every other type is reflected over as before. A type without any comparable
member fails loudly instead of passing without verifying anything.
Some types cannot be seen by the generator, because it works from the types declared in your source:
- a member declared as
object, an interface or a base type only reveals the declared type; the instance it holds at runtime is compared through reflection, - a
private,protectedorfile-local type cannot be referenced by generated code and is compared through reflection, - a value that reaches the comparison through your own extension method is only registered if the extension's
parameter or type parameter carries
[RequiresMemberMetadata], - an anonymous type with a member holding a collection of anonymous types, other than an array, cannot be written as an instance and is compared through reflection; the element type itself is registered.
A type the generator merely did not see, such as the runtime type behind an object member or a value passed through
an unmarked extension, can be named explicitly to register it anyway:
using aweXpect.Core.Metadata;
[assembly: GenerateMetadata(typeof(Track))]
A type the generated code cannot reference stays on reflection regardless, and the generator warns with aweXpect2001
when a named type yields no registration.
The registration needs ModuleInitializerAttribute and C# 9, so nothing is generated for a project that targets
.NET Framework or .NET Standard 2.0 unless it polyfills the attribute. Those targets cannot be trimmed or published
with Native AOT and keep using reflection.
The walk follows every member type the comparison would visit, including framework types. A member of type
Exception, for example, registers the types reachable from its properties, because reflection would compare them
too. Members whose getter is marked with RequiresUnreferencedCode or RequiresDynamicCode cannot be registered, so
their type stays on the reflection path.
Two limits remain under trimming. Only public members are registered, so a comparison that requests
IncludeMembers.Internal or IncludeMembers.Private reflects over the whole type, and a trimmed member is left out
of the comparison. And a type the generator did not see whose members were all removed by the trimmer fails with an
error that names the type and asks you to root it.