Como implementar test data builders em C# com ForeverFactory

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:

  1. A flexibilidade de configuração do NBuilder

  2. 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:

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);
    }
}

Com ela, podemos criar conjuntos mais complexos para nossos cenários de teste, conforme necessário:

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();
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.

Outras opções para compartilhar:
comments powered by Disqus