Object graph comparison

Edit this page

Consider the class Order and its wire-transfer equivalent OrderDto (a so-called DTO). Suppose also that an order has one or more Products and an associated Customer. Coincidentally, the OrderDto will have one or more ProductDtos and a corresponding CustomerDto. You may want to make sure that all exposed members of all the objects in the OrderDto object graph match the equally named members of the Order object graph.

You may assert the structural equality of two object graphs with Should().BeEquivalentTo():

orderDto.Should().BeEquivalentTo(order);

Additionally you can check the inequality of two objects with Should().NotBeEquivalentTo():

orderDto.Should().NotBeEquivalentTo(order);

All options described in the following sections are available for both BeEquivalentTo and NotBeEquivalentTo.

Recursion

The comparison is recursive by default. To avoid infinite recursion, Fluent Assertions will recurse up to 10 levels deep by default, but if you want to force it to go as deep as possible, use the AllowingInfiniteRecursion option. On the other hand, if you want to disable recursion, just use this option:

orderDto.Should().BeEquivalentTo(order, options => 
    options.ExcludingNestedObjects());

Value Types

To determine whether Fluent Assertions should recurs into an object’s properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides Object.Equals as an object that was designed to have value semantics. Anonymous types, records, record structs and tuples also override this method, but because the community proved us that they use them quite often in equivalency comparisons, we decided to always compare them by their members.

You can easily override this by using the ComparingByValue<T>, ComparingByMembers<T>, ComparingRecordsByValue and ComparingRecordsByMembers options for individual assertions:

subject.Should().BeEquivalentTo(expected,
   options => options.ComparingByValue<IPAddress>());

For records and record structs this works like this:

actual.Should().BeEquivalentTo(expected, options => options
    .ComparingRecordsByValue()
    .ComparingByMembers<MyRecord>());

Or do the same using the global options:

AssertionOptions.AssertEquivalencyUsing(options => options
    .ComparingByValue<DirectoryInfo>());

Note that primitive types are never compared by their members and trying to call e.g. ComparingByMembers<int> will throw an InvalidOperationException.

Auto-Conversion

In the past, Fluent Assertions would attempt to convert the value of a property of the subject-under-test to the type of the corresponding property on the expectation. But a lot of people complained about this behavior where a string property representing a date and time would magically match a DateTime property. As of 5.0, this conversion will no longer happen. However, you can still adjust the assertion by using the WithAutoConversion or WithAutoConversionFor options:

subject.Should().BeEquivalentTo(expectation, options => options
    .WithAutoConversionFor(x => x.Path.Contains("Birthdate")));

Compile-time types vs. run-time types

By default, Fluent Assertions respects an object’s or member’s declared (compile-time) type when selecting members to process during a recursive comparison. That is to say if the subject is a OrderDto but the variable it is assigned to has type Dto only the members defined by the latter class would be considered when comparing the object to the order variable. This behavior can be configured and you can choose to use run-time types if you prefer:

Dto orderDto = new OrderDto();

// Use runtime type information of orderDto
orderDto.Should().BeEquivalentTo(order, options => 
    options.RespectingRuntimeTypes());

// Use declared type information of orderDto
orderDto.Should().BeEquivalentTo(order, options => 
    options.RespectingDeclaredTypes());

One exception to this rule is when the declared type is object. Since object doesn’t expose any properties, it makes no sense to respect the declared type. So if the subject or member’s type is object, it will use the run-time type for that node in the graph. This will also work better with (multidimensional) arrays.

Matching Members

All public members of the Order object must be available on the OrderDto having the same name. If any members are missing, an exception will be thrown. However, you may customize this behavior. For instance, if you want to include only the members both object graphs have:

orderDto.Should().BeEquivalentTo(order, options => 
    options.ExcludingMissingMembers());

Selecting Members

If you want to exclude certain (potentially deeply nested) individual members using the Excluding() method:

orderDto.Should().BeEquivalentTo(order, options => 
    options.Excluding(o => o.Customer.Name));

The Excluding() method on the options object also takes a lambda expression that offers a bit more flexibility for deciding what member to exclude:

orderDto.Should().BeEquivalentTo(order, options => options 
    .Excluding(ctx => ctx.Path == "Level.Level.Text"));

Maybe far-fetched, but you may even decide to exclude a member on a particular nested object by its index.

orderDto.Should().BeEquivalentTo(order, options => 
    options.Excluding(o => o.Products[1].Status));

You can use For and Exclude if you want to exclude a member on each nested object regardless of its index.

orderDto.Should().BeEquivalentTo(order, options =>
    options.For(o => o.Products)
           .Exclude(o => o.Status));

