Structural equality for better tests by Mark Seemann
A Fluent Builder as a Value Object?
If you've read a bit about unit testing, test-driven development, or other kinds of developer testing, you've probably come across a phrase like this:
Test behaviour, not implementation.It's often taken to mean something like behaviour-driven development (BDD), and that's certainly one interpretation. I've no problem with that. My own Pluralsight course Outside-In Test-Driven Development shows a similar technique.
It'd be a logical fallacy, however, to thereby conclude that you can only apply that ideal in the large, but not in the small. That it's only possible to do it with coarse-grained tests at the boundary of the system, but not with unit testing.
It may be harder to do at the unit level, since when writing unit tests, you're closer to the implementation, so to speak. Writing the test before the implementation may, however, help
The code shown here is part of the sample code base that accompanies my book Code That Fits in Your Head.
An example test #
Here's a test (using xUnit.net 2.4.1) I wrote before the implementation:
[Theory] [InlineData("Home")] [InlineData("Calendar")] [InlineData("Reservations")] public void WithControllerHandlesSuffix(string name) { var sut = new UrlBuilder(); var actual = sut.WithController(name + "Controller"); var expected = sut.WithController(name); Assert.Equal(expected, actual); }
It tests an ASP.NET Core URL Builder; particular how it deals with the Controller
suffix issue I ran into last year.
Do you notice something odd about this test?
It describes an equality relation between two individual projections of an initial UrlBuilder
object (sut).
First of all, with a Mutable Fluent Builder the test would produce a false negative because aliasing would make the assertion a tautological assertion. Using an Immutable Fluent Builder, however, elegantly dodges that bullet: expected
and actual
are two separate objects.
Yet, it's possible to compare them. How?
Assertions #
I think that most people would have written the above test like this:
[Theory] [InlineData("Home")] [InlineData("Calendar")] [InlineData("Reservations")] public void WithControllerHandlesSuffix(string name) { var sut = new UrlBuilder(); var actual = sut.WithController(name + "Controller"); var expected = sut.WithController(name); Assert.Equal(expected.Controller, actual.Controller); }
Instead of comparing two whole objects, this variation compares the Controller
property values from two objects. In order for this to compile, you have to expose an implementation detail: that the class has a class field (here exposed as an automatic property) that keeps track of the Controller name.
I think that most object-oriented programmers' default habit is to write assertions that compare properties or class fields because in both C# and Java, objects by default only have reference equality. This leads to primitive obsession, this time in the context of test assertions.
Structural equality, on the other hand, makes it much easier to write concise and meaningful assertions. Just compare expected
with actual
.
Structural equality on a Builder? #
The UrlBuilder
class has structural equality by overriding Equals
and GetHashCode
:
public override bool Equals(object? obj) { return obj is UrlBuilder builder && action == builder.action && controller == builder.controller && EqualityComparer<object?>.Default.Equals(values, builder.values); } public override int GetHashCode() { return HashCode.Combine(action, controller, values); }
That's why the above Assert.Equal
statement works.
You may think that it's an odd choice to give a Fluent Builder structural equality, but why not? Since it's immutable, it's perfectly safe, and it makes things like testing much easier.
I rarely see people do this. Even programmers experienced with functional programming often seem to categorise structural equality as something associated exclusively with algebraic data types (ADTs). The UrlBuilder
class, on the other hand, doesn't look like an ADT. After all, its public API exposes only behaviour, but no data:
public sealed class UrlBuilder { public UrlBuilder() public UrlBuilder WithAction(string newAction) public UrlBuilder WithController(string newController) public UrlBuilder WithValues(object newValues) public Uri BuildAbsolute(IUrlHelper url) public override bool Equals(object? obj) public override int GetHashCode() }
On the other hand, my threshold for when I give an immutable class structural equality is monotonically decreasing. Structural equality just makes things easier. The above test is just one example. Structural equality enables you to test behaviour instead of implementation details. In this example, the behaviour can be expressed as an equality relation between two different inputs.
UrlBuilder as an algebraic data type #
While it may seem odd or surprising to give a Fluent Builder structural equality, it's really isomorphic to a simple record type equipped with a few endomorphisms. (After all, we already know that the Builder pattern is isomorphic to the endomorphism monoid.) Let's make this explicit with F#.
Start by declaring a record type with a private
definition:
type UrlBuilder = private { Action : string option; Controller : string option; Values : obj option }
While its definition is private
, it's still an algebraic data type. Records in F# automatically have structural equality, and so does this one.
Since it's private
, client code can't use the normal language constructs to create instances. Instead, the module that defines the type must supply an API that client code can use:
let emptyUrlBuilder = { Action = None; Controller = None; Values = None } let withAction action ub = { ub with Action = Some action } let withController (controller : string) ub = let index = controller.LastIndexOf ("controller", StringComparison.OrdinalIgnoreCase) let newController = if 0 <= index then controller.Remove(index) else controller { ub with Controller = Some newController } let withValues values ub = { ub with Values = Some values }
Without further ceremony you can port the initial test to F# as well:
[<Theory>] [<InlineData("Home")>] [<InlineData("Calendar")>] [<InlineData("Reservations")>] let ``withController handles suffix`` name = let sut = emptyUrlBuilder let actual = sut |> withController (name + "Controller") let expected = sut |> withController name expected =! actual
In addition to xUnit.net this test also uses Unquote 6.0.0.
Even though UrlBuilder
has no externally visible data, it automatically has structural equality. Functional programming is, indeed, more test-friendly than object-oriented programming.
This F# implementation is equivalent to the C# UrlBuilder class.
Conclusion #
You can safely give immutable objects structural equality. Besides other advantages, it makes it easier to write tests. With structural equality, you can express a relationship between the expected and actual outcome using high-level language.
These days, I don't really care if the type in question is a 'proper' algebraic data type. If it's immutable, I don't have to think much about it before giving it structural equality.
Comments
That is mostly true but not compeltely so. Consider the type
type MyRecord = { MyField: int -> bool }
If you try to compare two instances with F#'s
=
operator, then you will get this compilier error.Adding the
StructuralEquality
attribute results in this compiler error.I learned all this the hard way. I had added some F# functions to some of my models in my MVU architecture. Later when I tried to test my root model for structual equality, I ran into this issue. Taking the suggestion in the compiler error, I fixed the problem by adding the
StructuralEquality
attribute (as well as theNoComparison
attribute) to my root model and refactored the code to fix the resulting compiler errors.During this time, I also realized that F#'s structual equality delegates to
object.Equals(object)
for types that extendobject
, which of course defaults to reference equality. For example, the following code compiles.[<StructuralEquality>] [<NoComparison>] type MyRecord = { MyField: IDisposable }
Tyson, thank you for writing. Yes, you're right. Language is imprecise. F# records automatically have structural equality, when possible.