This content originally appeared on HackerNoon and was authored by Kazys Račkauskas
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 a 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);
}
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;
}
}
\
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;
}
}
\
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)
);
\
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()
}
);
}
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 NA 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)
);
}
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
...
}
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);
}
}
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 made 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::
It does not support constructor parameters and fails with errors if the target class does not have a parameterless 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 is with Bogus.Faker is:
[Test]
public void BogusTest()
{
var faker = new Faker<InvoiceLine2>();
var invoiceLine = faker.Generate();
Assert.IsNotNull(invoiceLine);
}
\
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);
}
\ 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
}
\ 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;
}
}
\ 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);
}
\ And the output:
{
"ItemCode": "S001",
"UnitCount": 2.0,
"UnitPrice": 100.0,
"Vat": 9.0,
"TotalPrice": 218.00
}
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)
)
);
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);
}
\ It produces the following output::
{
"ItemCode": "ItemCode1",
"UnitCount": 1.0,
"UnitPrice": 1.0,
"Vat": 1.0,
"TotalPrice": 1.01
}
\ 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);
}
}
\ 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);
}
\ And the output:
{
"ItemCode": "S002",
"UnitCount": 1.0,
"UnitPrice": 100.0,
"Vat": 9.0,
"TotalPrice": 109.00
}
\ 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.
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.
\
- An 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 to my needs.
\
- Seamless support for classes that can be initialized using both constructor parameters, setter properties, or a 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":
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
);
}
}
The builder code itself is 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)
);
}
\ Usage:
[Test]
public void EasyTddBuilder()
{
var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder
.Random()
.WithUnitPrice(100)
.WithUnitCount(1)
.Build();
Assert.IsNotNull(invoiceLine);
ToJson(invoiceLine);
}
\ And the output:
{
"ItemCode": "ana0i",
"UnitCount": 1.0,
"UnitPrice": 100.0,
"Vat": 9.0,
"TotalPrice": 109.00
}
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 are not allowed to depend on third-party libraries. The extension generates the code and all is in your project, with no dependencies, no strings attached. Feel free to modify it all is yours. This approach offers all the benefits of 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 losing 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 a similar way as above, by opening the quick action menu and clicking "Generate Builder":
When the builder is already generated the tool offers to open the builder class or to regenerate:
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.
Photo by Markus Spiske on Unsplash
This content originally appeared on HackerNoon and was authored by Kazys Račkauskas
Kazys Račkauskas | Sciencx (2024-08-23T14:00:22+00:00) Introducing Builder: Your Buddy in Test-Driven Development (TDD). Retrieved from https://www.scien.cx/2024/08/23/introducing-builder-your-buddy-in-test-driven-development-tdd/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.