Using For you can navigate arbitrarily deep. Consider a Product has a collection of Parts and a Part has a name. Using For your can also exclude the Name of all Parts of all Products.

orderDto.Should().BeEquivalentTo(order, options =>
    options.For(o => o.Products)
           .For(o => o.Parts)
           .Exclude(o => o.Name));

Of course, Excluding() and ExcludingMissingMembers() can be combined.

You can also take a different approach and explicitly tell Fluent Assertions which members to include. You can directly specify a property expression or use a predicate that acts on the provided ISubjectInfo.

orderDto.Should().BeEquivalentTo(order, options => options
    .Including(o => o.OrderNumber)
    .Including(pi => pi.Name == "Date"));

Including properties and/or fields

You may also configure member inclusion more broadly. Barring other configuration, Fluent Assertions will include all public properties and fields. This behavior can be changed:

// Include properties (which is the default)
orderDto.Should().BeEquivalentTo(order, options => options
    .IncludingProperties());

// Include fields
orderDto.Should().BeEquivalentTo(order, options => options
    .IncludingFields());

// Include internal properties as well
orderDto.Should().BeEquivalentTo(order, options => options
    .IncludingInternalProperties());

// And the internal fields
orderDto.Should().BeEquivalentTo(order, options => options
    .IncludingInternalFields());

// Exclude Fields
orderDto.Should().BeEquivalentTo(order, options => options
    .ExcludingFields());

// Exclude Properties
orderDto.Should().BeEquivalentTo(order, options => options
    .ExcludingProperties());

This configuration affects the initial inclusion of members and happens before any Excludes or other IMemberSelectionRules. This configuration also affects matching. For example, that if properties are excluded, properties will not be inspected when looking for a match on the expected object.

Comparing members with different names

Imagine you want to compare an Order and an OrderDto using BeEquivalentTo, but the first type has a Name property and the second has a OrderName property. You can map those using the following option:

// Using names with the expectation member name first. Then the subject's member name.
orderDto.Should().BeEquivalentTo(order, options => options
    .WithMapping("Name", "OrderName"));

// Using expressions, but again, with expectation first, subject last.
orderDto.Should().BeEquivalentTo(order, options => options
    .WithMapping<OrderDto>(e => e.Name, s => s.OrderName));

Another option is to map two deeply nested members to each other. In that case, your path must start at the root:

// Using dotted property paths 
rootSubject.Should().BeEquivalentTo(rootExpectation, options => options
    .WithMapping("Parent.Collection[].Member", "Parent.Collection[].Member"));

// Using expressions
rootSubject.Should().BeEquivalentTo(rootExpectation, options => options
    .WithMapping<SubjectType>(e => e.Parent.Collection[0].Member, s => s.Parent.Collection[0].Member));

Note that collection indices in string-based paths are not allowed. Within expressions, you must use an index to make it a valid property path, but it’ll be ignored. So both the examples are equivalent. Also, such nested paths must have the same parent. So mapping properties or fields at different levels is not (yet) supported.

Now imagine those types appear somewhere in the object graph. Then you can use this overload:

// Using names
orderDto.Should().BeEquivalentTo(order, options => options
    .WithMapping<Order, OrderDto>("Name", "OrderName"));

// Using expressions
orderDto.Should().BeEquivalentTo(order, options => options
    .WithMapping<Order, OrderDto>(e => e.Name, s => s.OrderName));

Notice that you can also map properties to fields and vice-versa.

Hidden Members

Sometimes types have members out of necessity, to satisfy a contract, but they aren’t logically a part of the type. In this case, they are often marked with the attribute [EditorBrowsable(EditorBrowsableState.Never)], so that the object can satisfy the contract but the members don’t show up in IntelliSense when writing code that uses the type.

If you want to compare objects that have such fields, but you want to exclude the non-browsable “hidden” members (for instance, their implementations often simply throw NotImplementedException), you can call ExcludingNonBrowsableMembers on the options object:

class DataType
{
    public int X { get; }

    [EditorBrowsable(EditorBrowsableState.Never)]
    public int Y => throw new NotImplementedException();
}

DataType original, derived;

derived.Should().BeEquivalentTo(original, options => options
    .ExcludingNonBrowsableMembers());

Equivalency Comparison Behavior

In addition to influencing the members that are including in the comparison, you can also override the actual assertion operation that is executed on a particular member.

orderDto.Should().BeEquivalentTo(order, options => options
    .Using<DateTime>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, 1.Seconds()))
    .When(info => info.Path.EndsWith("Date")));

If you want to do this for all members of a certain type, you can shorten the above call like this.

orderDto.Should().BeEquivalentTo(order, options => options 
    .Using<DateTime>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, 1.Seconds()))
    .WhenTypeIs<DateTime>());

Enums

