Writing Unit Tests with xUnit, NSubstitute and FluentAssertions

Writing Unit Tests with xUnit, NSubstitute and FluentAssertions

It's really hard to overestimate the importance of testing. Let's imagine you are an engineer designing a railway bridge. You need to know the traffic flow, the materials it will be constructed, and many other factors. When it is finally done you can just allow a train to pass over the bridge to make sure it won't break...

You can't. The good news - you can follow this way as a software engineer! You can test individual parts, the whole structure, and even the entire flow with the train and passengers because of the 3 reasons:

  • unit tests
  • integration tests
  • end-to-end tests

Writing the tests makes your product reliable, raises the quality, reduces the time between code is committed and feature is delivered. It also makes you design code properly, especially if you are TDD adept.

In this post, I'll show you how to create a set of unit tests using xUnit, NSubstitute and FluentAssertions.

Take a look at the project on GitHub or play with code on .Net Fiddle.

Personal experience

As a part of the development team, I'm working with the above libraries to make sure every single user story is well covered and ready to be delivered. There are several highlights I want to share with you:

  • We have 216 unit tests and 158 integration tests driven by xUnit, NSubstitute, and FluentAssertions
  • We run tests via CI/CD pipeline every time a new pull request is created so you couldn't merge broken code to the master branch
  • We follow the Continuous Delivery approach, which means every master merge is a production release
  • We have no dedicated QA engineers

According to the described points, it's clear we rely on automated tests a lot. They provide a high-quality product for the end-user.

Sample Project

I prefer Rider for development but all of the tools and projects considered here also available for Visual Studio

We are going to test a project called MortgageAdvisor. I definitely wouldn't rely on it buying a house but it is a good one for testing purposes. Let's take a look at the structure:

screely-1619172994042.png

We have 4 services with a well-defined purpose:

  • LoanInterestService returns the actual loan interest based on a client's birth date and the number of months to pay. It has nothing to do with real-life but allows to emulate the calculation of the interest rate under different conditions.
public class LoanInterestService : ILoanInterestService
{
    public decimal GetLoanInterest(DateTime birthDate, int yearsToPay)
    {
        ...
    }
}
  • PersonValidatorService can be used to check whether a person reliable or not for some reasons.
public class PersonValidatorService: IPersonValidatorService
{
    public bool IsValidPerson()
    {
        ...
    }
}
  • MortgageService is responsible for payment details. It calculates the amount of money you can borrow and monthly payment based on the actual loan interest, annual income, and years you are going to pay.
public class MortgageService: IMortgageService
{
    public (decimal, decimal) GetAmountAndMonthlyPayment(decimal loanInterest, decimal annualIncome, int yearsToPay)
    {
        ...
    }
}
  • CalculationService exposes the core API to combine all of the calculated details. Note that it contains the previous services as dependencies. Let's keep it in mind.
public class CalculationService: ICalculationService
{
    private readonly ILoanInterestService _loanInterestService;
    private readonly IPersonValidatorService _personValidatorService;
    private readonly IMortgageService _mortgageService;

    public CalculationService(ILoanInterestService loanInterestService, IPersonValidatorService personValidatorService, IMortgageService mortgageService)
    {
        _loanInterestService = loanInterestService;
        _personValidatorService = personValidatorService;
        _mortgageService = mortgageService;
    }

    public MortgageCalculation GetCalculation(DateTime birthDate, decimal annualIncome, int yearsToPay)
    {
        ...
    }
}
  • MortgageCalculation domain model is introduced to provide the calculation results. It contains a couple of static methods to simplify calculation confirmed or rejected instances construction.
public class MortgageCalculation
{
    public MortgageCalculation(bool isPersonAvailable, decimal? amount, decimal? monthlyPayment)
    {
        IsPersonAvailable = isPersonAvailable;
        Amount = amount;
        MonthlyPayment = monthlyPayment;
    }

    public bool IsPersonAvailable { get; }
    public decimal? Amount { get; }
    public decimal? MonthlyPayment { get; }

    public static MortgageCalculation GetRejectedCalculation() => new(false, null, null);

    public static MortgageCalculation GetConfirmedCalculation(decimal amount, decimal monthlyPayment) => new(true, amount, monthlyPayment);
}

