Nos artigos anteriores da série, exploramos como o uso de test builders pode simplificar a construção de testes.
Neste artigo, apresento a biblioteca ForeverFactory, que se propõe a simplificar a implementação de builders ao mesmo tempo que maximiza a reutilização de configurações.
Por que criei a ForeverFactory?
Gosto muito da abordagem de escrever test builders artesanais, totalmente customizados para os cenários de um software, como apresentado neste outro artigo, fazê-los sempre desta forma pode ser bastante repetitivo.
Um dos problemas dessa abordagem, é que toda vez que quisermos customizar a configuração de uma propriedade, precisamos criar um novo método no nosso 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();
// resto do teste
}
1 | Temos um lugar centralizado para configurar uma "pessoa padrão" |
2 | Foi necessário implementar na mão um novo método ComNome(string nome) para permitir customizar cada propriedade |
3 | A partir daí, podemos customizar a propriedade Nome |
E esse é o problema que uma biblioteca bastante popular visa resolver: o NBuilder.
NBuilder
O NBuilder nos permite evitar essa escrita repetitiva de métodos de configuração através de uma interface fluente e extensível.
A título de comparação, o exemplo anterior ficaria assim:
[Fact]
public void Deve_fazer_algo()
{
var pessoa = Builder<Pessoa>().CreateNew()
.With(x => x.Name = "Arnoldo")
.With(x => x.Idade = 27)
.Build();
// resto do teste
}
Como podemos ver no teste acima, o NBuilder torna possível configurar de maneira flexível qualquer propriedade dos objetos que estamos construindo, e isso por si só já resolve o problema da escrita manual desses métodos de customização.
Além disso, o NBuilder também facilita a criação de coleções de objetos, como no exemplo a seguir:
[Fact]
public void Deve_fazer_algo()
{
var dezPessoasCentenarias = Builder<Pessoa>().CreateListOfSize(10)
.With(x => x.Idade = 100)
.Build();
// resto do teste
}
Mas essa flexibidade vem com um custo: fica mais difícil reutilizar cenários comuns.
Imagine um cenário onde nossa suíte de testes contenha 300 testes que precisem de objetos do tipo Pessoa
, todos com CPFs válidos, mas com configurações ligeiramente diferentes.
[Fact]
public void Teste_1()
{
var pessoaCentenaria = Builder<Pessoa>().CreateNew()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build();
// resto do teste
}
// ...
[Fact]
public void Teste_N()
{
var menorDeIdade = Builder<Pessoa>().CreateNew()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 17)
.Build();
// resto do teste
}
Como podemos ver no exemplo acima, apesar da flexibilidade, acabamos esbarrando na duplicação de setup de teste. E este é um problema que busquei resolver com a ForeverFactory tomando inspiração de uma biblioteca do ecossistema Python, chamada FactoryBoy.
FactoryBoy
A biblioteca FactoryBoy permite criar factories reutilizáveis, que podem ser customizados ainda mais para atender cada cenário de teste.
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)
# resto do teste
#...
def test_N(self):
menor_idade = PessoaFactory(idade=17)
# resto do teste
1 | A classe PessoaFactory guarda toda a configuração padrão de uma pessoa, e configura características importantes, como por exemplo, toda pessoa possui um CPF |
2 | Apesar do teste customizar somente a propriedade idade, o resto das propriedades, como o CPF, serão herdadas das configurações definidas na classe PessoaFactory |
Assim, cada teste fica mais focado nos aspectos mais importantes aquele cenário específico, aumentando a consição sem abrir mão de configurar corretamente cada objeto.
Como pudemos ver no exemplo acima, assim como fazíamos nos nossos builders feitos na mão, a biblioteca FactoryBoy nos permite maximizar a reutilização de configurações provendo um ponto centralizado para formalizar essas configurações.
ForeverFactory
Construí a biblioteca ForeverFactory buscando inspiração nessas duas excelentes ferramentas, com o objetivo de juntar essas duas características numa única biblioteca:
-
A flexibilidade de configuração do NBuilder
-
A reutilização de configurações através uma factory centralizada do FactoryBoy
A título de exemplo, a seguir temos um teste simples escrito com 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();
// resto do teste
}
[Fact]
public void Teste_usando_uma_factory_customizada() (2)
{
var pessoaCentenaria = new PessoaFactory()
.With(x => x.Idade = 100)
.Build();
// resto do teste
}
1 | Teste sem factory customizada, como no NBuilder |
2 | Teste com uma factory customizada PessoaFactory , como no FactoryBoy |
Nas seções a seguir, veremos em mais detalhes o que dá pra fazer com o ForeverFactory, e como tirar proveito da biblioteca para escrever testes enxutos e concisos.
Criando objetos com ForeverFactory
Como vimos no exemplo acima, podemos facilmente criar objetos no mesmo estilo do NBuilder:
var pessoaCentenaria = MagicFactory.For<Pessoa>()
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build();
Além disso, também podemos criar coleções de objetos utilizando o método Many(x)
:
var cemPessoas = MagicFactory.For<Pessoa>()
.Many(100)
.With(x => x.CPF = "xxx.xxx.xx-xx")
.With(x => x.Idade = 100)
.Build();
Ao criar coleções de objetos, podemos fazer também algumas configurações extras:
var cemPessoas = MagicFactory.For<Pessoa>()
.Many(100)
.WithFirst(50, x.Idade = 17) (1)
.WithLast(50, x.Idade = 18) (2)
.Build();
1 | As primeiras 50 pessoas terão idade 17 |
2 | As últimas 50 pessoas terão idade 18 |
Factories customizadas
Podemos criar factories customizadas extendendo a classe MagicFactory<T>
:
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);
}
}
1 | O objeto customization nos permite configurar como uma "Pessoa padrão" deve ser construída |
2 | O método Set instrui a factory a inicializar a propriedade com o valor informado. Este valor pode ser sobrescrito posteriormente, caso-a-caso |
Criando conjuntos complexos de objetos
Considere uma factory customizada como a classe PessoaFactory
abaixo:
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);
}
}
Com ela, podemos criar conjuntos mais complexos para nossos cenários de teste, conforme necessário:
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();
1 | Cria 10 instâncias de Pessoa com a configuração { CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 17 } |
2 | Cria 10 instâncias de Pessoa com a configuração { CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 18 } |
3 | Cria 9 instâncias de Pessoa com a configuração { CPF = "xxx.xxx.xxx-xx", Nome = "Albert Einstein", Idade = 100 } |
4 | Cria 1 instância de Pessoa com a configuração { CPF = "xxx.xxx.xxx-xx", Nome = "Stephen Hawking", Idade = 56 } |
Benchmark
Para fins de comparação, montei alguns cenários benchmark de configuração equivalente comparando ForeverFactory
4.0.2 e 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 |
Estes resultados foram obtidos com base nos seguintes testes de benchmark:
[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();
}
Conclusão
A biblioteca ForeverFactory nos permite criar objetos para teste de forma flexível e reaproveitar configurações importantes através da implementação de factories customizadas. Além disso, em cenários equivalentes, chega a ser 6x mais rápida que o NBuilder.
Podemos utilizá-la para criar testes mais focados nos detalhes que importam para cada cenário, e estender cenários base válidos.