DEV Community

Cover image for Builder: Your Buddy in Test-Driven Development (TDD)
Kazys
Kazys

Posted on • Originally published at easytdd.dev

Builder: Your Buddy in Test-Driven Development (TDD)

Builder pattern

Today, I will talk about the builder pattern in test-driven development. If you are already working with tests, you have probably noticed how time-consuming it can be to create all the input data. Often, the same set of data, or data with slight differences, is used across many tests in a system's test suite. The Builder helps here. It serves two purposes:

  • The builder allows developers to construct test data objects step by step, using a fluent interface that enhances readability and reduces verbosity.

  • The builder class is an excellent place to define and collect all common and edge case objects. For example, for a Passenger, it could be Man, Woman, Boy, Girl, Infant, etc. For an Itinerary, it could be One-way, Round trip, Direct, Indirect, etc.

For a sake of an example I will take Invoice class, a very simplified version could be something like this:

public class Invoice
{
    public Invoice(
        string invoiceNo, 
        string customer, 
        string countryCode, 
        DateTime invoiceDate, 
        IReadOnlyList<InvoiceLine> lines)
    {
        InvoiceNo = invoiceNo;
        InvoiceDate = invoiceDate;
        Customer = customer;
        CountryCode = countryCode;
        Lines = lines;
    }

    public string InvoiceNo { get; }
    public string Customer { get; }
    public string CountryCode { get; }
    public DateTime InvoiceDate { get; }
    public decimal TotalAmount => Lines.Sum(x => x.TotalPrice);
    public IReadOnlyList<InvoiceLine> Lines { get; }
}

public class InvoiceLine
{
    public InvoiceLine(
        string itemCode, 
        decimal unitCount, 
        decimal unitPrice, 
        decimal vat)
    {
        ItemCode = itemCode;
        UnitCount = unitCount;
        UnitPrice = unitPrice;
        Vat= vat;
    }

    public string ItemCode { get; }
    public decimal UnitCount { get; }
    public decimal UnitPrice { get; }
    public decimal Vat { get; }
    public decimal TotalPrice => UnitCount * UnitPrice * (1 + Vat / 100);
}
Enter fullscreen mode Exit fullscreen mode

To create an Invoice object I have to provide many values to the constructors of Invoice and InvoiceLine. In many cases, only a portion of properties are relevant to specific tests. Here builders come in to help.

Builder for InvoiceLine could look something like this:

public partial class InvoiceLineBuilder
{
    private string _itemCode;
    private decimal _unitCount;
    private decimal _unitPrice;
    private decimal _vat;

    public static implicit operator InvoiceLine(InvoiceLineBuilder builder) => builder.Build();

    public static InvoiceLineBuilder Default()
    {
        return new InvoiceLineBuilder(
            "001",
            1,
            100,
            21
        );
    }

    public InvoiceLineBuilder(
        string itemCode,
        decimal unitCount,
        decimal unitPrice,
        decimal vat)
    {
        _itemCode = itemCode;
        _unitCount = unitCount;
        _unitPrice = unitPrice;
        _vat = vat;
    }

    public InvoiceLine Build()
    {
        return new InvoiceLine(
            _itemCode,
            _unitCount,
            _unitPrice,
            _vat
        );
    }

    public InvoiceLineBuilder WithItemCode(string value)
    {
        _itemCode = value;
        return this;
    }

    public InvoiceLineBuilder WithUnitCount(decimal value)
    {
        _unitCount = value;
        return this;
    }

    public InvoiceLineBuilder WithUnitPrice(decimal value)
    {
        _unitPrice = value;
        return this;
    }