Following the single responsibility principle, we've implemented several simple services. Let's create a xUnit test project to cover all of the services.

xUnit

xUnit is an open-source unit testing tool for the .NET (Core) Framework. It is a foundation that helps you to organize the testing infrastructure.

To add a new xUnit project in Rider IDE you can use the template Unit Test Project with the type xUnit.

Let's take a look at an example:

public class CalculationServiceTests
{
    [Fact]
    public void GetCalculation_InvalidPerson_Rejects()
    {
        ...
    }

    [Theory]
    [InlineData(1983, 42000, 30)]
    [InlineData(1965, 55000, 25)]
    public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
    {
        ...
    }
}

We've created a new class CalculationServiceTests to cover CalculationService. Tests methods are defined by the adding attributes:

  • Fact - identify a "normal" test case with no method arguments applied
  • Theory - identify a parameterized test case to check a subset of data

Data Subset with Theory

While the Fact attribute just mark your method as a test Theory one gives you more flexibility providing the subset of data. The most common option is the InlineData attribute:

[Theory]
[InlineData(1983, 42000, 30)]
[InlineData(1965, 55000, 25)]
public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    ...
}

The InlineData attribute allows you to have multiple tests within the same method. It is cool, especially for checking border cases.

There are two more options to use dynamic data subsets called ClassData and MemberData. You can find more details here.

Context Sharing

You probably want to share common context among multiple test classes and xUnit gives you such opportunities with the following features:

You can set up a test database, run a host application, or cleanup a message queue. Any infrastructure steps should be configured via the options above. You find them useful, especially writing some integration tests.

Summarize

xUnit is a powerful library with a great documentation page you can check for more details. It is supported by both Rider and VisualStudio with the project templates and test runner. You can specify Azure DevOps step to run the unit tests project while CI pipeline and get the results.

Now when we are familiar with the xUnit let's go with the NSubstitute library!

NSubstitute

You probably heard about the importance of low coupling and high cohesion for the designed system. The module should be as independent of any other modules as possible and self-contained with a well-defined purpose.

LoanInterestService, PersonValidatorService and MortgageService suit the description above. They provide specific API and have no dependencies. Let's take a look at the LoanInterestServiceTests class:

public class LoanInterestServiceTests
{
    private readonly ILoanInterestService _loanInterest = new LoanInterestService();

    [Theory]
    [InlineData(1990, 30, "1.8")]
    [InlineData(1988, 20, "1.6")]
    public void GetLoanInterest_ReturnsDefaultInterest(int birthYear, int yearsToPay, string expectedInterestString)
    {
        // Arrange
        var birthDate = new DateTime(birthYear, 1, 1);
        var expectedInterest = Convert.ToDecimal(expectedInterestString);

        // Act
        var loanInterest = _loanInterest.GetLoanInterest(birthDate, yearsToPay);

        // Assert
        Assert.True(loanInterest == expectedInterest);
    }
}

It has no external services to resolve. Just LoanInterestService itself. That makes such modules simple for testing. In real life it is hard to keep them isolated at all, e.g if you want to log errors you need ILogger dependency.

Create Substitute

It is totally fine to inject external modules if you follow the single responsibility principle and combine them to provide more complex behavior. CalculationService is a good example:

public class CalculationServiceTests
{
    private readonly ICalculationService _calculationService;

    public CalculationServiceTests()
    {
        ILoanInterestService loanInterestService = ...;
        IPersonValidatorService personValidatorService = ...;
        IMortgageService mortgageService = ...;

        _calculationService = new CalculationService(
            loanInterestService,
            personValidatorService,
            mortgageService);
    }
    ...
}

To instantiate CalculationService implementation we need to pass LoanInterestService, PersonValidatorService and MortgageService via constructor.

You probably want to have a DI infrastructure for testing projects to use injection. It is NOT an idea of unit testing. A unit test has to test isolated module behavior and have NO dependencies. If you do have them - you do the integration tests.

We can achieve this by implementing dummy services called stubs. But what if you have a huge graph of dependencies? Do you want to add way more code just to test the valuable ones? The best approach is using mocks.

NSubstitute drives you to write mocks most efficiently. It saves your time and makes testing code simple to read and analyze. Let's see how can you add mocks services with NSubstitute API:

