YAN JUSTINO
MSc. Software Engineering · PhD. Student
AWS · MCSD · OCA · ORCID · Tech Lead at ITAÚ Unibanco
Atualizado em 10/02/2024
O Design de Software é uma disciplina que procura diminuir a carga cognitiva entre software e os problemas do "mundo real". Muitos conceitos e técnicas podem ser aplicados ao código a fim de se alcançar essa capacidade. Partindo da perspectiva de um desenvolvedor, penso que o design pode se manifestar como uma espécie de "estética" que evidência um fluxo coeso da informação, estruturado em uma visão de solução. Particularmente, gosto quando o código transmite aquele senso de "beleza" que nos faz exclamar:
Uau! Que código bem escrito!!!!
Focando em um recorte específico da organização de código, tenho, por experiência, a impressão de que a qualidade estética aplicada aos artefatos do código de produção (src) não é proporcional a aplicada aos artefatos de testes. Revisitando o passado, percebo que em diversos projetos que pude atuar, havia uma certa falta de "apreço" com a escrita e manutenção dos testes.
Independentemente da sênioridade dos desenvolvedores, e da relevância do projeto, os testes seguiam seu destino cruel em uma jornada rumo a "cafonice". Recorrendo a um MEA CULPA, eu também fui responsável ao longo de minha carreira, por escrever diversas bizarrices em forma de testes (Quem nunca?). Diante disso, uma pulga atrás orelha me faz pensar na seguinte questão:
Como aspectos culturais somados a algumas estratégias de design impactam a evolução do código de teste?
Essa não é uma pergunta simples e talvez mereça uma investigação mais profunda do que essas divagações. Guiado por um impulso extremamente pragmático, direciono a atenção para algumas estratégias de design para entender:
Como as linguagens e frameworks organizam seus softwares de modo a facilitar a manutenção concomitante do código de produção e do código de testes?
Diante disso, me deparei com um tradeoff bem interessante: distanciamento vs aproximação "física" entre o código de testes e o código de produção.
Distanciamento vs Aproximação
Enquanto desenvolvedor dotnet, frequentemente atuo em sistemas que adotam como estratégia o uso de projetos distintos dentro da mesma solução para separar os testes do código de produção. Acredito que essa seja a prática mais comum e recomendada no mundo dotnet. O modelo a seguir ilustra um exemplo de estrutura de solução dotnet.
$/
├─ src/
│ ├─ Production.csproj
│ ├─ ProductionCode.cs
├─ tests/
│ ├─ UnitTests.csproj
│ ├─ TesteCode.cs
├─ .gitignore
├─ README.md
├─ Solution.sln
Contudo, tomando por referência outras Linguagens e Frameworks percebe-se uma abordagem diferente: o código de teste é colocado próximo (na mesma pasta ou arquivo) ao o código de produção, como comumente feito em Go e Rust. O modelo a seguir ilustra como um pacote básico do Go é estruturado.
$/
├─ src/
│ ├─ go.mod
│ ├─ modname.go
│ ├─ modname_test.go
├─ .gitignore
├─ README.md
Vasculhando a rede sobre essa abordagem, percebi um certo pensamento comum entre seus defensores: de que juntar o código de teste ao código de produção pode oferece algumas vantagens para o processo de desenvolvimento e a manutenibilidade do código. Dentre esses benefícios, eles alegam que a abordagem promove: (a) facilidade de Localização, (b) encorajamento a escrita de testes, (c) colaboração, (d) rápida refatoração, (e) estrutura uniforme etc.
Tá... Mas e o C#?
Diante dos potênciais benefícios em aproximar os arquivos de testes com os arquivos de produção, podemos nos perguntar
Quais seriam os ganhos e limitações dessa abordagem aplicada em uma solução dotnet?
Tendo essa questão em mente, convido a darmos um passo desprendido de qualquer pré-conceito para aguçar nosso senso de curiosidade. Para isso, nosso ponto de partida será aplicar a abordagem de unificar o arquivo de testes ao código de produção em um mesmo projeto dotnet. As etapas a seguir detalharão como faremos isso.
1. Criando a solução
Diferentemente, de uma solução prototípica em dotnet onde teríamos um projeto de testes apartado, optamos por não criá-lo, e passamos a escrever nossos arquivos de testes diretamente no projeto de produção, fazendo uso da convenção *.Test.cs
.
$/
├─ src/
│ ├─ OrderManagement.csproj/
│ ├─ Order/
│ ├─ OrderItem.cs
│ ├─ OrderItem.Test.cs
├─ .gitignore
├─ README.md
├─ Solution.sln
2. Configurando o projeto
O próximo passo é adicionar ao projeto, os pacotes relativos a execução dos testes. Nesse ponto, o projeto recebeu as referências aos os pacotes do framework xUnit, para executar os testes; e o FluentAssertions, para proporcionar uma maior expressividade as validações. como ilustrar o código de configuração a seguir.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup Label="Test Dependecies">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
3. Escrevendo o Teste de Unidade
Criamos um arquivo chamado OrdemItem.cs
contendo o código de um record chamado OrdemItem
. Fizemos uso de um recurso da linguagem C# chamado partial
para dividir os escopos das regras de negócio dos seus testes. O código a seguir ilustra essa implementação.
public partial record OrderItem
{
public string? Sku { get; init; }
public uint Quantity { get; init; }
public decimal Price { get; init; }
public decimal GetTotalPrice() => Quantity * Price;
public static implicit operator OrderItem((uint quantity, decimal price) tuple) => new()
{
Sku = Guid.NewGuid().ToString(),
Quantity = tuple.quantity,
Price = tuple.price
};
}
O código a seguir, ilustrar o conteúdo do arquivo OrderItem.Test.cs
, o qual implementa as especificações do framework de testes xUnit, para testar o record OrderItem.
public partial record OrderItem
{
[Theory]
[InlineData(01, 5, 005)]
[InlineData(05, 5, 025)]
[InlineData(50, 5, 250)]
[InlineData(00, 5, 000)]
internal void get_total_price_method_should_calculate_correctly(uint quantity, decimal price, decimal expected)
{
OrderItem item = (quantity, price);
item.GetTotalPrice().Should().Be(expected);
}
}
4. Ajustando a compilação
A fim de evitar que a versão final do software leve arquivos e pacotes desnecessários para o ambiente de produção, fizemos as seguintes intervenções no projeto.
a) adicionamos a tag a seguir, para indicar ao compilador que em versões de release remova os arquivos que estão na convenção **\*.Tests.cs
. Isso irá remover da solução todos os arquivos de teste.
<ItemGroup Condition="'$(Configuration)' == 'Release'">
<Compile Remove="**\*.Tests.cs" />
</ItemGroup>
b) incluímos a condicional '$(Configuration)' != 'Release'
na tag que envolve nossas dependências de testes, para indicar ao compilador que as utilize apenas em versões de desenvolvimento.
<ItemGroup Label="Test Dependecies" Condition="'$(Configuration)' != 'Release'">
...
</ItemGroup>
A versão final de nosso arquivo de projeto ficou da seguinte forma:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Release'">
<Compile Remove="**\*.Tests.cs" />
</ItemGroup>
<ItemGroup Label="Test Dependecies" Condition="'$(Configuration)' != 'Release'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
5. Executando os testes
Com a implementação dos passos anteriores, somos capazes de executar nosso teste diretamente no projeto principal, através do comando dotnet test
ou pelos recursos da IDE (no meu caso o Rider).
Avaliações
Diante da implementação apresentada, concluímos que não existem empecilhos para um teste conviver junto ao seu código de produção em um projeto dotnet. Percebemos que a adoção do recurso partial
possibilita um certo isolamento do teste em nível lógico, e ao mesmo tempo lhe concede um acesso a todo o escopo de visibilidade do código de produção.
Outro aspecto que nos chama atenção é que o uso da convenção **\*.Tests.cs
permite uma ordenação "natural" na árvore de arquivos do projeto. Isso facilita a localização do arquivo de testes e pode ser um incentivo para desenvolvedores mante-los organizados. Essa convenção facilita adaptações no processo de compilação para remover os arquivos de testes quando forem enviados para um ambiente produtivo.
Pensando em aspectos de developer experience, sinto um grande conforto e uma sensação de praticidade ao ver os testes tão próximos ao código de produção. Tendo a acreditar que a abordagem tem potencial de favorecer o processo de construção e manutenibilidade de software de forma concomitante aos testes. Contudo, algumas ponderações devem ser feitas.
Considerações Importantes
Alguns aspectos devem ser considerados sobre a adoção dessa abordagem em soluções dotnet:
A abordagem parece ser apropriada para Testes de Unidade. Sendo assim, recomendamos que testes de integração e end-to-end (E2E) mantenham suas estruturas apartadas.
Existe uma carga extra sobre compilador para remover os artefatos de testes do código de produção. Soluções que contenham muitos projetos podem ser impactadas em desempenho.
É preciso investigar o quanto a adoção da abordagem pode impactar as esteiras de pipeline em sua organização. Adaptações nos steps podem ser necessárias.
Analisadores estáticos de código como o SonarQube podem apontar problemas para esse tipo de estrutura em soluções dotnet. O escopo desse artigo não explorou essa vertente.
É preciso ponderar se a abordagem trará um benefício real para a equipe. O engajamento a essa abordagem deve estar associada á um ganho real para a equipe e organização.
De forma geral, a abordagem parece ser muito promissora para o uso em testes de unidade. Contudo, em muitos casos, seguir as convenções da comunidade e as melhores práticas para a linguagem pode ajudar a manter o código organizado, facilitar a manutenção e melhorar a colaboração entre desenvolvedores. Nesse sentido, é preciso balancear os ganhos e limitação na aplicação de qualquer nova técnica.
Conclusão
O texto explora a prática de colocar arquivos de teste na mesma pasta que o código de produção, destacando os benefícios dessa abordagem, como facilidade de localização, incentivo à escrita de testes, e facilidade de refatoração. Além disso, compara essa prática, comum em linguagens como Go e Rust, com a separação tradicional de testes e código de produção em projetos C#.
Para isso, foi conduzida uma experiência em .NET usando *.Test.cs e partial classes para demonstrar a viabilidade e vantagens de manter testes próximos ao código. Apesar dos benefícios percebidos, como praticidade e organização melhorada, o texto pondera sobre desafios potenciais, como impacto no compilador e adaptações na pipeline de CI/CD. Ele conclui que, embora tecnicamente viável, a adoção dessa abordagem deve considerar as práticas da comunidade e as necessidades específicas do projeto
Caros leitores,
Espero que tenham encontrado insights valiosos na discussão sobre a co-localização de arquivos de teste e código de produção, uma prática que pode transformar significativamente o desenvolvimento de software.
Se você já experimentou essa metodologia em seus projetos, como foi? Quais vantagens e obstáculos você encontrou? Se ainda não tentou, consideraria implementá-la após essa leitura?
Seu feedback é crucial para enriquecer essa discussão.
O código desse artigo está disponível Aqui
Top comments (4)
Ótima provoção Yan! Olhando por esse prisma, faz muito sentido ter ssa proximidade entre regra de negócio e testes. Uma reflexão válida também é se pensar numa abordagem como Vertical Silce. Unir funcionalidades dentro de uma vertical, facilita muito a manutenção e a gestão do código. Incluir testes dentro dessa abordagem me parece bem interessante.
Parabéns pelo artigo!
Excelente insight, Ângelo! Vertical slice pode ser um bom motivador pra agregar testes e código de produção!
Ótimo artigo 👏👏👏
Obrigado!