Unit Testing Constructors in C# with Fluent Constructor Assertions open_in_new
The Problem
In my day to day interactions with writing C# code, I've found that constructors are surprisingly clunky to unit test. Since the best practice in constructors is to null check the constructor arguments, I've often found myself writing the same boilerplate code for different constructors just so that I can pass null
as one one of the arguments in my constructor's unit test.
You may have encountered constructors such as this example below:
public class TransportHandler : ICommandHandler
{
private readonly ITransportService _transportService;
private readonly ILogger<TransportHandler> _logger;
public TransportHandler(ITransportService transportService,
ILogger<TransportHandler> logger)
{
_transportService = transportService
?? throw new ArgumentNullException(nameof(transportService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
}
To unit test something like this to 100% branch coverage, you would have to write multiple actions and then assert that they throw an ArgumentNullException
when each action is executed. You would also have to write a test to make sure that no exceptions are thrown when both arguments are valid. For instance, we can use XUnit open_in_new and FluentAssertions open_in_new to do the below:
[Fact]
public void GivenNullTransportService_WhenInstantiating_ThenThrowException()
{
// ARRANGE
Action act = () => new TransportHandler(null,
Substitute.For<ILogger<TransportHandler>>());
// ACT + ASSERT
act.Should()
.Throw<ArgumentNullException>()
.WithMessage("Value cannot be null. (Parameter 'transportService')");
}
[Fact]
public void GivenNullLogger_WhenInstantiating_ThenThrowException()
{
// ARRANGE
Action act = () => new TransportHandler(Substitute.For<ITransportService>(),
null);
// ACT + ASSERT
act.Should()
.Throw<ArgumentNullException>()
.WithMessage("Value cannot be null. (Parameter 'logger')");
}
As you might imagine, this is not very scallable for constructors that make more than 4 or 5 arguments. What about for classes that have multiple ways of being instantiated?
The Solution
This is the reason behind the creation of Fluent Constructor Assertions open_in_new. This library enables us to easily define the test cases for a specific constructor, all in the one test method. No more ClassData
attributes or MemberData
attributes! Defining tests for the example TransportHandler
becomes as simple as:
[Fact]
public void WhenInstantiatingTransportHandler_ThenHandleAllScenariosAppropriately()
{
// ARRANGE
ITransportService transportService = Substitute.For<ITransportService>();
ILogger<TransportHandler> logger = Substitute.For<ILogger<TransportHandler>>();
// ACT + ASSERT
ForConstructorOf<TransportHandler>
.WithArgTypes(typeof(ITransportService), typeof(ILogger<TransportHandler>))
.Throws<ArgumentNullException>("Value cannot be null. (Parameter 'transportService')")
.ForArgs(null, logger)
.And.Throws<ArgumentNullException>("Value cannot be null. (Parameter 'logger')")
.ForArgs(transportService, null)
.And.Succeeds("Both arguments are not null")
.ForArgs(transportService, logger)
.Should.BeTrue();
}
Whoa what was that!
- Lets break it down into blocks and explain what just happened.
- Step 1: define the constructor we want to test
Lines 9 - 10
: here, we are creating a test context for the classTransportHandler
which has a constructor that accepts two arguments:ITransportService
andILogger<TransportHandler>
.- Step 2: define the first test case
Lines 11 - 12
: in this case, we are checking that the constructor will throw anArgumentNullException
when the first parameter isnull
. We also define the expected exception message"Value cannot be null. (Parameter 'transportService')"
. This expected exception message helps the test runner identify whether the exception was thrown for the correct parameter. If you do not specify this message, the runner will assume any exception of the specified type is a valid exception.- Step 3: add some more test cases
- We are able to chain additional test cases by using the
And
keyword. You can see examples of this onLines 13 and 14
. We can also use theSucceeds()
method to define that test case that should not throw an exception. - Step 4: lets execute the test!
Line 17
: we can tell the test context to execute the configured test cases with theShould.BeTrue()
method.
Check out the code!
If you found this interesting, feel free to check out the FluentConstructorAssertions codebase open_in_new on Github. Don't be afraid to contribute or raise any issues you find. Please also give it a star if you like it!