Skip to main content

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.

ExpectationNegatedSummary
IsEquivalentToIsNotEquivalentTostructurally equal to the expected object
AreEquivalentToa quantifier such as None()the selected items are equivalent to the expected value
Equivalent()switches an equality expectation to equivalency

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.

When you publish with trimming or Native AOT, see Native AOT and trimming.

On objects​

IsEquivalentTo and IsNotEquivalentTo are extension methods on any object. They accept an optional callback to configure the comparison via EquivalencyOptions<TExpected>:

using aweXpect.Equivalency; // for the options, e.g. `IgnoringMember`

await Expect.That(album).IsEquivalentTo(expected);
await Expect.That(album).IsEquivalentTo(expected, o => o.IgnoringMember("PlayCount"));
await Expect.That(album).IsNotEquivalentTo(unexpected);

On collection items​

AreEquivalentTo checks every selected item 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 of equality​

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 = //...
Track expectedTrack = //...
await Expect.That(tracks).Contains(expectedTrack).Equivalent();
await Expect.That(tracks).StartsWith(expectedTrack).Equivalent();
await Expect.That(tracks).All().AreEqualTo(expectedTrack).Equivalent(o => o.IgnoringCollectionOrder());

Default behaviour​

Equivalency takes the public fields and properties of the expected object and compares each one with the member of the same name on the actual object, recursing into nested objects. How a value is compared depends on its type:

TypeCompared
primitives, enum, string, decimal, DateTime, DateTimeOffset, TimeSpan, Guid, BigInteger, Complex, Half, NFloat, Int128, UInt128by value, with Equals
MemberInfo (and therefore Type), Assembly, Module, Delegate, Uri, CultureInfo and anything derived from themby value, with Equals
StringBuilderby its text, so it also matches a string
collections (IEnumerable<T>)item by item, in order
sets (ISet<T>, IReadOnlySet<T>)item by item, without an order
dictionaries (IDictionary, IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue>)entry by entry, by key
everything elseby its members, recursively

Members​

  • Only the members of the expected object are compared; additional members of the actual object are ignored.
  • A member that the actual object doesn't have is reported as missing instead of being compared against null.
  • A field and a property of the same name match each other, so a class with public fields can be compared against an anonymous object, which only has properties. The same kind is preferred, and only included kinds are considered: with IncludingFields(IncludeMembers.None) an expected property no longer matches a field.
Explicitly implemented interface properties

An expected member that the actual object doesn't have is also matched against a property that the actual type implements explicitly for an interface (int IHasId.Id => 1;), by its short name. A public field or property of that name always takes precedence, and a name that the actual type implements explicitly for more than one interface is reported as ambiguous. Failures name the kind of the expected member.

Values and objects​

  • As soon as either side is compared by value, both are: a string is only equivalent to an equal string, and swapping subject and expectation doesn't change the result.
  • A type that is compared by members ignores its own Equals, also when it implements IEqualityComparer, so an Equals can neither hide differing members nor reject matching ones. To let Equals decide, compare the type by value; to check a member against your own criterion, use It.Is<T>().

Collections and dictionaries​

  • A set on either side is enough to match the items without an order, exactly like ignoring collection order, so a HashSet<T> can be compared against an array.
  • A dictionary reports a differing, missing or superfluous entry under its key. Each expected key is looked up through the actual dictionary, so its key comparer decides which keys are the same, as it does for IsEqualTo. Two expected keys that this comparer considers the same can't both be matched by one entry, so the second one is reported as lacking a distinct key.
How the key comparer is found

The comparer is read from the Comparer or KeyComparer property of the dictionary (or of the dictionary that a ReadOnlyDictionary<TKey, TValue> wraps), which needs reflection. For a dictionary without such a property, or when reflection is unavailable (by default when publishing with Native AOT), the matched keys are told apart by their own Equals, so two such keys are only noticed when the entry counts differ, and a type that only implements IReadOnlyDictionary<TKey, TValue> or IDictionary<TKey, TValue> looks its keys up by their own Equals.

Safeguards​

  • Cyclic references are detected, so graphs that reference themselves don't recurse forever. An instance that is referenced more than once is still compared against each of its expected counterparts.
  • The comparison fails at a recursion depth of 100 nested objects instead of overflowing the stack, see Limiting the recursion depth.
  • A type without any members to compare throws an InvalidOperationException instead of succeeding without verifying anything. Include the relevant members, compare the type by value, or exclude all members explicitly with IncludeMembers.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());
OptionEffect
IgnoringMemberignores members by name
Ignoring, IgnoringFields, IgnoringPropertiesignores members by path and type
IncludingFields, IncludingPropertieschooses which fields and properties are compared
IgnoringCollectionOrdermatches collection items without an order
For<T>applies options to members of type T only
ComparisonTypecompares a type by value or by its members
LimitingRecursionDepthchanges the maximum recursion depth of 100

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]").

