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:
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 templateUnit Test Project
with the typexUnit
.
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 appliedTheory
- 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
andMemberData
. 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:
- setup and cleanup with Constructor and Dispose
- share context among tests in class with Class Fixture
- share context among multiple test classes with Collection Fixture
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:
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:
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:
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!