public class CalculationServiceTests
{
    private readonly ILoanInterestService _loanInterestService;
    private readonly IPersonValidatorService _personValidatorService;
    private readonly IMortgageService _mortgageService;
    private readonly ICalculationService _calculationService;

    public CalculationServiceTests()
    {
        _loanInterestService = Substitute.For<ILoanInterestService>();
        _personValidatorService = Substitute.For<IPersonValidatorService>();
        _mortgageService = Substitute.For<IMortgageService>();

        _calculationService = new CalculationService(
            _loanInterestService, 
            _personValidatorService, 
            _mortgageService);
    }
    ...
}

And that's it! Substitute.For<T>() allows you to provide a type of dependency and get mocked service as a result. You don't even need to provide constructor parameters for interfaces and classes with the default constructor.

Be careful when specifying a class, as all non-virtual members will be executed. Only virtual members can be recorded or have return values specified

Specify behavior

Going further we want to adjust the behavior of the mocking services in case of calling their methods. For instance, to calculate the mortgage results we need to validate a person, get loan interest and payment details as well:

public class CalculationService: ICalculationService
{
    ...

    public MortgageCalculation GetCalculation(DateTime birthDate, decimal annualIncome, int yearsToPay)
    {
        // IPersonValidatorService.IsValidPerson mock
        if (!_personValidatorService.IsValidPerson())
            return MortgageCalculation.GetRejectedCalculation();

        // ILoanInterestService.GetLoanInterest mock
        var loanInterest = _loanInterestService.GetLoanInterest(birthDate, yearsToPay);

        // IMortgageService.GetAmountAndMonthlyPayment mock
        var (amount, monthlyPayment) = _mortgageService.GetAmountAndMonthlyPayment(loanInterest, annualIncome, yearsToPay);

        return MortgageCalculation.GetConfirmedCalculation(amount, monthlyPayment);
    }
}

Let's check how can we ensure such services fit the expected behavior:

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    // Arrange
    const decimal monthlyPayment = 1200;
    const decimal loanInterest = 1.2m;
    var birthDate = new DateTime(birthYear, 1, 1);
    var amount = monthlyPayment * yearsToPay * 12;

    _personValidatorService
        .IsValidPerson()
        .Returns(true);

    _loanInterestService
        .GetLoanInterest(birthDate, yearsToPay)
        .Returns(loanInterest);

    _mortgageService
        .GetAmountAndMonthlyPayment(loanInterest, annualIncome, yearsToPay)
        .Returns((amount, monthlyPayment));

    // Act
    var calculations = _calculationService.GetCalculation(birthDate, annualIncome, yearsToPay);

    // Assert
    Assert.NotNull(calculations);
    Assert.True(calculations.IsPersonAvailable);
    Assert.Equal(calculations.Amount, amount);
    Assert.Equal(calculations.MonthlyPayment, monthlyPayment);
}

All you need just to call a service method with the expected input arguments and provide a result with the Return option. Here is a couple of options you can find useful to obtain specific arguments:

// Returns for specific arguments
_mortgageService
    .GetAmountAndMonthlyPayment(loanInterest, annualIncome, yearsToPay)
    .Returns(...);

// Returns for matching arguments
_mortgageService
    .GetAmountAndMonthlyPayment(Arg.Any<decimal>(), Arg.Any<decimal>(), Arg.Any<int>())
    .Returns(...);

// Returns for any arguments
_mortgageService
    .GetAmountAndMonthlyPayment(default, default, default)
    .ReturnsForAnyArgs(...);

More Options

A few more manipulations I found useful a lot is throwing an exception and checking a method was called:

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{    
    ...

    // Ensures ILoanInterestService.GetLoanInterest throws an exeption if yearsToPay less than 0
    _loanInterestService
        .GetLoanInterest(birthDate, yearsToPay: -1)
        .Returns(_ => throw new InvalidOperationException($"{nameof(yearsToPay)} should be more that 0"));

    // Asserts IPersonValidatorService.IsValidPerson method was called
    _personValidatorService
        .Received()
        .IsValidPerson();

    // Asserts ILoanInterestService.GetLoanInterest method was called
    _loanInterestService
        .Received()
        .GetLoanInterest(Arg.Any<DateTime>(), yearsToPay);

    // Asserts IMortgageService.GetAmountAndMonthlyPayment method was called    
    _mortgageService
        .ReceivedWithAnyArgs()
        .GetAmountAndMonthlyPayment(default, default, default);

    ...
}