The name must cover whole segments at the end of the member path, so "Name" ignores every member called Name at any depth, while "ame" or "t.Name" ignore nothing.

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 and Internal:

await Expect.That(album).IsEquivalentTo(expected, o => o
.IncludingFields(IncludeMembers.None) // exclude all fields
.IncludingProperties(IncludeMembers.Public | IncludeMembers.Internal));

Default for both is IncludeMembers.Public. IncludeMembers.Internal also includes protected internal members, because the whole assembly can access them. Other protected and private members are never compared, because they are implementation details of a type. To compare such a type, let its Equals decide by comparing it by value.

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.

The elements do not have to be comparable: each expected element is matched against an element that is equivalent to it, and every element can be matched only once, so [1, 1, 2] is not equivalent to [1, 2, 2]. When no such matching covers both collections, only the elements that were left over are reported, each against the leftover element it differs from the least.

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. They also apply to a member whose runtime type derives from T, because an instance of an abstract type is always an instance of a derived type, and the runtime type of a Type member is the internal RuntimeType rather than Type itself. When several registrations match, the most derived one wins:

await Expect.That(album).IsEquivalentTo(expected, o => o
.For<Artist>(x => x.IgnoringMember("BornOn"))
.For<List<Track>>(x => x.IgnoringCollectionOrder()));

Like the other fluent methods, For<T> returns a copy and leaves the options it was called on unchanged. The callback is applied to the final options of the expectation, so every other option applies to T as well, no matter whether it is set before or after For<T>. A registration in the callback of a single expectation replaces one for the same type in the customized default.

When the subject and the expectation have different types and both are registered, the registration for the type of the expectation wins, because the members that are compared come from the expectation. An extension can read the options that apply to a type with GetOptionsFor(type).

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). By value, Equals decides alone and in both directions; by members, Equals is ignored and only the members count. Comparing by value is therefore how you ask for the equality a type defines for itself (a value object that compares only its Id, for example). One side being compared by value is enough; when it is only the expectation, the expectation's Equals decides, since the subject is compared by members, which ignores its Equals. To override for a specific type:

await Expect.That(album).IsEquivalentTo(expected, o => o
.For<TrackId>(x => x with { ComparisonType = EquivalencyComparisonType.ByValue }));

Unlike the other type-specific options, the comparison type applies to the type itself and not to its members: a member without a registration of its own falls back to the comparison type of the top-level options or, if none is set, to the DefaultComparisonTypeSelector, so a string member of a type compared by members is still compared by value.

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),
});

Limiting the recursion depth​

Equivalency walks nested objects recursively, so a graph that is deep enough would overflow the stack and take the whole test process with it. The comparison therefore stops after 100 nested objects on a single path and reports the member path at which the limit was hit (shown here with the limit lowered to 3):

Failure message
Expected that subject
is equivalent to expected,
but it was not:
Property Next.Next.Next exceeded the maximum recursion depth of 3

Equivalency options:
- include public fields and properties
- limit the recursion depth to 3

The depth is counted per path, so two members on the same level are both at the same depth, and members that are compared by value do not add to it. A cyclic reference is caught by the cycle detection and never reaches the limit.

Use LimitingRecursionDepth to raise or lower the limit:

await Expect.That(album).IsEquivalentTo(expected, o => o.LimitingRecursionDepth(500));

It is equivalent to setting MaxRecursionDepth directly. A depth below one, which could not even compare the root, throws an ArgumentOutOfRangeException.

A limit other than the default is listed in the failure message under Equivalency options:.

Customizing the global defaults​

You can change the default EquivalencyOptions via the configuration. Every equivalency expectation starts from them: they are used as they are when no callback is provided, and a callback receives them as its starting point:

using aweXpect.Customization;

using IDisposable scope = Customize.aweXpect.Equivalency().DefaultEquivalencyOptions
.Set(new EquivalencyOptions().IgnoringCollectionOrder());

// All equivalency checks within this scope ignore collection order by default.

To change the default for all async flows, e.g. in an assembly-level setup, set it on Customize.aweXpect.Global:

using aweXpect.Customization;

Customize.aweXpect.Global.Equivalency().DefaultEquivalencyOptions
.Set(new EquivalencyOptions().IgnoringCollectionOrder());

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:

Failure message
Expected that album
is equivalent to expected,
but it was not:
Property Artist.Name differed:
Actual: "Wings"
Expected: "The Beatles"

Equivalency options:
- include public fields and properties

When the playlist-filter pattern with It.Is<T>() fails, the member's expectation is rendered as Expected:

Failure message
Expected that midnight
is equivalent to new
{
Title = It.Is<string>().That.IsNotEmpty(),
PlayCount = It.Is<int>().That.IsGreaterThan(2),
},
but it was not:
Property PlayCount differed:
Actual: 1
Expected: is int that is greater than 2

Equivalency options:
- include public fields and properties