Better Unit Tests With Custom Builders, Asserts And A Sprinkle of DDD

21/12/2020

Data ostatniej aktualizacji: 11.10.2021 04:56

In modern software development unit tests are de facto standard. I cannot imagine having a new project without unit tests, although ten or more years ago not many people were eager to write such tests. Unit tests help us not only to ensure that our code does what it is supposed to do. What is more important, a good unit test will help us evolve our code providing a safety net for design changes and refactorings. If you are a TDD fan writing unit tests will also help you shape your design and guide you through the development process.

But you have probably heard that code is a liability, not an asset. This is especially true for unit tests code, which does not run in production and does not directly create your organization’s business advantage. As all code, unit test code is exposed to the same laws of software evolution. One of them is Lehman’s law:  “As a system evolves, its complexity increases unless works are done to maintain or reduce it.”​. If you don’t care about your unit tests code quality, its readability, and maintainability the technical debt collector will soon come knocking at your door.

In this post, I will try to show two useful techniques: custom test data builders and asserts. I will also try to point out how certain DDD tactical patterns help write better tests.

Custom test data builders

One of the hardest things to get right in tests and also one of the things that still stops many people from writing tests is the proper setup of test data. In complex business applications to test some new functionality that one has just added, one must set up a set of interconnected objects and these objects must be in proper state. Imagine you are writing tests for the insurance claim handling application. To test such functionality properly, you must provide a policy object with all its connected data, claims history objects, and finally claim data itself. Remember also that to have reliable tests you should bring your objects to the desired state using the same methods you use in your application code. Cheating with reflection is not the right way to go. That is a lot of work, and if you have many tests setting up objects that only differ in a few state information, this means lots of repetition in your code. Also, such code will be a nightmare to maintain as changes to your business classes will have to be propagated to all those places where you build your objects in tests. Some people say mocks are the way to go here, but I am not a big fan of such a solution. In my opinion, we should mock external dependencies, not our own business objects.  

If you don’t want to repeat code that creates objects needed for tests you have two options: Object Mother or Builder. In essence, the Object Mother is a class with methods that gives you ready to use objects in the desired state. Internally it uses methods you normally use in your production code to create an object and manipulate it. The builder, on the other hand, is a class that contains sane defaults for your object (usually the data that will allow you to go quickly with the happy path of your tests) but allows you to customize any of these values. This way when using the builder you only have to customize the data that is relevant to a given test. Internally the Builder combines Builder Pattern with normal constructors and methods you use in your production code.

Let’s have a look at some examples of using a builder. Examples are taken from our sample project showing DDD tactical patterns in simplified loan application processing microservices. The microservice evaluates loan applications based on business rules and allows us to accept or reject such applications.

In the first example, we see a test that checks if a customer’s age in years calculation works correctly. This might seem like a trivial functionality, but believe me – there are many ways in different businesses to calculate this!

[Fact]
public void Customer_Born1974_IsAt2019_45YearsOld()
{
   var customer =  GivenCustomer()
       .BornOn(new DateTime(1974, 6, 26))
       .Build();

   var ageAt2019 = customer.AgeInYearsAt(new DateTime(2019, 1, 1));
  
   ageAt2019.Should().Be(45.Years());
}

Customer class requires many more parameters to be created successfully, but in this test, we are only interested in birthdate. Builder helps us by providing sane defaults for all other required values, yet it gives us the ability to easily set values we are interested in. Notice that the test can almost be read as a regular sentence and is extremely easy to understand even for a non-technical person. DDD tactical patterns contributed a lot to enable us to express projects’ ubiquitous language in code. We see how the Value Object pattern helps us express concepts from the domain and how it simplifies assertions.

Let’s see another example. This time we test the core functionality of our service – loan application evaluation. Here we can see how to build complex objects like Loan Application (an object that is an aggregate root and consists of Customer, Loan, and Property objects).

[Fact]
public void WhenAllRulesAreSatisfied_ScoringResult_IsGreen()
{
   var application = GivenLoanApplication()
       .WithCustomer(customer => customer.WithAge(25).WithIncome(15_000M))
       .WithLoan(loan => loan.WithAmount(200_000).WithNumberOfYears(25).WithInterestRate(1.1M))
       .WithProperty(prop => prop.WithValue(250_000M))
       .Build();

   var score = scoringRulesFactory.DefaultSet.Evaluate(application);
  
   score.Score.Should().Be(ApplicationScore.Green);   
}

Here LoanApplicationBuilder class exposes methods that allow us to reuse builders we have already defined for Customer, Loan, and Property classes. Again code is very simple and readable and uses the ubiquitous language of our project.

In the last example we again use LoanApplicationBuilder, but this time we rely on the defaults for application data.

[Fact]
public void LoanApplication_Accepted_CannotBeRejected()
{
   var application = GivenLoanApplication()
       .Evaluated()
       .Accepted()
       .Build();
  
   var user = GivenOperator().Build();

   Action act = () => application.Reject(user);
  
   act
       .Should()
       .Throw<ApplicationException>()
       .WithMessage("Cannot reject application that is already accepted or rejected");
}

