How to implement test data builders in C# with ForeverFactory
In this article, I introduce ForeverFactory, an open-source library I wrote to make creating objects for tests easier.
In the previous articles in this series, we explored how test builders can simplify writing tests.
In this article, I introduce the ForeverFactory library, which aims to simplify implementing builders while maximizing configuration reuse.
Why did I create ForeverFactory?
I really like the approach of writing handcrafted test builders, fully tailored to a piece of software’s scenarios, as shown in this other article, but always writing them this way can get pretty repetitive.
One of the problems with this approach is that every time we want to customize how a property is set, we have to add a new method to our builder:
[Fact]
public class PessoaBuilder
{
private int? _idade;
private string _nome;
private string _cpf;
// ...
public Pessoa Construir()
{
return new Pessoa()
{
CPF = _cpf ?? "xxx.xxx.xxx-xx", // <1>
Nome = _nome ?? "Ana",
Idade = _idade ?? 18
// ...
};
}
public PessoaBuilder ComCPF(string cpf)
{
_cpf = cpf;
return this;
}
public PessoaBuilder ComIdade(int idade)
{
_idade = idade;
return this;
}
public PessoaBuilder ComNome(string nome) // <2>
{
_nome = nome;
return this;
}
// ...
}
public void Deve_fazer_algo()
{
var pessoa = new PessoaBuilder()
.ComNome("Arnoldo") // <3>
.ComIdade(27)
.Construir();
// rest of the test
} - We have a central place to configure a “default person”
- We had to hand-write a new
ComNome(string nome)method to allow customizing each property - From then on, we can customize the
Nomeproperty
And that’s the problem a very popular library sets out to solve: NBuilder.
NBuilder
NBuilder lets us skip writing those configuration methods over and over, thanks to a fluent, extensible interface.
For comparison, the previous example would look like this:
[Fact]
public void Deve_fazer_algo()
{
var pessoa = Builder<Pessoa>().CreateNew()
.With(x => x.Name = "Arnoldo")
.With(x => x.Idade = 27)
.Build();
// rest of the test
} As the test above shows, NBuilder makes it possible to flexibly configure any property of the objects we’re building, and that alone solves the problem of writing these customization methods by hand.
NBuilder also makes it easy to create collections of objects, as in the following example:
[Fact]
public void Deve_fazer_algo()
{
var dezPessoasCentenarias = Builder<Pessoa>().CreateListOfSize(10)
.With(x => x.Idade = 100)
.Build();
// rest of the test
} But that flexibility comes at a cost: reusing common scenarios gets harder.
Imagine our test suite has 300 tests that need Pessoa (person) objects, all with valid CPFs (the Brazilian taxpayer ID), but with slightly different configurations.
[Fact]
public void Teste_1()
{
var pessoaCentenaria = Builder<Pessoa>().CreateNew()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build();
// rest of the test
}
// ...
[Fact]
public void Teste_N()
{
var menorDeIdade = Builder<Pessoa>().CreateNew()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 17)
.Build();
// rest of the test
} As the example above shows, despite the flexibility, we end up running into duplicated test setup. That’s a problem I set out to solve with ForeverFactory, drawing inspiration from a library in the Python ecosystem called FactoryBoy.
FactoryBoy
The FactoryBoy library lets us create reusable factories, which can be customized further to fit each test scenario.
import factory
class Pessoa:
def __init__(self, cpf, idade, nome):
self.cpf = cpf
self.idade = idade
self.nome = nome
class PessoaFactory(factory.Factory):
class Meta:
model = Pessoa
cpf = "xxx.xxx.xxx-xx" # <1>
idade = 18
nome = "Ana"
class PessoaTests(unittest.TestCase):
def test_1(self):
pessoa_centenaria = PessoaFactory(idade=100) # <2>
# rest of the test
#...
def test_N(self):
menor_idade = PessoaFactory(idade=17)
# rest of the test - The
PessoaFactoryclass holds the whole default configuration of a person and sets important characteristics, for example, every person has a CPF - Even though the test customizes only the age property, the remaining properties, such as the CPF, are inherited from the configuration defined in the
PessoaFactoryclass
That way, each test stays focused on what matters most for that specific scenario, making it more concise without giving up on configuring each object correctly.
As the example above shows, just like with our handwritten builders, FactoryBoy lets us maximize configuration reuse by providing a central place to formalize those configurations.
ForeverFactory
I built the ForeverFactory library drawing inspiration from these two excellent tools, aiming to bring both of these traits together in a single library:
- NBuilder’s configuration flexibility
- FactoryBoy’s configuration reuse through a central factory
As an example, here is a simple test written with ForeverFactory:
[Fact]
public void Teste_sem_factory_customizada() // <1>
{
var pessoaCentenaria = MagicFactory.For<Pessoa>()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build();
// rest of the test
}
[Fact]
public void Teste_usando_uma_factory_customizada() // <2>
{
var pessoaCentenaria = new PessoaFactory()
.With(x => x.Idade = 100)
.Build();
// rest of the test
} - A test without a custom factory, as in NBuilder
- A test with a custom
PessoaFactoryfactory, as in FactoryBoy
In the following sections, we’ll look in more detail at what ForeverFactory can do and how to use the library to write lean, concise tests.
Creating objects with ForeverFactory
As we saw in the example above, we can easily create objects in the same style as NBuilder:
var pessoaCentenaria = MagicFactory.For<Pessoa>()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build(); We can also create collections of objects using the Many(x) method:
var cemPessoas = MagicFactory.For<Pessoa>()
.Many(100)
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build(); When creating collections of objects, we can also apply some extra configuration:
var cemPessoas = MagicFactory.For<Pessoa>()
.Many(100)
.WithFirst(50, x => x.Idade = 17) // <1>
.WithLast(50, x => x.Idade = 18) // <2>
.Build(); - The first 50 people will be 17 years old
- The last 50 people will be 18 years old
Custom factories
We can create custom factories by extending the MagicFactory<T> class:
public class PessoaFactory : MagicFactory<Pessoa>
{
protected override void Customize(ICustomizeFactoryOptions<Pessoa> customization)
{
customization // <1>
.Set(x => x.CPF = "xxx.xxx.xxx-xx") // <2>
.Set(x => x.Nome = "Albert Einstein")
.Set(x => x.Age = 56);
}
} - The
customizationobject lets us configure how a “default Pessoa” should be built - The
Setmethod tells the factory to initialize the property with the given value. This value can be overridden later, case by case
Building complex sets of objects
Consider a custom factory like the PessoaFactory class below:
PessoaFactory.cs
public class PessoaFactory : MagicFactory<Pessoa>
{
protected override void Customize(ICustomizeFactoryOptions<Pessoa> customization)
{
customization
.Set(x => x.CPF = "xxx.xxx.xxx-xx")
.Set(x => x.Nome = "Albert Einstein")
.Set(x => x.Age = 56);
}
} With it, we can build more complex sets for our test scenarios, as needed:
Teste.cs
var trintaPessoas = new PessoaFactory()
.Many(10).With(x => x.Idade = 17) // <1>
.Plus(10).With(x => x.Idade = 18) // <2>
.Plus(9).With(x => x.Idade = 100) // <3>
.PlusOne().With(x => x.Nome = "Stephen Hawking") // <4>
.Build(); - Creates 10
Pessoainstances with the configuration{ CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 17 } - Creates 10
Pessoainstances with the configuration{ CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 18 } - Creates 9
Pessoainstances with the configuration{ CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 100 } - Creates 1
Pessoainstance with the configuration{ CPF = "xxx.xxx.xxx-xx", Nome = "Stephen Hawking", Idade = 56 }
Benchmark
For comparison, I put together a few benchmark scenarios with equivalent configuration, comparing ForeverFactory 4.0.2 and NBuilder 6.1.0:
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Allocated |
|---|---|---|---|---|---|---|
| BuildSingleObjectForeverFactory | 683.6 ns | 7.34 ns | 6.86 ns | 0.1373 | - | 1,152 B |
| BuildSingleObjectNBuilder | 1,939.7 ns | 20.81 ns | 19.47 ns | 0.0935 | - | 784 B |
| BuildThousandObjectsForeverFactory | 243,524.0 ns | 3,502.33 ns | 2,924.61 ns | 53.7109 | 5.8594 | 449,403 B |
| BuildThousandObjectsNBuilder | 1,555,241.7 ns | 17,075.19 ns | 14,258.56 ns | 76.1719 | 15.6250 | 653,402 B |
These results came from the following benchmark tests:
[Benchmark]
public void BuildSingleObjectForeverFactory()
{
MagicFactory.For<Person>().With(x => x.Name = PersonName).Build();
}
[Benchmark]
public void BuildSingleObjectNBuilder()
{
Builder<Person>.CreateNew().With(x => x.Name = PersonName).Build();
}
[Benchmark]
public void BuildThousandObjectsForeverFactory()
{
MagicFactory.For<Person>().Many(1000).With(x => x.Name = PersonName).Build().ToList();
}
[Benchmark]
public void BuildThousandObjectsNBuilder()
{
Builder<Person>.CreateListOfSize(1000).All().With(x => x.Name = PersonName).Build();
} Conclusion
The ForeverFactory library lets us create test objects flexibly and reuse important configuration by implementing custom factories. On top of that, in equivalent scenarios, it’s up to 6x faster than NBuilder.
We can use it to write tests that focus on the details that matter for each scenario, and to extend valid base scenarios.