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
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
TaskCompletionSource, first-class support for
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,
NotThrowAfterAsync() now allow chaining using the
Which extension. For specific cases, such as the
ArgumentException, you can also use the new
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
BeLowerCased() and their negated counterparts. A more subtle change is how
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
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.
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
Date and time support got some minor adjustments. For instance, we added nullable overloads to
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
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,
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
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
ThatAreNotStatic(). But if you need more flexibility then you can use
ThatSatisfy(Func<Type, bool> predicate). If the involved types are
Task<T>, you can use
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
NotBeUnderNamespace(). You could already get the
Properties() from whatever your type selection returned, but you can now also get the
ReturnTypes(). For methods, you can use
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
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
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
XAttribute, as well as various
System.Data types (
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
On the behavioral side,
BeEquivalentTo will no longer include
internal properties and fields, unless
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
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
- 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.
AttributeBasedFormatterso you can build more specific formatters that get precendence over more generic ones.
- Improved the message that
RaisePropertyChangeForthrows when a property changed event was raised for the wrong property.
- Requesting an unsupported test framework via
FluentAssertions.TestFrameworksetting now throws an exception instead of using the fallback.
WhoseValueto make it read in a more natural way like
Added overload of the
Enumeratingextension method to be able to force the enumeration of an object’s member like this:
obj.Enumerating(x => x.SomeMethodThatUsesYield("blah")).Should().Throw<ArgumentException>();
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
AssertionScopefor 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
ForConditionshould not evaluate its lambda when the previous assertion failed.
IValueFormatternow work with a
FormattingOptionsobject that wraps the
UseLineBreaksoption and some of the new settings for limiting the depth.
AssertionScopethat allows you to use an
OccurrenceConstraintin your custom assertion extensions that can verify a number against a constraint, e.g. as is done here in
- Ensured that
Givenwill no longer evaluate its predicate if the preceding
FailWithraised 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
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
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
Tracer classes. This affected the
IOrderingRule and forced us to replace their dependency on
INode and its derivatives
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.