    public InvoiceLineBuilder WithVat(decimal vat)
    {
        _vat = value;
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Builder for Invoice could look something like this:

public partial class InvoiceBuilder
{
    private string _invoiceNo;
    private string _customer;
    private string _countryCode;
    private DateTime _invoiceDate;
    private IReadOnlyList<InvoiceLine> _lines;

    public static implicit operator Invoice(InvoiceBuilder builder) 
        => builder.Build();

    public static InvoiceBuilder Default()
    {
        return new InvoiceBuilder(
            "S001",
            "AB VeryImportantCustomer",
            "SV",
            DateTime.Parse("2024-01-01"),
            new []
            {
                InvoiceLineBuilder
                    .Default()
                    .Build()
            }
        );
    }

    public InvoiceBuilder(
        string invoiceNo,
        string customer,
        string countryCode,
        DateTime invoiceDate,
        IReadOnlyList<InvoiceLine> lines)
    {
        _invoiceNo = invoiceNo;
        _customer = customer;
        _countryCode = countryCode;
        _invoiceDate = invoiceDate;
        _lines = lines;
    }

    public Invoice Build()
    {
        return new Invoice(
            _invoiceNo,
            _invoiceDate,
            _lines
        );
    }

    public InvoiceBuilder WithInvoiceNo(string value)
    {
        _invoiceNo = value;
        return this;
    }

    public InvoiceBuilder WithCustomer(string value)
    {
        _customer = value;
        return this;
    }

    public InvoiceBuilder WithCountryCode(string value)
    {
        _countryCode = value;
        return this;
    }

    public InvoiceBuilder WithInvoiceDate(DateTime value)
    {
        _invoiceDate = value;
        return this;
    }

    public InvoiceBuilder WithLines(IReadOnlyList<InvoiceLine> value)
    {
        _lines = value;
        return this;
    }

    public InvoiceBuilder WithLines(params InvoiceLine[] value)
    {
        _lines = value;
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode

In case when a test needs an Invoice object just for its total price property then Invoice can be created like this:

var invoice = InvoiceBuilder
    .Default()
    .WithLines(
        InvoiceLineBuilder
            .Default
            .WithUnitPrice(158)
    );
Enter fullscreen mode Exit fullscreen mode

As the total price is calculated by summing invoice lines, and the default unit count for the invoice line is 1 then it is enough to set the unit price for the invoice line. If similar functionality is needed in multiple tests we could go further and add the following method to the InvoiceBuilder:

public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice)
{
    return new InvoiceBuilder(
        "S001",
        DateTime.Parse("2023-01-01"),
        new[]
        {
            InvoiceLineBuilder
                .Default()
                .WithUnitPrice(totalPrice)
                .Build()
        }
    );
}
Enter fullscreen mode Exit fullscreen mode

Collection of predefined setups

As mentioned above, the builder class is a great place to collect all common and edge cases for the class. Here, I will provide a few of those possible cases.:

  • An invoice with items having regular VAT

  • An invoice with items having reduced VAT

  • An invoice with items having mixed VAT

  • An invoice to an EU country

  • An invoice to a USA country

  • An invoice to China

From my point of view, it is a great place to gather knowledge about the different cases our system handles. It serves as a useful knowledge base for new developers to understand what the system needs to manage. If I'm new to a field, I might not even think of possible edge cases. Here is a code example from some of the cases mentioned above:

public static InvoiceBuilder ForEUCountry()
{
    return Default()
        .WithCountryCode("SV");
}

public static InvoiceBuilder ForUSA()
{
    return Default()
        .WithCountryCode("USA");
}

public static InvoiceBuilder ForChina()
{
    return Default()
        .WithCountryCode("CN");
}

public InvoiceBuilder WithRegularVat()
{
    return this
        .WithLines(
            InvoiceLineBuilder
                .Default
                .WithItemCode("S001")
                .WithVat(21),
            InvoiceLineBuilder
                .Default
                .WithItemCode("S002")
                .WithVat(21)
        );
}

public InvoiceBuilder WithReducedVat()
{
    return this
        .WithLines(
            InvoiceLineBuilder
                .Default
                .WithItemCode("S001")
                .WithVat(9),
            InvoiceLineBuilder
                .Default
                .WithItemCode("S002")
                .WithVat(9)
        );
}

public InvoiceBuilder WithMixedVat()
{
    return this
        .WithLines(
            InvoiceLineBuilder
                .Default
                .WithItemCode("S001")
                .WithVat(21),
            InvoiceLineBuilder
                .Default
                .WithItemCode("S002")
                .WithVat(9)
        );
}
Enter fullscreen mode Exit fullscreen mode

Now we can create a mix of the above. For example, if a test case needs an invoice for an EU customer with invoice lines that have mixed VAT, I can do the following:

[Test]
public void SomeTest()
{
    //arrange
    var invoice = InvoiceBuilder
        .ForEU()
        .WithMixedVat();

    //act
    ...
    //assert
    ...
}
Enter fullscreen mode Exit fullscreen mode

This is just a simple example, but I hope you understand the concept.

A builder is useful when we have a large, complex object, but only a few fields are relevant to the test.

Another useful case is when I want to test multiple scenarios based on specific values. All properties except one remain the same, and I change only one. This makes it easier to highlight the difference, which causes the service or object to behave differently.

Ways to create the builder class

Code with your own hands

First, you can create a builder class on your own. This doesn't require any initial investment of time or money, and you have a lot of freedom in how you build it. Copying, pasting, and replacing can be useful, but it still takes quite a bit of time, especially for larger classes.

Create Your Own Code Generator

When I started with code generation, I began by setting up a single test for it. This test didn't actually test anything; it just accepted a type, retrieved all properties using reflection, created a builder class from a hardcoded template, and wrote it to the test runner output window. All I had to do was create a class file and copy/paste the content from the test runner's output window.

BuilderGenerator

All about BuilderGenerator can be found here. It explains the .NET Incremental Source Generator. This means the builder code is regenerated live when the target class changes. So, there's no hassle or manual work compared to the methods above. Just create a builder class, add the BuilderFor attribute with the target class type, and all With methods are generated automatically and ready to use.

[BuilderFor(typeof(InvoiceLine))]
public partial class InvoiceLineBuilder
{
    public static InvoiceLineBuilder Default()
    {
        return new InvoiceLineBuilder()
            .WithItemCode("S001")
            .WithUnitCount(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

I haven't worked with it much, but it seems to have a wide user base with 82.7K downloads at the time of writing. I noticed a couple of issues that make me choose other options:

  • The solution fails to build if the builder class is in a different project than the target class. It can be in another project, but the namespace must remain the same. Otherwise, you will see the following errors:

BuilderGenerator error when builder is in the other project.

  • It does not support constructor parameters and fails with errors if the target class does not have a parameterless constructor.:

BuilderGenerator error when a target class does not have a default constructor.

Let's explore what other options we have.

Bogus.Faker generator

This is a very popular library with over 82.2M total downloads (and 186.1K for the current version) at the time of writing. As the author of the library states, it is a fake data generator capable of producing numerous objects based on predefined rules. It isn't exactly what the builder pattern is in TDD, but it can be adapted. There are several ways to use Bogus.Faker, but I will focus on how to mimic the builder pattern here.

The simplest way to create an object with Bogus.Faker is:


[Test]
public void BogusTest()
{
    var faker = new Faker<InvoiceLine2>();

    var invoiceLine = faker.Generate();

    Assert.IsNotNull(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

It creates an instance of InvoiceLine2 with default values, which means nulls and zeros. To set some values, I will use the following setup:

[Test]
public void BogusTest()
{
    var faker = new Faker<InvoiceLine2>()
        .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5))
        .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000))
        .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5))
        .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0));

