CODE WITH ME
The logo of William Pei
Illustration of a person looking at some code
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:

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

csharp
[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:

csharp
[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 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 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!

Built with NextJS\\Powered by Waystone UI open_in_new
© William Pei 2024\\last updated 2024-04-13