CODE WITH ME
The logo of William Pei
HomeCode with meCSharp
Illustration of a person looking at some code

Unit Testing Constructors in C# with Fluent Constructor Assertions

William Pei \\ 21.06.28

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:

1public class TransportHandler : ICommandHandler
2{
3  private readonly ITransportService _transportService;
4  private readonly ILogger<TransportHandler> _logger;
5
6  public TransportHandler(ITransportService transportService,
7                          ILogger<TransportHandler> logger)
8  {
9    _transportService = transportService
10                        ?? throw new ArgumentNullException(nameof(transportService));
11
12    _logger = logger ?? throw new ArgumentNullException(nameof(logger));
13  }
14}

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 and FluentAssertions to do the below:

1[Fact]
2public void GivenNullTransportService_WhenInstantiating_ThenThrowException()
3{
4  // ARRANGE
5  Action act = () => new TransportHandler(null,
6    Substitute.For<ILogger<TransportHandler>>());
7
8  // ACT + ASSERT
9  act.Should()
10     .Throw<ArgumentNullException>()
11     .WithMessage("Value cannot be null. (Parameter 'transportService')");
12}
13
14[Fact]
15public void GivenNullLogger_WhenInstantiating_ThenThrowException()
16{
17  // ARRANGE
18  Action act = () => new TransportHandler(Substitute.For<ITransportService>(),
19    null);
20
21  // ACT + ASSERT
22  act.Should()
23     .Throw<ArgumentNullException>()
24     .WithMessage("Value cannot be null. (Parameter 'logger')");
25}

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. 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:

1[Fact]
2public void WhenInstantiatingTransportHandler_ThenHandleAllScenariosAppropriately()
3{
4  // ARRANGE
5  ITransportService transportService = Substitute.For<ITransportService>();
6  ILogger<TransportHandler> logger = Substitute.For<ILogger<TransportHandler>>();
7
8  // ACT + ASSERT
9  ForConstructorOf<TransportHandler>
10    .WithArgTypes(typeof(ITransportService), typeof(ILogger<TransportHandler>))
11    .Throws<ArgumentNullException>("Value cannot be null. (Parameter 'transportService')")
12    .ForArgs(null, logger)
13    .And.Throws<ArgumentNullException>("Value cannot be null. (Parameter 'logger')")
14    .ForArgs(transportService, null)
15    .And.Succeeds("Both arguments are not null")
16    .ForArgs(transportService, logger)
17    .Should.BeTrue();
18}

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 class TransportHandler which has a constructor that accepts two arguments: ITransportService and ILogger<TransportHandler>.
Step 2: define the first test case
Lines 11 - 12: in this case, we are checking that the constructor will throw an ArgumentNullException when the first parameter is null. 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 on Lines 13 and 14. We can also use the Succeeds() 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 the Should.BeTrue() method.

Check out the code!

If you found this interesting, feel free to check out the FluentConstructorAssertions codebase on Github. Don't be afraid to contribute or raise any issues you find. Please also give it a star if you like it!