The new thing here is that we use builder methods such as Evaluated, Accepted to tell the builder that we want our object to be moved to a certain state after creation. Internally we will use proper methods on LoanApplication class to achieve this.

Now it is time to analyze how to implement a builder. We will analyze LoanAppplicationBuilder, as it is the most complex case in our sample project. For each class of objects, you want to create in your test you should create a separate builder class. Therefore, LoandApplicationBuilder will be responsible for the creation of one class of objects – instances of LoanApplication class.

As mentioned previously, the builder should have sane defaults for all data we need to create an object.

public class LoanApplicationBuilder
{
   private Operator user = new Operator(new Login("admin"), new Password("admin"), new Name("admin", "admin"), new MonetaryAmount(1_000_000));
   private Customer customer = new CustomerBuilder().Build();
   private Property property = new PropertyBuilder().Build();
   private Loan loan = new LoanBuilder().Build();
   private LoanApplicationNumber applicationNumber = new LoanApplicationNumber(Guid.NewGuid().ToString());
   private bool evaluated = false;
   private LoanApplicationStatus targetStatus = LoanApplicationStatus.New;
   private ScoringRulesFactory scoringRulesFactory = new ScoringRulesFactory(new DebtorRegistryMock());

We create a field for each parameter we want to be customizable and assign default values. Note how we use other builders like CustomerBuilder or PropertyBuilder to reuse functionality and avoid repetitions.

Once we have all the defaults we must provide methods to customize each value. We want to build a fluent interface in order to nicely chain methods when building our objects. So for each customizable property, we create a method that accepts a value to be used and returns our builder instance.

public LoanApplicationBuilder WithNumber(string number)
{
   applicationNumber = new LoanApplicationNumber(number);
   return this;
}

For dependent complex objects, we use their own builders and provide the user with methods that give them ready-to-use instances of these builders.

public LoanApplicationBuilder WithCustomer(Action<CustomerBuilder> customizeCustomer)
{
   var customerBuilder = new CustomerBuilder();
   customizeCustomer(customerBuilder);
   customer = customerBuilder.Build();
   return this;
}

In this example, you can see that method accepts a function that works on the Customer’s builder instance. The implementation provides such instance, the calls provided function, allowing its client to perform customizations, and finally, we build the Customer object that we will use later to construct LoanApplication instance.

The last example of customization methods shows how to give the builder’s clients the ability to express the desired state of the created object. The state that cannot be expressed by providing a new value of one of the constructors’ parameters.

public LoanApplicationBuilder Evaluated()
{
   evaluated = true;
   return this;
}

public LoanApplicationBuilder Rejected()
{
   targetStatus = LoanApplicationStatus.Rejected;
   return this;
}

Here, in the first method, we tell our builder that we want our LoanApplications to be evaluated. In the second, we tell it to create an application with rejected status.

Once we have all the customization methods implemented. It is time to create a method that actually builds our requested object. We add the Build method that creates an instance of LoanApplication class.

public DomainModel.LoanApplication Build()
{
   var application = new DomainModel.LoanApplication
   (
       applicationNumber,
       customer,
       property,
       loan,
       user
   );

   if (evaluated)
   {
       application.Evaluate(scoringRulesFactory.DefaultSet);
   }

   if (targetStatus == LoanApplicationStatus.Accepted)
   {
       application.Accept(user);
   }

   if (targetStatus == LoanApplicationStatus.Rejected)
   {
       application.Reject(user);
   }

   return application;
}

This method uses a standard constructor providing all the required values based on defaults or customizations executed previously. Once we have an instance of the LoanApplication class we can use its business method to move our object into the desired state. Here if the builder’s client requested the object to be evaluated we call the Evaluate method and if the final state of our LoanApplication is Accepted or Rejected, a proper business method is called.

The Last step is to provide a nice way to start using our builder in tests. For such a purpose, we add the static factory method to our builder.

public static LoanApplicationBuilder GivenLoanApplication() => new LoanApplicationBuilder();

Now, in our tests’ code, we can do a static import of our class and start using it following the given / when / then pattern.

using static LoanApplication.TacticalDdd.Tests.Builders.LoanApplicationBuilder;

While the Object Mother pattern is simpler to use as you get ready to use methods that give you complete objects. It works great for simple applications with a limited number of cases. Object Mother has to provide a method for each of your cases. When you have many combinations of required test values, the number of methods grows beyond control, andcontrol and we end up in the maintenance nightmare again. Builder, on the other hand, requires a bit more work to use, but gives us more flexibility and prevents code duplication. As already said, in the case of complex objects under test, I always prefer Builders.

Custom Asserts

Good assertions are critical elements of unit tests. Assertions are there to ensure that functionality under test works. There are many excellent libraries that help us write better tests. Good assertions are the ones that are easy to read, understand, and that produce meaningful messages when tests fail.

I highly recommend using the Fluent Assertions library. This library provides many out-of-the-box assertions for working with value types, reference types, and collections.

It has a nice fluent interface that combined with extension methods allows us to write code that reads like a sentence.

var rule = new CustomerIsNotARegisteredDebtor(new DebtorRegistryMock());
var ruleCheckResult = rule.IsSatisfiedBy(application);
ruleCheckResult.Should().BeTrue();

The second example checks if the result of the age calculation is correct. Here we also take advantage of the fact that Value Objects implement comparison by value not reference.

ageAt2021.Should().Be(47.Years());

The third example shows the assertion of installment calculation for a loan. It also combines powers of Fluent Assertions and Value Objects.

installment.Should().Be(new MonetaryAmount(12_587.78M));

But there are cases when certain comparisons require doing some computation in test or assertion logic is complex, or it may require a combination of checking values from multiple objects in the object graph under test. For such cases, we can help ourselves with Custom Assert objects. Almost all xUnit test libraries give us the ability to create custom assertions and so does the Fluent Assertions library. Before we get into details on how to implement custom assertion, let’s see one in action.

application.Evaluate(scoringRulesFactory.DefaultSet);

application
   .Should()
   .BeRejected()
   .And.HaveRedScore();

Here instead of directly navigating the object graph of our Loan Application object and comparing values to expected ones, we nicely encapsulate assertion logic in methods of custom assertion class.

Let’s see how to implement custom assertions. We need to create a new class, called LoanApplicationAssert. Our class will provide asserts for Loan Application. The loan application class is a reference type therefore we must extend ReferenceTypeAssertions<TSubject, TAssert>.

public class LoanApplicationAssert : ReferenceTypeAssertions<DomainModel.LoanApplication,LoanApplicationAssert>
{
   public LoanApplicationAssert(DomainModel.LoanApplication loanApplication)
       : base(loanApplication)
   {
      
   }

We must provide a constructor which takes an instance of our class under test. It will be available under the Subject property in other assert class methods.

We also add a class with an extension method that will allow us to follow the Should pattern used in the Fluent Assertions library.

public static class LoanApplicationAssertExtension
{
   public static LoanApplicationAssert Should(this DomainModel.LoanApplication loanApplication)
       => new LoanApplicationAssert(loanApplication);
}

Next, we can come back to our LoanApplicationAssert class and add business assertions that we need in tests. In the Subject property, we have access to an instance of our class under test.

public AndConstraint<LoanApplicationAssert> ScoreIsNull()
{
   Subject.Score.Should().BeNull();
   return new AndConstraint<LoanApplicationAssert>(this);
}

This is a very simple example. It only hides how to get to the Score property of the application class. But in your custom asserts you may need to perform some calculations or to write a complex logical expression to check if the subject under test is in a correct state.

Let’s have a look at a more complex example from another project.

public AndConstraint<LimitAssert> HaveSublimit(AssetGroupCode assetGroupCode, MonetaryAmount amount)
{
   var sublimit = Subject.SublimitFor(assetGroupCode);

   Execute
       .Assertion
       .ForCondition(sublimit!=null)
       .FailWith($"Sublimit for {assetGroupCode.AssertGroup} not found");
  
   Execute
       .Assertion
       .ForCondition(sublimit.Amount==amount)
       .FailWith($"Sublimit for {assetGroupCode.AssertGroup} amount should be {amount.Amount} but was {sublimit.Amount.Amount}");

  
   return new AndConstraint<LimitAssert>(this);
}

Here we analyze some objects with a complex structure (a limit with sub-limits). In the first step, we get the sub-limit for the asset group. We assert that it exists. Finally, we assert that it has the expected amount.

Custom assertion classes complete our test toolbox by giving us the ability to encapsulate complex checks into methods of such class. They allow us to write clearer and more readable tests that reassemble business specifications. We can also use them to provide meaningful and helpful error messages when tests fail. With custom assertions, we also avoid test logic duplication, making maintenance and development easier. With help of libraries like Fluent Assertions, most of the work is already done and we have to create our own assert classes only for the most complex, crucial cases.

Summary

Unit tests are crucial for the development and maintenance of complex software systems. Applying the same principles and best practices you apply to production code is required to avoid the accumulation of technical debt in your unit tests code. Patterns like Builder and Custom Assert combined with tactical and strategic DDD helps us write clean, readable code. Code that speaks the project’s ubiquitous language and can almost be read like a regular sentence. Be also aware that builders and custom asserts are another layer of abstraction and as such have advantages and disadvantages. We have already mentioned advantages like test logic reusability, clearer and more concise code that is more immune to changes in business logic implementation. The obvious disadvantage is the additional code you need to write and maintain, which for the small and simple project might be overkill.

Code for this article can be found here.

Wojciech Suwała, Head Architect
Altkom Software & Consulting

In this post, I am going to present how you can use domain-driven design tactical patterns like value object, entity, […]
I liked .NET technology from its inception. In fact I left the dark star of overxmlized J2EE development to join […]
Mikrousługi (ang. microservices) to styl architektoniczny zorientowany na szybkość rozwoju oprogramowania, rozumianą jako  liczba funkcjonalności tworzonych w jednostce czasu oraz […]