    var invoiceLine = faker.Generate();

    Assert.IsNotNull(invoiceLine);

    ToJson(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

The code above creates an invoice line object with random values. An example might look like this:

{
  "ItemCode": "gwg7y",
  "UnitCount": 3.0,
  "UnitPrice": 597.035612417891230,
  "Vat": 0.0,
  "TotalPrice": 1791.106837253673690
}
Enter fullscreen mode Exit fullscreen mode

It is useful, but each test requires its own setup. Instead, we can create a builder class:

public class InvoiceLineBuilder: Faker<InvoiceLine2>
{
    public static InvoiceLineBuilder Default()
    {
        var faker = new InvoiceLineBuilder();

        faker
            .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5))
            .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000))
            .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5))
            .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0));

        return faker;
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage would look something like this:

[Test]
public void BogusTest()
{
    var faker = TestDoubles.Bogus.InvoiceLineBuilder
        .Default()
        .RuleFor(x => x.ItemCode, f => "S001")
        .RuleFor(x => x.UnitPrice, f => 100);

    var invoiceLine = faker.Generate();

    Assert.IsNotNull(invoiceLine);

    ToJson(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

And the output:

{
  "ItemCode": "S001",
  "UnitCount": 2.0,
  "UnitPrice": 100.0,
  "Vat": 9.0,
  "TotalPrice": 218.00
}
Enter fullscreen mode Exit fullscreen mode

From my perspective, it is a bit more verbose than the regular Builder Pattern. Additionally, I am not a fan of using random values. It is not a big problem, but issues arise when a class's properties are initialized using a constructor and it doesn't have setters. Then it doesn't work as a builder, and each setup becomes static.

var faker = new InvoiceLineBuilder();

faker
    .CustomInstantiator(f => 
        new InvoiceLine(
            f.Random.AlphaNumeric(5),
            f.Random.Decimal(10, 1000),
            f.Random.Number(1, 5),
            f.PickRandom(21, 9, 0)
        )
    );
Enter fullscreen mode Exit fullscreen mode

NBuilder

This is also a very popular library with over 13.2 million total downloads (and 7.2 million for the current version). Though it has not been actively developed recently, the last version was released in 2019. Essentially, it is very similar to Bogus.Faker. It should even be possible to reuse Bogus for providing random values by implementing a specific IPropertyNamer.

Let's try using it without setting any properties:

[Test]
public void NBuilderTest()
{
    var invoiceLine = Builder<InvoiceLine2>
        .CreateNew()
        .Build();

    Assert.IsNotNull(invoiceLine);

    ToJson(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

It produces the following output::

{
  "ItemCode": "ItemCode1",
  "UnitCount": 1.0,
  "UnitPrice": 1.0,
  "Vat": 1.0,
  "TotalPrice": 1.01
}
Enter fullscreen mode Exit fullscreen mode

The aim of this post is to show how to create a reusable builder class. Let's get started:

public class InvoiceLineBuilder
{
    public static ISingleObjectBuilder<InvoiceLine2> Default()
    {
        return Builder<InvoiceLine2>
            .CreateNew()
            .With(x => x.ItemCode, "S001")
            .With(x => x.UnitCount, 1)
            .With(x => x.UnitPrice, 100)
            .With(x => x.Vat, 21);
    }
}
Enter fullscreen mode Exit fullscreen mode

And here is the usage:

[Test]
public void NBuilderTest()
{
    var invoiceLine = TestDoubles.NBuilder.InvoiceLineBuilder
        .Default()
        .With(x => x.ItemCode, "S002")
        .With(x => x.Vat, 9)
        .Build();

    Assert.IsNotNull(invoiceLine);

    ToJson(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

And the output:

{
  "ItemCode": "S002",
  "UnitCount": 1.0,
  "UnitPrice": 100.0,
  "Vat": 9.0,
  "TotalPrice": 109.00
}
Enter fullscreen mode Exit fullscreen mode

Similar to Bogus.Faker, you cannot override values if a class property is set using a constructor and does not have a setter. If you try to use the With method for such a property, it will fail with the following exception:

 System.ArgumentException : Property set method not found.
Enter fullscreen mode Exit fullscreen mode

EasyTdd.Generators.Builder

EasyTdd.Generators.Builder is a Nuget package and works in tandem with the EasyTdd - the Visual Studio Extention. This package leverages a .NET incremental source generator to create builders from templates used by the EasyTdd extension. The builder generator handles property setters, constructor parameters, and a combination of both. It also supports generic parameters.

This is my preferred way to create a builder. Here are the benefits compared to the other options:

  • The builder class is generated just with a few clicks.

  • Incremental source generator is used for the builder class generation. This causes builder class automatic updates on every change in the source class.

  • Templating support. You can easily adapt the template for my needs.

  • Seamless support for classes that can be initialized using both constructor parameters, setter properties or mix of both.

  • Generic class support.

When the EasyTdd is installed in the Visual Studio, open the quick action menu on the target class and select "Generate Incremental Builder":

Select

This action creates a partial builder class with the BuilderFor attribute set:

[EasyTdd.Generators.BuilderFor(typeof(InvoiceLine))]
public partial class InvoiceLineBuilder
{
    public static InvoiceLineBuilder Default()
    {
        return new InvoiceLineBuilder(
            () => default, // Set default itemCode value
            () => default, // Set default unitCount value
            () => default, // Set default unitPrice value
            () => default  // Set default vat value
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The builder code itself in generated in the background, and this partial class is intended for common/edge case setups. Feel free to set default values instead of default.

More about setting it up and how it works can be found here.

The good part is that if I need random values, I can use Bogus here:

public static InvoiceLineBuilder Random()
{
    var f = new Faker();

    return new InvoiceLineBuilder(
        () => f.Random.AlphaNumeric(5),
        () => f.Random.Decimal(10, 1000),
        () => f.Random.Number(1, 5),
        () => f.PickRandom(21, 9, 0)
    );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

[Test]
public void EasyTddBuilder()
{
    var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder
        .Random()
        .WithUnitPrice(100)
        .WithUnitCount(1)
        .Build();

    Assert.IsNotNull(invoiceLine);

    ToJson(invoiceLine);
}
Enter fullscreen mode Exit fullscreen mode

And the output:

{
  "ItemCode": "ana0i",
  "UnitCount": 1.0,
  "UnitPrice": 100.0,
  "Vat": 9.0,
  "TotalPrice": 109.00
}
Enter fullscreen mode Exit fullscreen mode

Pure EasyTdd

The EasyTdd also offers full builder code generation without the dependency to EasyTdd.Generators Nuget package. This is useful if you do not want or you are not allowed to depend on third party libraries. The extention generates the code and all is in your project, no dependencies, no strings attached. Feel free to modify it all is yours. This approach offers all the benefits as with EasyTdd.Generators case, except automatic regeneration on target class changes. In this case the builder needs to be regenerated manually (also with a few clicks). Two files are generated to void loosing the setups on regeneration. One file contains the builder class declaration, with all necessary methods, the other is intended just only for setups and additional methods, which are not intended for regeneration. The class can be generated in similar way as above, by opening the quick action menu and clicking "Generate Builder":

Select

When the builder is already generated the tool offers to open the builder class or to regenerate:

Select option to option or regenerate a builder.

Summary

In this blog post, I introduced the builder pattern and its use in test-driven development. I also showed several ways to implement it, starting from manual implementation, using third-party libraries like Bogus.Faker and NBuilder, incremental code generators like BuilderGenerator and EasyTdd.Generators.Builder, and finally, having all code generated by the EasyTdd Visual Studio extension. Each method has its strengths and weaknesses and works well in simple cases. However, when dealing with immutable classes, EasyTdd stands out by handling property changes equally, whether a property value is initialized by a setter or through a constructor parameter. EasyTdd supports templates and allows you to customize the output to match your preferences. EasyTdd also surpasses other methods of implementing a builder due to its speed of implementation. It provides tools in Visual Studio to generate files automatically with just a few clicks, saving time and effort.

Top comments (2)

Collapse
 
heyeasley profile image
heyeasley 🍓🥭

Cool. Discovering!

Collapse
 
easytdd profile image
Kazys

Thanks!