Summarize

We've considered really powerful opportunities you can apply to test lots of cases. However, these are far from all possibilities so I strongly recommend taking a look at the official documentation. You can also look at alternative tools such as Moq to find the options that suit you best. To add `NSubstitute as a Nuget package run the following command:

dotnet add package NSubstitute

FluentAssertions

Unit tests, as well as integration and end-to-end tests, are useful as long as they are easy to write and even easier to read. Having clear assertion can be an option here. Let's see how do we check the expected results:

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    ...

    // Assert
    Assert.NotNull(calculations);
    Assert.True(calculations.IsPersonAvailable);
    Assert.Equal(calculations.Amount, amount);
    Assert.Equal(calculations.MonthlyPayment, monthlyPayment);

    var exception = Assert.Throws<InvalidOperationException>(() => _loanInterestService.GetLoanInterest(birthDate, yearsToPay));
    Assert.Equal($"{nameof(yearsToPay)} should be more that 0", exception.Message);
}

Looks pretty clear until your assertions are simple enough. But even the code above can be much nicer to read with the FluentAssertions:

Assertions API

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    ...

    Action action = () => _loanInterestService.GetLoanInterest(birthDate, yearsToPay: -1);

    // Assert
    calculations.Should().NotBeNull();
    calculations.IsPersonAvailable.Should().BeTrue();
    calculations.Amount.Should().Equals(amount);
    calculations.MonthlyPayment.Should().Equals(monthlyPayment);

    action.Should()
        .Throw<InvalidOperationException>()
        .WithMessage($"{nameof(yearsToPay)} should be more that 0");
}

Fluent style makes it easier to analyze your assertions especially if you have multiple values to check. FluentAssertions provides a set of cool assertions for Dates and Times, Collections, Numeric Types and many others.

Output result

Another great advantage of using FluentAssertions is the failed test output. Let's provide a couple of incorrect assertions on purpose:

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    ...

    const int incorrectAmount = 100894;

    Assert.Equal(calculations.Amount, incorrectAmount);
    //calculations.Amount.Should().Be(incorrectAmount);
}

Then we have the following output:

screely-1619114972588.png

There is no info about the particular check was failed. It is not a problem if you have just one assertion but in more complex cases we had better know exactly the place is broken. Let's uncomment the fluent assertion API and check the output:

screely-1619114980478.png

It looks way more descriptive - you know that calculations.Amount didn't pass the check. What if we have more than one assertion failed? Do I need to fix the problems step by step? Fortunately, you have an option called AssertionScope which allows you to identify all problems at once:

public void GetCalculation_ValidPerson_Confirms(int birthYear, int annualIncome, int yearsToPay)
{
    ...

    const int incorrectAmount = 100894;
    const int incorrectMonthlyPayment = 120;
    const bool incorrectIsPersonAvailable = false;

    using (new AssertionScope())
    {
        calculations.Amount.Should().Be(incorrectAmount);
        calculations.MonthlyPayment.Should().Be(incorrectMonthlyPayment);
        calculations.IsPersonAvailable.Should().Be(incorrectIsPersonAvailable);
    }
}

The output provides all of the Exceptions that happened. It is saving time especially if you deal with the CI pipeline and can fix all of the problems in a single commit:

screely-1619114988091.png

As said by founders, the library was designed to solve the debugging hell not only by using clearly named assertion methods but also by making sure the failure message provides as much information as possible.

But I wouldn't underestimate the power of clear namings - it makes your tests useful, easy to maintain, and support. To add FluentAssertions as a Nuget package run the following command:

dotnet add package FluentAssertions

Conclusion

In the end, we wrote some unit tests and made a couple of improvements with the tools. We learned how to use NSubstitute to isolate the behavior of a specific module, and also felt the power of using the FluentAssertions. Write valuable tests and make your code and product reliable, maintainable and qualitative.

I hope you find it useful. Thanks for reading!

Did you find this article valuable?

Support Daniil Doronkin by becoming a sponsor. Any amount is appreciated!