Writing good tests is tricky. Modifying implementation details should not break tests. A lot of times constructors are implementation details. Especially the ones we hide behind interfaces. Also, constructors usually change more frequently than methods. In this post, we're going to quickly see a couple of simple examples of how calling constructors in unit tests can turn into a disaster, then we will see some workarounds to avoid them in different situations.
Let's start with a simple immutable Point
Value Object:
var point = new Point(x : 32, y : -9);
And test if it remembers the x we passed to it's constructor:
public void Point_remembers_its_x()
{
var point = new Point(x : 32, y : -9);
point.X.Should().Be(32);
}
Look at the magic numbers: 32 , -9 ,....
The test is not trustworthy. I would generate them randomly.The trustworthiness can be a subject for another post. I'd use the magic numbers for these series for the sake of simplicity.
Notice that for assertion the FluentAssertions library is used here. You can use any tool that's convenient for you depending on the programming language you use.
The implementation of production code is straightforward.
Now Let's add another test:
public void Point_rejects_x_when_it_is_greater_than_90()
{
Action newPoint = () => new Point(x: 92, y: -9);
newPoint.Should().Throw<ArgumentOutOfRangeException>();
}
There are also other tests:
- Point rejects
X
when it is smaller than -90. - Point rejects
Y
when it is greater than -180. - Point rejects
Y
when it is smaller than -180. - Point remembers it's
Y
. I skip writing the test bodies in the post as they are straightforward. So we have 6 tests so far and each one call the constructor of thePoint
. A couple of iterations later it turns out thatZ
is also required to be a part ofPoint
. That requires shotgun surgery of all of the 6 unit tests! ThePoint
is a fairly simple predictable object, what about more complex objects with more unit tests? By each change in a constructor we need to update tens of tests. It not only is time consuming and boring, but also every time you change a unit test you reduce it's trustworthiness. We may change the signature of a constructor, and forget to update the test accordingly. So an old test may passY
asX
parameter to the constructor somewhere. That small change ends up with unit tests that should not pass but they do. And a lot of times not having a safety net at all is much better than having an untrustworthy one.
A simple solution would be setting default parameters to avoid changing all of the tests:
public class Point
{
public Point(double x, double y, double z = 0)
{
...
That works here. Changes to the constructor parameters no longer need all of the tests to be updated, but there are still other problems that we may face when unit testing less obvious domains. For example a new parameter may require to be placed between the existing parameters, and as we know many programming languages (like C#) require optional parameters to be last parameters.
Another solution would be extracting the creation of a valid instance of the class to a factory method, so that every time the constructor changes, a single factory method needs to be changed.
public Point CreateAPoint()
{
return new Point(32, -9);
}
So all tests call this single factory method. If the constructor changes later on, I will easily change this single factory method to support all of my existing tests. Let's call it:
public void Point_remembers_its_x()
{
var point = CreateAPoint();
point.X.Should().Be(32);
}
But we have a mystery guest here. Why should X
be 32?
- That's not readable and causes Yoyo Problem.
- That's not safe. What if I change the implementation of the
CreateAPoint()
factory method? - And as mentioned before, that's not trustworthy.
Trustworthiness can be addressed by test builders. But Value Objects don't usually need separate test specific builders. Value Objects can act as builders of themselves. It's best not to add redundant Value Object builders in unit test projects. Value Objects are linguistically allowed to act as builders of themselves, even if some of the builder methods never get called by production code.
Let's dig deeper in the next post and see more detailed examples of how to build clean Value Objects in unit tests.
Top comments (0)