Fluent Assertions 6.0, the biggest release ever

Edit this page | 10 minute read

Update August 22th, 2021: 6.1 was shipped with important performance fixes and a version of FluentAssertions.JSON compatible with v6 was also released.

A true story

Once upon a time in a small country called The Netherlands, a little open-source project was born. It must have been 2008 and open-source in the .NET community was still in its infancy. CodePlex was still a thing and NuGet did not exist yet. Fast-forward to 2021 and this little project has attracted more than 100 million downloads and is being used in more projects than I can keep track off.

But almost 1.5 year before that, on January 4th, 2020, me and my partner-in-crime Jonas Nyrup started to work on our next major release. We got support by about 20 contributors, with special thanks to Lukas Grutzmacher and Michaël Hompus, as well as sponsors Jetbrains and Cristian Quisoj. Now, after 570 commits affecting 626 files, two alphas and three betas, it’s time to let Fluent Assertions 6 enter the real-world. Let me provide you with the highlights of this release.

Time to break with the past (again)

A bump of the major part of the release number implies breaking changes, and 6.0 is no different. Given that opportunity, one of the first things we do with any major release is to review the supported frameworks. We now support .NET Framework 4.7, .NET Core 2.1, .NET Core 3.0, .NET Standard 2.0 and 2.1, and although there’s no special support for it, that also means we support .NET 5. We did however drop support for .NET Framework 4.5, .NET Standard 1.3 and 1.6. In a similar fashion, we reviewed the test frameworks we support as well. This resulted in us dropping support for the first version of MSTest, NSpec v1 and 2, xUnit 1, Gallio and MBUnit.

Given a major release bump, we also took the opportunity to review some of our design decisions from the past. One of them is the way we supported async code. In the past, we would invoke asynchronous code by wrapping it in a synchronously blocking call. Unfortunately this resulted in occasional deadlocks, which we fixed by temporarily clearing the SynchronizationContext. This worked but felt like an ugly workaround. So in v6, we decided to fully embrace asynchronous code and make some of the assertion APIs async itself. This means that using Should().Throw() will no longer magically work on async code and you need to use ThrowAsync() instead.

Another design change that we thought the time is right for, was the removal of support for non-generic collections. I mean, why would anybody still want to use an ArrayList, right? Nonetheless, for those that can’t let go of this ancient type, we documented a workaround in our migration guide.

A shipload of changes

Although Fluent Assertions was already quite feature complete, v6 still adds some new capabilities such as support for BufferedStream, TaskCompletionSource, first-class support for enums and IReadOnlyDictionary<K, V>. But that’s not all, here’s a summary of all other less important changes.

To ease assertions on exceptions coming from code returning a Task, we’ve added an async version of Where to further refine what you were expecting, like this:

await act.Should().ThrowAsync<ArgumentException>().Where(i => i.Message == "That was wrong.");

And in case you want to assert that an exception with specific characterics is not throw, NotThrow(), NotThrowAsync(), NotThrowAfter() and NotThrowAfterAsync() now allow chaining using the Which extension. For specific cases, such as the ArgumentException, you can also use the new WithParameterName() method.

act.Should().ThrowExactly<ArgumentNullException>().WithParameterName("interfaceType");

The already pretty complete set of assertions available for strings has been extended as well. For instance, you can now assert the lower or upper casing of strings using BeUpperCased() or BeLowerCased() and their negated counterparts. A more subtle change is how StartWith(), EndWith() and the EquivalentOf() versions allow an empty string as the expecation. And talking about that last category of methods, StartWithEquivalent() has been renamed to StartWithEquivalentOf(), just like EndWithEquivalent(). Also, the methods MatchRegex() and its inverse counterpart now take an entire Regex object in addition to the regex string it took before. This should give you a bit more flexibility when you need it.

