Por mais que a realização de testes unitários se constitua numa atividade de fundamental importância dentro do desenvolvimento de sistemas, não é um fato raro que esse tipo de prática acabe negligenciado em muitos projetos de software. Inúmeros podem ser os motivos que conduzam a problemas deste gênero, sendo que as causas mais comuns costumam girar em torno de aspectos como tempo escasso, equipes reduzidas e sobrecarregadas em suas tarefas rotineiras, a falta de hábito em se proceder com testes mais abrangentes, dentre outros fatores.
A principal meta por trás do teste de um software é garantir que o produto gerado atenda àquilo que foi especificado para o projeto em questão. Em termos práticos, isto implica verificar se a aplicação funciona de maneira correta dentro de uma série de parâmetros definidos previamente: pessoas envolvidas com essas tarefas conduzem uma série de atividades validando funcionalidades, tentando, a partir de tais ações, encontrar falhas que produzam dados inconsistentes ou, até mesmo, defeitos que comprometam a operação do sistema. Sem a realização de procedimentos deste gênero, tais problemas poderiam passar despercebidos, com consequências imprevisíveis às operações rotineiras de uma organização.
Existem diferentes maneiras de se testar uma aplicação, com cada uma destas levando em conta aspectos como quais profissionais executarão o processo de validação ou ainda, a extensão do que será verificado. Tomando por base estes critérios, os diferentes tipos de testes de software podem ser classificados em:
- Teste de unidade (também conhecido como teste unitário): é uma modalidade que se concentra na verificação das menores unidades em um projeto. O teste é realizado em uma unidade lógica, utilizando dados suficientes apenas para verificar a lógica da estrutura em questão. Unidades em uma linguagem de programação orientada a objetos podem ser identificadas como um método, uma classe ou ainda um objeto;
- Teste de integração: procura apontar erros verificando os relacionamentos (interfaces) entre as diferentes partes (módulos) que compõem uma aplicação. Testes de integração costumam auxiliar na construção da estrutura de um programa, considerando para isto os requisitos definidos dentro do projeto correspondente;
- Teste de sistema: conduzido de maneira a simular a operação de uma aplicação por usuários finais, baseando-se num ambiente similar ao do sistema já em produção e na manipulação de dados e informações próximas àquilo que será processado no dia a dia;
- Teste de aceitação: normalmente um grupo de usuários finais do sistema participa desse tipo de teste, visando simular operações cotidianas que determinem se a aplicação está em conformidade com o que se espera;
- Teste de regressão: realizado quando do lançamento de novas versões de um software, checando se as mudanças introduzidas na aplicação não produziram efeitos colaterais na execução de funcionalidades pré-existentes.
Este artigo discutirá a importância dos testes unitários no desenvolvimento de software dentro da plataforma .NET. Para isto será construída uma aplicação de exemplo que usará os recursos Visual Studio 2012 voltados à execução de testes de unidade automatizados.
Desenvolvimento baseado em Testes Unitários: uma visão geral
Os testes unitários são comumente empregados na checagem de métodos, classes e transições de estados dentro de sistemas orientados a objetos. O trecho de código que será testado é conhecido como “System Under Test” - SUT; é comum também o uso do termo CUT (“Class Under Test” ou “Code Under Test”).
São características comumente atribuídas aos testes unitários:
- São automatizados e repetíveis;
- Podem ser implementados facilmente;
- Uma vez escritos, os testes devem ser mantidos para reuso futuro;
- Qualquer profissional envolvido com o desenvolvimento de uma aplicação deve ser capaz de executá-los;
- Facilmente acionáveis, geralmente isso acontece a partir de um botão ou item de menu dentro de uma IDE;
- Rapidez na execução.
As principais plataformas de desenvolvimento atuais contam com diversos frameworks e funcionalidades que viabilizam a implementação e, consequentemente, a execução automatizada de construções deste tipo. No caso específico do .NET Framework, isto pode ser feito através do uso de frameworks como MS Test (parte integrante da própria plataforma .NET) e NUnit (http://nunit.org/).
Testes unitários representam inclusive a base para a forma de desenvolvimento que tem ganho bastante espaço, sobretudo com a crescente popularidade de metodologias ágeis como Scrum: trata-se do “Desenvolvimento Guiado por Testes” ou TDD.
TDD (sigla do inglês “Test Driven Development”) é um processo para desenvolvimento de software que enfatiza, através de uma série de princípios propostos por metodologias ágeis, a construção de soluções em um modo no qual as mesmas poderão ser facilmente integradas a uma ferramenta de automação de testes unitários.
A escolha do TDD como uma prática de desenvolvimento implica, obrigatoriamente, na codificação dos testes unitários antes mesmo da escrita das partes da aplicação que serão submetidas aos mesmos. Por mais que um teste possa ser formulado após a codificação de uma funcionalidade, isto não é adotado em projetos em conformidade com os princípios de TDD: se tal teste fosse elaborado após o desenvolvimento do recurso a ser verificado, o mesmo poderia ser concebido de uma maneira “viciada”, considerando apenas a possibilidade de execução com sucesso da função considerada.
A implementação de um projeto baseado em técnicas de TDD é feita seguindo um ciclo conhecido como Red-Green-Refactor, com cada um destes termos correspondendo a uma fase na construção dos testes:
- Red: o teste é escrito logo no início do desenvolvimento, com a funcionalidade-alvo sequer implementada (normalmente, apenas um “esqueleto” existirá, procurando se aproximar da estrutura esperada para a função em questão). O objetivo principal é que esse teste realmente falhe (daí o nome “Red”, um sinal “vermelho” para um problema), cumprindo a meta de evitar uma verificação “viciada”, que sempre resultaria em sucesso na sua execução;
- Green: nesta fase é efetuada a codificação da forma mais simples possível, atendendo àquilo que se espera para uma funcionalidade, além de garantir que os testes associados serão executados com sucesso (sinal “verde” indicando que não existem problemas);
- Refactor: com os testes tendo passado na etapa anterior, é possível refatorar o item sob análise, eliminando prováveis duplicações e melhorando a estrutura do código. Se este não for o caso, o comum é que se passe para os próximos testes unitários.
Inúmeros são os benefícios decorrentes das práticas de TDD:
- Um código mais claro, uma vez que os testes unitários geralmente efetuam checagens em porções menos extensas do mesmo;
- Quando bem estruturado, um teste acaba servindo como documentação do código, visto que, a partir da leitura do mesmo, é possível ter uma noção do funcionamento de uma classe, método e/ou objeto;
- Um rápido feedback com alertas para problemas encontrados. Isto é possível por conta de diversos testes serem repetidos a cada novo ciclo de desenvolvimento/manutenção em uma aplicação;
- Testes unitários asseguram uma cobertura adequada de diferentes trechos de código, algo que poderia não se conseguir somente através de testes de sistema ou de aceitação (com prováveis “bugs” aparecendo apenas quando a aplicação estivesse em produção);
- Maior economia de tempo e de recursos financeiros na manutenção de uma aplicação, haja visto que diversas falhas acabam sendo apontadas (e solucionadas) ainda durante a etapa de desenvolvimento.
Diante do exposto, conclui-se que as técnicas de TDD, ao favorecer um código mais simples e de fácil manutenção, acabam contribuindo para uma melhor assimilação de boas práticas de desenvolvimento/arquitetura de software:
- Desenvolver guiado por testes permite separar a lógica de negócios ou de acesso a dados das camadas gráficas de uma aplicação. A este tipo de princípio se dá o nome de Separação de Responsabilidades (do inglês “Separation of Concerns”), onde o mesmo fornece, através de uma série de recomendações, diretrizes que conduzam à obtenção de aplicações formadas por componentes mais coesos;
- Quanto à noção de coesão, este conceito deve ser compreendido como a medida com a qual uma estrutura de software (classe ou componente) atende o objetivo inicial para o qual foi concebida. Uma alta coesão indica que o item considerado não acumula responsabilidades além daquelas para as quais foi especificado, uma característica perseguida por todos os projetos de software que procuram ser bem estruturados. Projetar testes unitários de uma maneira simples é também uma ação contribuinte para a obtenção de classes mais coesas;
- Acoplamento é outro conceito empregado para determinar o grau de relacionamento entre diferentes partes da aplicação. Um alto acoplamento resulta em um nível de alta dependência entre os diversos componentes envolvidos, fato este que pode levar a dificuldades na manutenção futura das funcionalidades. O ideal é que exista um baixo nível de acoplamento entre as estruturas de um determinado elemento. Mais uma vez, testes unitários bem estruturados podem auxiliar em classes com baixo acoplamento, diminuindo as dependências entre diferentes componentes da solução base.
Utilizando Testes Unitários no Visual Studio 2012
A solução apresentada neste artigo foi criada no .NET framework 4.5, através do Visual Studio 2012 Professional. Basicamente, será implementada uma biblioteca para o cálculo de tributos federais como PIS, COFINS, ISS e IRPJ; estes estão geralmente associados a operação fiscais que envolvam a prestação de seguros.
O primeiro passo para a implementação do exemplo baseado em testes unitários consiste na criação de uma Solution chamada “TesteNF”, conforme a Figura 1.
A solução TesteNF será formada pelos seguintes projetos:
- TesteNF.Utils: conterá a classe estática TributacaoHelper, que disponibiliza operações para o cálculo dos impostos;
- TesteNF.Utils.UnitTesting: aplicação onde estarão os diversos testes unitários responsáveis por verificar as funcionalidades da classe TributacaoHelper.
Uma vez gerado o arquivo correspondente à Solution TesteNF, deve-se prosseguir com a criação de um projeto do tipo Class Library com o nome “TesteNF.Utils” (Figura 2).
Neste primeiro momento, será implementada no projeto TesteNF.Utils uma versão extremamente simples da classe TributacaoHelper, com todos os métodos para cálculo de impostos retornando zero como resultado de sua execução. O objetivo é justamente assegurar que a primeira bateria de testes unitários falhe, de maneira que apenas num segundo momento sejam codificados os cálculos necessários.
Na Tabela 1 estão indicados as alíquotas para cada um dos impostos calculados por meio do tipo TributacaoHelper.
Imposto | Alíquota |
PIS (Programa de Integração Social) | 0,0065 |
COFINS (Contribuição para o Financiamento da Seguridade Social) | 0,03 |
IRPJ (Imposto sobre Renda de Pessoa Jurídica) | 0,015 |
CSLL (Contribuição Social sobre Lucro Líquido) | 0,02 |
Tabela 1. Alíquotas de impostos que serão utilizadas pelo tipo TributacaoHelper
A Listagem 1 tem a definição inicial para a classe TributacaoHelper. Estão declarados neste tipo os métodos estáticos CalcularPIS, CalcularCOFINS, CalcularIRPJ e CalcularCSLL, com cada uma das operações devolvendo como resultado o imposto a ser pago a partir de um valor de serviço prestado.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace TesteNF.Utils
{
public static class TributacaoHelper
{
public static double CalcularPIS(double valorBase)
{
return 0;
}
public static double CalcularCOFINS(double valorBase)
{
return 0;
}
public static double CalcularIRPJ(double valorBase)
{
return 0;
}
public static double CalcularCSLL(double valorBase)
{
return 0;
}
}
}
Agora é a vez de criar o projeto de testes chamado “TesteNF.Utils.UnitTesting” (Figura 3). Assim como acontece com outros templates, esse tipo de projeto é nativo do Visual Studio e não exige qualquer configuração adicional para sua inclusão em uma Solution.
Com o projeto TesteNF.Utils.UnitTesting gerado, é possível observar a existência de um arquivo chamado UnitTest1.cs, também criado automaticamente, mas pode removê-lo, já que outras classes de testes serão criadas seguindo uma nomenclatura própria para este projeto.
OBSERVAÇÃO: o projeto TesteNF.Utils.UnitTesting executa o teste de funcionalidades da classe TributacaoHelper, então será necessário adicionar ao mesmo uma referência que aponte para a Class Library TesteNF.Utils.
A primeira classe de testes a ser criada terá o nome “TributacaoHelperTeste01”. O Visual Studio também oferece um template (“Basic Unit Test”) para este tipo de construção, sendo possível selecioná-lo a partir da opção que adiciona novos tipos de arquivos a um projeto (conforme indicado na Figura 4).
Os diferentes testes a serem executados através da classe TributacaoHelperTeste01 tomarão R$ 8.400,00 como valor-base para serviços prestados. Na Tabela 2 estão listados os valores esperados para cada cálculo de imposto a ser verificado.
Imposto | Operação a ser Verificada em TributacaoHelper | Valor Esperado |
PIS | CalcularPIS | 54,6 |
COFINS | CalcularCOFINS | 252 |
IRPJ | CalcularIRPJ | 126 |
CSLL | CalcularCSLL | 84 |
Tabela 2. Valores esperados para os testes da classe TributacaoHelperTeste01.
Na Listagem 2 está o código corresponde à classe TributacaoHelperTeste01. Nota-se na definição deste tipo as seguintes características:
- A classe TributacaoHelperTeste01 foi marcada com o atributo TestClassAttribute, pois, diferente de uma Console Application ou executável Windows Forms, projetos de testes não contam com um método Main para iniciar a sua execução; logo, o Visual Studio irá buscar sempre classes marcadas com o atributo citado, a fim de acionar os diferentes testes presentes nas mesmas;
- Os diferentes métodos que representam testes (TestarCalculoPIS, TestarCalculoCOFINS, TestarCalculoIR e TestarCalculoCSLL) também estão marcados com um atributo, sendo que neste último caso foi utilizado o elemento TestMethodAttribute. Mais uma vez, será por meio deste vínculo que o mecanismo de execução de testes do Visual Studio identificará quais métodos precisarão ser executados;
- As verificações de cada funcionalidade presente na classe TributacaoHelper são feitas através do uso do método AreEqual, o qual pertence ao tipo Assert. Esta operação recebe como parâmetros o resultado da execução do método a ser testado, além do valor esperado para esta ação. Caso tais valores não coincidam, o teste falhará e um alerta será gerado dentro do Visual Studio.
OBSERVAÇÃO: As classes TestClassAttribute, TestMethodAttribute e Assert estão situadas no namespace Microsoft.VisualStudio.TestTools.UnitTesting.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using TesteNF.Utils;
namespace TesteNF.Utils.UnitTesting
{
[TestClass]
public class TributacaoHelperTeste01
{
[TestMethod]
public void TestarCalculoPIS()
{
Assert.AreEqual(TributacaoHelper
.CalcularPIS(8400.00), 54.60);
}
[TestMethod]
public void TestarCalculoCOFINS()
{
Assert.AreEqual(TributacaoHelper
.CalcularCOFINS(8400.00), 252.00);
}
[TestMethod]
public void TestarCalculoIRPJ()
{
Assert.AreEqual(TributacaoHelper
.CalcularIRPJ(8400.00), 126.00);
}
[TestMethod]
public void TestarCalculoCSLL()
{
Assert.AreEqual(TributacaoHelper
.CalcularCSLL(8400.00), 84.00);
}
}
}
Durante a construção de testes unitários, o uso de uma nomenclatura padronizada representa uma boa prática de desenvolvimento. Nos exemplos apresentados neste artigo o nome de todos os métodos é iniciado pelo prefixo “Testar”. Já a identificação das classes de testes é formada pelo nome do tipo a ser analisado (no caso TributacaoHelper), seguidos pela palavra “Teste”, além de números identificadores (“01”, “02”).
Além de AreEqual, a classe estática Assert (namespace Microsoft.VisualStudio.TestTools.UnitTesting) também disponibiliza para validações outros métodos, constituindo exemplos disto as operações:
- AreNotEqual: verifica que os valores fornecidos como parâmetros são diferentes;
- IsFalse: checa se o retorno de uma condição é falso;
- IsTrue: verifica se o retorno de uma expressão é verdadeiro;
- IsNull: checa se o valor associado a um objeto é nulo;
- IsNotNull: verifica se o conteúdo de um objeto não é nulo.
Uma segunda classe de testes chamada TributacaoHelperTeste02 precisará ser criada. Na Tabela 3 estão os valores que servirão de base para os testes, considerando R$ 7.728,00 como total de prestação de serviços.
Imposto | Operação a ser Verificada em TributacaoHelper | Valor Esperado |
PIS | CalcularPIS | 50,23 |
COFINS | CalcularCOFINS | 231,84 |
IRPJ | CalcularIRPJ | 115,92 |
CSLL | CalcularCSLL | 77,28 |
Tabela 3. Valores esperados para os testes da classe TributacaoHelperTeste02.
Na Listagem 3 é apresentado o código que define a classe TributacaoHelperTeste02; este tipo conta com uma estrutura idêntica à da classe TributacaoHelperTeste01, diferindo apenas pelos valores analisados.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using TesteNF.Utils;
namespace TesteNF.Utils.UnitTesting
{
[TestClass]
public class TributacaoHelperTeste02
{
[TestMethod]
public void TestarCalculoPIS()
{
Assert.AreEqual(TributacaoHelper
.CalcularPIS(7728.00), 50.23);
}
[TestMethod]
public void TestarCalculoCOFINS()
{
Assert.AreEqual(TributacaoHelper
.CalcularCOFINS(7728.00), 231.84);
}
[TestMethod]
public void TestarCalculoIRPJ()
{
Assert.AreEqual(TributacaoHelper
.CalcularIRPJ(7728.00), 115.92);
}
[TestMethod]
public void TestarCalculoCSLL()
{
Assert.AreEqual(TributacaoHelper
.CalcularCSLL(7728.00), 77.28);
}
}
}
Agora processaremos os testes: as diferentes opções que permitem acionar a execução encontram-se no menu Test (Figura 5).
Acionando a opção “All Tests” será ativada a janela “Test Explorer” (Figura 6) com o resultado da execução das duas classes de testes unitários. Conforme pode ser observado, todos os testes falharam.
Selecionando um dos testes que falharam serão exibidos detalhes a respeito do mesmo como, por exemplo, em que arquivo/classe está situado o método que produziu a falha (conforme indicado na Figura 7).
A Listagem 4 apresenta o código referente à classe TributacaoHelper, já considerando agora as instruções necessárias para o cálculo dos diversos impostos previstos por este tipo.
OBSERVAÇÃO: para a aplicação de exemplo foram utilizados valores fixos nas alíquotas de impostos. Numa situação real, o ideal seria que tais alíquotas fossem parametrizáveis, ou seja, definidas em um arquivo de configuração ou, até mesmo, numa tabela pertencente dentro de uma base de dados.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace TesteNF.Utils
{
public static class TributacaoHelper
{
public static double CalcularPIS(double valorBase)
{
return Math.Round(valorBase * 0.65 / 100, 2);
}
public static double CalcularCOFINS(double valorBase)
{
return Math.Round(valorBase * 3 / 100, 2);
}
public static double CalcularIRPJ(double valorBase)
{
return Math.Round(valorBase * 1.5 / 100, 2);
}
public static double CalcularCSLL(double valorBase)
{
return Math.Round(valorBase * 1 / 100, 2);
}
}
}
Uma nova execução dos testes unitários resultará em sucesso desta vez, como pode ser visualizado na Figura 8.
Observando o código que implementa a classe TributacaoHelper, nota-se que em todos os métodos repete-se o uso da operação Round, a qual se encontra definida na classe estática Math (namespace System). Além disso, em todas as situações a alíquota é dividida por 100, obtendo o valor decimal correspondente a uma porcentagem. Este é um bom exemplo no qual técnicas de refatoração podem ser empregadas: um método chamado CalcularImposto é implementado agrupando as instruções comuns com, o mesmo sendo acionado pelas demais operações de TributacaoHelper (Listagem 5).
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace TesteNF.Utils
{
public static class TributacaoHelper
{
private static double CalcularImposto(
double valorBase, double aliquota)
{
return Math.Round(valorBase * aliquota / 100, 2);
}
public static double CalcularPIS(double valorBase)
{
return CalcularImposto(valorBase, 0.65);
}
public static double CalcularCOFINS(double valorBase)
{
return CalcularImposto(valorBase , 3);
}
public static double CalcularIRPJ(double valorBase)
{
return CalcularImposto(valorBase, 1.5);
}
public static double CalcularCSLL(double valorBase)
{
return CalcularImposto(valorBase, 1);
}
}
}
Reprocessando os testes após essa etapa de refatoração, nota-se que os mesmos ainda são válidos, o que significa que não foram introduzidos erros no código-fonte (Figura 9).
Com isso, procurou-se demonstrar como testes unitários podem ser utilizados a partir do Visual Studio 2012. O desenvolvimento guiado por testes pode resultar não apenas na obtenção de aplicações com menor sujeição a erros, como também contribuir para uma melhor estruturação do sistema ao estimular o uso de boas práticas de arquitetura.
Contudo, é altamente recomendável não depender apenas de testes unitários para a validação de uma aplicação. As outras modalidades de testes são importantíssimas e devem ser aplicadas ao longo das diferentes fases de um projeto, garantindo um produto estável, sem falhas que impeçam sua operação normal no dia a dia.