By default, Should().BeEquivalentTo() compares Enum members by the enum’s underlying numeric value. An option to compare an Enum only by name is also available, using the following configuration :

orderDto.Should().BeEquivalentTo(expectation, options => options.ComparingEnumsByName());

Note that even though an enum’s underlying value equals a numeric value or the enum’s name equals some string value, we do not consider those to be equivalent. In other words, enums are only considered to be equivalent to enums of the same or another type, but you can control whether they should equal by name or by value.

Collections and Dictionaries

Considering our running example, you could use the following against a collection of OrderDtos:

orderDtos.Should().BeEquivalentTo(orders, options => options.Excluding(o => o.Customer.Name));

You can also assert that all instances of OrderDto are structurally equal to a single object:

orderDtos.Should().AllBeEquivalentTo(singleOrder);

Ordering

Fluent Assertions will, by default, ignore the order of the items in the collections, regardless of whether the collection is at the root of the object graph or tucked away in a nested property or field. If the order is important, you can override the default behavior with the following option:

orderDto.Should().BeEquivalentTo(expectation, options => options.WithStrictOrdering());

You can even tell FA to use strict ordering only for a particular collection or dictionary member, similar to how you exclude certain members:

orderDto.Should().BeEquivalentTo(expectation, options => options.WithStrictOrderingFor(s => s.Products));

And you can tell FA to generally use strict ordering but ignore it for a particular collection or dictionary member:

orderDto.Should().BeEquivalentTo(expectation, options => options.WithStrictOrdering().WithoutStrictOrderingFor(s => s.Products));

In case you chose to use strict ordering by default you can still configure non-strict ordering in specific tests:

AssertionOptions.AssertEquivalencyUsing(options => options.WithStrictOrdering());

orderDto.Should().BeEquivalentTo(expectation, options => options.WithoutStrictOrdering());

Notice: For performance reasons, collections of bytes are compared in exact order. This is even true when applying WithoutStrictOrdering().

Diagnostics

Should().BeEquivalentTo is a very powerful feature, and one of the unique selling points of Fluent Assertions. But sometimes it can be a bit overwhelming, especially if some assertion fails under unexpected conditions. To help you understand how Fluent Assertions compared two (collections of) object graphs, the failure message will always include the relevant configuration settings:

Xunit.Sdk.XunitException
Expected item[0] to be 0x06, but found 0x01.
Expected item[1] to be 0x05, but found 0x02.
Expected item[2] to be 0x04, but found 0x03.
Expected item[3] to be 0x03, but found 0x04.
Expected item[4] to be 0x02, but found 0x05.
Expected item[5] to be 0x01, but found 0x06.

With configuration:
- Use declared types and members
- Compare enums by value
- Include all non-private properties
- Include all non-private fields
- Match member by name (or throw)
- Be strict about the order of items in byte arrays

However, sometimes that’s not enough. For those scenarios where you need to understand a bit more, you can add the WithTracing option. When added to the assertion call, it would extend the above output with something like this:

With trace:
  Structurally comparing System.Object[] and expectation System.Byte[] at root
  {
    Strictly comparing expectation 6 at root to item with index 0 in System.Object[]
    {
      Treating item[0] as a value type
    }
    Strictly comparing expectation 5 at root to item with index 1 in System.Object[]
    {
      Treating item[1] as a value type
    }
    Strictly comparing expectation 4 at root to item with index 2 in System.Object[]
    {
      Treating item[2] as a value type
    }
    Strictly comparing expectation 3 at root to item with index 3 in System.Object[]
    {
      Treating item[3] as a value type
    }
    Strictly comparing expectation 2 at root to item with index 4 in System.Object[]
    {
      Treating item[4] as a value type
    }
    Strictly comparing expectation 1 at root to item with index 5 in System.Object[]
    {
      Treating item[5] as a value type
    }
  }

By default, the trace is displayed only when an assertion fails as part of the message. If the trace is needed for the successful assertion you can catch it like this:

object object1 = [...];
object object2 = [...];
var traceWriter = new StringBuilderTraceWriter();            
object1.Should().BeEquivalentTo(object2, opts => opts.WithTracing(traceWriter));             
string trace = traceWriter.ToString();

Alternatively, you could write your own implementation of ITraceWriter for special purposes e.g. writing to a file.

Global Configuration

Even though the structural equivalency API is pretty flexible, you might want to change some of these options on a global scale. This is where the static class AssertionOptions comes into play. For instance, to always compare enumerations by name, use the following statement:

AssertionOptions.AssertEquivalencyUsing(options => 
   options.ComparingEnumsByName);

All the options available to an individual call to Should().BeEquivalentTo are supported, with the exception of some of the overloads that are specific to the type of the subject (for obvious reasons).