But we’ve also changed the semantics of string comparisons related to the culture of the test environment. We believe that unit tests should behave the same no matter the culture of the machine being run on, so as of v6, we internally use StringComparison.Ordinal and OrdinalIgnoreCase for string comparisons. And in case you’re dealing with a collection of strings, you can use NotContainMatch() to assert that the collection does not contain a string that matches a wildcard pattern, and AllBe() to assert that all strings in collection are equal to the specified string.

If the collection involved deals with anything else than strings, we still have plenty to offer. For example, Satisfy() will allow you to compare a collection with a set of predicates while ignoring the exact order. BeInAscendingOrder() and BeInDescendingOrder() now take a lambda as a predicate. And NotContainInOrder() allow you to assert that the collection does not contain the specified elements in the exact same order, not necessarily consecutive. And for the more complicated cases, you can use NotContainEquivalentOf to assert the absence of an element in the collection using the same engine as BeEquivalentTo uses.

Date and time support got some minor adjustments. For instance, we added nullable overloads to Be() and NotBe(), applicable to both DateTime as well as DateTimeOffset. And to simplify the creation of the latter, you can now use the new WithOffset() extension method to chain on a DateTime such as returned by the date/time fluent API like 31.January(2021). By the way, we decided to change int-based precision parameters to take a TimeSpan.

Although the event keyword is becoming a lost art in C# world, we still support it. In v6, we fixed some bugs and cleaned up the API. For instance, WithSender() and WithArgs() will only return the events that match the constraints. And several of the APIs will return an IEventRecording instead of IEventRecorder. This allowed us to make EventMonitor and RecordedEvent internal and make the entire API a bit more cleaner.

On the reflection front, several new members were added to help filter types and members returned by the Types() method before asserting they have certain characteristics. Examples of these include ThatAreClasses() and ThatAreNotClasses(), or ThatAreStatic() and ThatAreNotStatic(). But if you need more flexibility then you can use ThatSatisfy(Func<Type, bool> predicate). If the involved types are IEnumerable<T> or Task<T>, you can use UnwrapEnumerableTypes() or UnwrapTaskTypes() to extract the generic types and continue with those.

Given you’ve selected the types you’re interested in, you can now run assertions like Should().NotBeSealed(), BeSealed(), BeInNamespace() and NotBeUnderNamespace(). You could already get the Methods() and Properties() from whatever your type selection returned, but you can now also get the ReturnTypes(). For methods, you can use Be() and NotBe() to ensure a method has a certain access modifier. And finally, for properties, you can ensure they are not writable using NotBeWritable(). Since we were allowed to introduce breaking changes, we removed some obsoleted members, including HasAttribute(), HasMatchingAttribute() and IsDecoratedWith(Type, bool).

Structural equivalency assertions

Fluent Assertions’ flagship feature has always been the ability to do a deep recursive comparison between two object graphs. In v6, a lot has changed, both on the outside as well as on the internals.

We’ll cover the internals when I discuss extensibility, but let’s start with the changes you may care about, in particular our most popular request, support for C# 9.0’s records We had extensive open discussions on what would be the most logical behavior, but ultimately settled on comparing records by their members, just like we do with classes and anonymous types. Of course, you can easily override this by using the ComparingByValue<T>, ComparingByMembers<T>, ComparingRecordsByValue and ComparingRecordsByMembers, including a new overload that takes an open-generic type. And since it wasn’t always obvious what logic Fluent Assertions would use for what type of object, it will now include information on how tuples, anonymous types, records and other types are compared in the failure message.

In addition to that, BeEquivalentTo now knows how to compare properties or fields pointing to a XDocument, XElement and XAttribute, as well as various System.Data types (DataSet, DataTable, DataColumn, DataRow, DataRelation, Constraint). And to reuse the logic to compare certain types of objects across your code-base, there’s a Using<T> version that takes an IEqualityComparer<T>

On the behavioral side, BeEquivalentTo will no longer include internal properties and fields, unless IncludingInternalProperties or IncludingInternalFields is used. We also restricted what types WhenTypeIs<T> can use and how Using<T> handles non-nullable types (which is explained in the Migration Guide). And talking about the Using/When combo, When will now use the conversion rules when trying to match the predicate. Oh, and auto-conversion will use CultureInfo.InvariantCulture instead of CultureInfo.CurrentCulture when applicable.

