Simplifying test setup in C# with Test Data Builders
Is your test setup tedious and repetitive? Make your life easier with Test Data Builders! In this article we show how they work and how they simplify your tests.
Building a good unit test suite for our projects can be very rewarding, and in most cases, it’s indispensable. But if we’re not careful, maintaining that suite can become tedious and even terribly laborious.
Let’s look at a very common scenario, one that can be easily solved with the Builder design pattern applied to tests to get what we call test data builders, or simply test builders. Throughout this article and any others that follow, I’ll call these builders test builders.
There are very good libraries that can help us set up these builders, like NBuilder, but in this article we’ll focus on handcrafting builders designed specifically for the application.
Repetitive tests
Let’s say that in our imaginary system, we have a business object like this one:
public class Cliente
{
public int Codigo { get; set; }
public string Nome { get; set; }
public string Cpf { get; set; }
} A real class representing a customer would probably be much bigger, but this one is enough for our exercise.
Now let’s imagine a simple test scenario that needs to pass a customer to some routine.
[Fact]
public void Deve_persistir_um_novo_cliente()
{
var cliente = new Cliente
{
Nome = "John Doe",
Cpf = "071.777.111-89"
};
var codigo = _repositorio.Salvar(cliente);
//... some validation code
} As our application grows, more and more scenarios show up that need to instantiate a customer to test some business rule. The customer setup is very similar in all of them, but depending on the day, the developer, and the alignment of the stars, the setups start to diverge, and maybe not all of the scenarios are even valid.
As we can see in the next example, a few weeks and many tests after that first test scenario we saw a bit earlier, a developer wrote a new test to validate a customer’s CPF (the Brazilian individual taxpayer ID).
[Fact]
public void Deve_garantir_que_o_cliente_possui_cpf_valido()
{
var cliente = new Cliente
{
Codigo = 2,
Nome = "Terminator",
Cpf = "07166611189"
};
var cpfValido = _validadorCpfCliente.Validar(cliente);
cpfValido.Should().BeTrue();
} We can already see differences in how the CPF property is set up in each scenario: some have formatted CPFs, others don’t; some CPFs might be valid, but most aren’t.
And then the day comes when the team decides that, to fix inconsistencies in how the customer’s CPF is handled, they’ll implement a rich CPF object that validates itself.
The CPF class looks more or less like this:
public class CPF
{
private readonly long _valor;
public CPF(long cpf)
{
ValidarCpf(cpf);
_valor = cpf;
}
public void ValidarCpf(long cpf) {...}
public override string ToString() {...}
} The team is happy with this implementation and decides that the customer’s CPF property should change from string to CPF.
But then they realize the customer object is instantiated in 652 tests, in different ways.
Someone suggests they could easily make the change with a find-and-replace (CTRL+H), but then realizes the CPF class is instantiated from a long, not a String, and that they’ll have to fix all 652 tests by hand.
The problem they’re facing comes from the high coupling between the test scenarios and the production code.
This is the textbook case where using Test Builders could have saved a big headache.
Test Builders
Test Builders can help us in several ways:
- Avoiding duplication in test setup
- Ensuring the consistency of the objects they build
- Improving the readability of test scenarios
Next, we’ll look into how a well-built Test Builder can help us, and how it affects our test suites.
Avoiding duplication with Test Builders
Let’s start by imagining how things would have gone if the fictional team from the previous scenario had used a Test Builder from the second or third scenario where they needed to instantiate a customer in a test.
In its simplest form, a ClienteBuilder is nothing more than a simple factory that returns a standardized Cliente instance.
public class ClienteBuilder
{
public ClienteBuilder()
{
return new Cliente
{
Codigo = 0,
Nome = "Terminator",
Cpf = "722.111.333-64"
};
}
} Then every scenario would instantiate new customers like this:
[Fact]
public void Teste_que_nao_se_importa_com_o_cpf()
{
var cliente = new ClienteBuilder().Construir();
//... rest of the test scenario
}
[Fact]
public void Teste_que_precisa_configurar_um_cpf_especifico()
{
var cliente = new ClienteBuilder()
.ComCpf("071.722.111-90")
.Construir();
//... test that validates the CPF
} If the team now decides to turn the customer’s CPF property from String into CPF, it’ll be very simple, because all it takes is making the change in the builder itself, just once:
public class ClienteBuilder
{
private string cpf;
public ClienteBuilder()
{
const string cpfValidoString = "946.549.990-00";
var cpfString = (cpf ?? cpfValidoString)
.Replace(".", "")
.Replace("-", "");
return new Cliente
{
Codigo = 0,
Nome = "Terminator",
Cpf = new CPF(valor: long.Parse(cpfString))
};
}
public ClienteBuilder ComCpf(string cpf){
this._cpf = cpf;
return this;
}
} And if some test starts failing, it’ll probably be because the CPF it provided wasn’t valid in the first place.
Finally, let’s imagine the team decided the Cliente class should no longer have public setters, and that all fields will be filled in through the constructor:
public class Cliente
{
public int Codigo { get; }
public string Nome { get; }
public CPF Cpf { get; }
public Cliente(int codigo, string nome, CPF cpf)
{
Codigo = codigo;
Nome = nome;
Cpf = cpf;
}
} If it weren’t for the Test Builders (or at least some kind of factory), we’d have to fix every test scenario. But since we have our ClienteBuilder, it’s the only thing that needs to change, and every test scenario survives the change unscathed.
Below is a fully customizable ClienteBuilder, using this new constructor:
public class ClienteBuilder
{
private int? _codigo;
private string nome;
private string cpf;
public ClienteBuilder()
{
const string cpfValidoString = "946.549.990-00";
var cpfString = (cpf ?? cpfValidoString)
.Replace(".", "")
.Replace("-", "");
return new Cliente(
codigo: _codigo ?? 0,
nome: nome ?? "Terminator",
cpf: new CPF(valor: long.Parse(cpfString))
);
}
public ClienteBuilder ComCodigo(int codigo){
this._codigo = codigo;
return this;
}
public ClienteBuilder ComNome(string nome){
this._nome = nome;
return this;
}
public ClienteBuilder ComCpf(string cpf){
this._cpf = cpf;
return this;
}
} And a test that sets up every field in a readable, easy-to-follow way:
[Fact]
public void Teste_que_precisa_configurar_um_cpf_especifico()
{
var cliente = new ClienteBuilder()
.ComCodigo(42)
.ComNome("Pensador Profundo")
.ComCpf("049.908.620-15")
.Construir();
//... some test logic
} As we saw in the examples above, well-implemented test builders help decouple the tests from the code under test, while also producing test scenarios that are more consistent and easier to read.
Building consistent objects with Test Builders
Builders can also be used to prevent the creation of objects that are invalid from a business standpoint, and that ends up having a beneficial side effect on our tests: they become more realistic.
A common example is properties that need to be set together to make sense.
Let’s go with an easy-to-picture example: we need to simulate an HTTP request to a legacy system that returns an object of type PrecoResponse.
public class PrecoResponse
{
public int CodigoProduto { get; set; }
public float Valor { get; set; }
public int Tipo { get; set; }
public string DescricaoTipo { get; set; }
public int CodigoTabela { get; set; }
public int InicioFaixa { get; set; }
public int FimFaixa { get; set; }
} Let’s also assume we have two rules to observe in this integration:
TipoandDescricaoTipoare always updated together. There are three price types, the first two of which come from a price table;- Some prices are part of a price table applied by quantity. These types follow a range of quantities of products sold. When
CodigoTabelais zero,InicioFaixaandFimFaixawill be zero too.
This business rule is a bit complicated, and if our builder isn’t implemented carefully, these rules won’t be clear in the tests, or worse, several scenarios might make no sense at all!
We can make sure every scenario is built according to these rules simply by translating them into the interface of PrecoResponseBuilder:
public enum TipoPreco
{
Praticado = 1, // from price table
Previsto = 2, // from price table
Minimo = 3
}
public class PrecoResponseBuilder
{
private int? _codigoProduto = null;
private float? _valor = null;
private TipoPreco? _tipo = null;
private string _descricaoTipo = null;
private int? _codigoTabela = null;
private int? _inicioFaixa = null;
private int? _fimFaixa = null;
public PrecoResponseBuilder Construir()
{
return new PrecoResponseBuilder
{
CodigoProduto = _codigoProduto ?? 1,
Valor = _valor ?? 100f,
Tipo = (int)(_tipo ?? TipoPreco.Praticado),
DescricaoTipo = _descricaoTipo ?? "PREVISTO",
CodigoTabela = _codigoTabela ?? 1,
InicioFaixa = _inicioFaixa ?? 1,
FimFaixa = _fimFaixa ?? 100
};
}
public PrecoResponseBuilder DoTipoMinimo()
{
return this.DoTipo(TipoPreco.Minimo);
}
public PrecoResponseBuilder DoTipoPraticado(
int codigoTabela,
int inicioFaixa, int fimFaixa)
{
return this
.DoTipo(TipoPreco.Praticado)
.ComTabelaPrecos(codigoTabela, inicioFaixa, fimFaixa);
}
public PrecoResponseBuilder DoTipoPrevisto(
int codigoTabela,
int inicioFaixa, int fimFaixa)
{
return this
.DoTipo(TipoPreco.Previsto)
.ComTabelaPrecos(codigoTabela, inicioFaixa, fimFaixa);
}
private PrecoResponseBuilder DoTipo(TipoPreco tipoPreco)
{
this._tipoPreco = TipoPreco.Praticado;
this._descricaoPreco = TipoPreco.Praticado.ToString();
return this;
}
private PrecoResponseBuilder ComTabelaPrecos(
int codigoTabela,
int inicioFaixa, int fimFaixa)
{
this._codigoTabela = codigoTabela;
this._inicioFaixa = inicioFaixa;
this._fimFaixa = fimFaixa;
return this;
}
} As we can see in the example above, it’ll be very hard to create invalid states using this PrecoResponseBuilder. Each price setup updates the Tipo and DescricaoTipo properties together. On top of that, only the methods for setting up prices of the Praticado and Previsto types allow setting up price tables, as the business rule requires.
[Fact]
public void Teste_que_utiliza_dtos_de_resposta_de_preco()
{
var precoMinimo = new PrecoResponseBuilder()
.DoTipoMinimo()
.Construir();
var precoPrevisto = new PrecoResponseBuilder()
.DoTipoPrevisto(codigoTabela: 1, inicioFaixa: 1, fimFaixa: 10)
.Construir();
var precoPraticado = new PrecoResponseBuilder()
.DoTipoPraticado(codigoTabela: 1, inicioFaixa: 1, fimFaixa: 10)
.Construir();
//... some test logic
} As we’ve seen, builders can be extremely beneficial to our test suites and help shield our tests from changes in our systems, making our day-to-day much simpler.