Reporting & Usability

In the category of less mindblowing improvements, v6 contains several reporting and usability refinements. Here are a couple of highlights.

  • Improved formatting of predicates.
  • Much smarter parsing of your code to report the variable names in your assertion.
  • Included the milliseconds part for error messages that involve a TimeSpan.
  • Added the possibility to set the maximum depth and other formatting settings either globally or per AssertionScope. This solves some of the reported performance problems that some folks have experienced with deep object graphs.
  • Changed AttributeBasedFormatter so you can build more specific formatters that get precendence over more generic ones.
  • Improved the message that RaisePropertyChangeFor throws when a property changed event was raised for the wrong property.
  • Requesting an unsupported test framework via Services.Configuration.TestFrameworkName or the FluentAssertions.TestFramework setting now throws an exception instead of using the fallback.
  • Renamed WhichValue to WhoseValue to make it read in a more natural way like

    dictionary.Should().ContainKey("Key").WhoseValue.Should().Be(4);

  • Added overload of the Enumerating extension method to be able to force the enumeration of an object’s member like this:

    obj.Enumerating(x => x.SomeMethodThatUsesYield("blah")).Should().Throw<ArgumentException>();

Extensibility

Unless you’ve been building your own extensions, you can safely skip this paragraph. But if you do, there’s a couple of nice improvements that may help you in the future:

  • Added an AddReportable overload to AssertionScope for deferring reportable value calculation until a test failure. This has helped improve performance for the rest of the library.
  • In a chained assertion API call, a second call to ForCondition should not evaluate its lambda when the previous assertion failed.
  • Formatter.ToString() and IValueFormatter now work with a FormattingOptions object that wraps the UseLineBreaks option and some of the new settings for limiting the depth.
  • Added ForConstraint method to AssertionScope that allows you to use an OccurrenceConstraint in your custom assertion extensions that can verify a number against a constraint, e.g. as is done here in StringAssertions.
  • Ensured that Given will no longer evaluate its predicate if the preceding FailWith raised an assertion failure

Since this a major release, we took the opportunity to apply some significant refactoring to the internal APIs. For example, we split-up the subject and expectation from the IEquivalencyValidationContext interface into a new type Comparands. This obviously affected the IEquivalencyStep and its implementations, but we didn’t stop there. We also moved the responsibility of IEquivalencyStep.CanHandle into Handle and replaced Handle’s boolean return value with a more clearer EquivalencyResult. And while we were at it, we’ve moved all implementations to the FluentAssertions.Equivalency.Steps namespace and renamed EquivalencyStepCollection to EquivalencyPlan. Then we started removing a lot more members from the IEquivalencyValidationContext interface by moving them into a hierarchy of INode interfaces as well as the new Reason and Tracer classes. This affected the IMemberMatchingRule, IMemberSelectionRule and IOrderingRule and forced us to replace their dependency on SelectedMemberInfo to INode and its derivatives IMember, IField and IProperty. But don’t be afraid, all the documentation has been properly updated. We even threw in a migration guide for those that run into common upgrade issues.

So what’s next?

Phew, this post got rather long, so I hope I didn’t loose you half-way. And I’ve just listed the highlights, so be sure to check out the full release notes for all the details. But this is not the end of the road. With the help of the community, we’ll keep working on the many suggestions and improvements shared with us.

And you can help too! Just pick up some of the up-for-grabs issues or, if you just started exploring the open-source community, take a good-first-issue. But even if you’re not in the position to contribute directly, you can still support us financially, either structurally through Patreon and Github Sponsors, or as a one-time donation through Ko-Fi or Paypal. It’ll help us explain to our families why we have invested so much time in this little project…

Oh, and follow me at @ddoomen to get regular updates on my everlasting quest for better solutions.

Leave a Comment