Twitter
RSS

Test-Driven Development no .NET (na prática)

No post anterior, fiz uma introdução ao Test-Driven Development (TDD), falando resumidamente sobre seus conceitos e mostrando como funciona o NUnit Testing Framework. Neste post, pretendo falar mais sobre a metodologia TDD e mostrar um exemplo de sua aplicação na prática.

Com esses conhecimentos já posso sair desenvolvendo programas utilizando TDD? Infelizmente não funciona bem assim, apenas sabendo programar orientado a objeto, conhecendo a sintaxe, atributos e métodos do NUnit não é suficiente. É preciso ter em mente algumas técnicas utilizadas em TDD antes de sair desenvolvendo um sistema. Existem algumas técnicas que devem ser seguidas quando se desenvolve utilizando TDD que facilitam o entendimento, no entanto, conhecer essas técnicas é apenas um começo. Para melhorar a sua experiência e aptidão, você deve praticar, práticar e práticar. Segue uma imagem para ilustrar melhor essas técnicas.

Se ainda não está familiarizado com TDD, o que estou escrevendo pode soar um pouco estranho. Muitas pessoas passaram muito tempo ouvindo que deve-se primeiro considerar cuidadosamente o design das classes, codificá-las e então testar. O que o TDD sugere é uma abordagem completamente diferente, o processo é iniciado de trás para frente, tendo o teste em primeiro lugar. Dito de outra forma, você não escreve uma linha de código até que você tenha um teste pronto para a função. A seqüência sugerida para desenvolvimento usando TDD é a seguinte:

1. Escrever um teste.
2. Execute o teste. Não consegue compilar uma vez que o código ainda não existe mesmo! (Esta é a mesma coisa que falhar.)
3. Escrever um esboço do que se deseja testar, só para fazer o teste compilar.
4. Execute o teste. Ele deve falhar. (Se não falhar , então o teste não era muito bom.)
5. Implementar o código completo para o teste passar.
6. Execute o teste. Ela deverá passar. (Se não o fizer, volte um passo e tente novamente.)
7. Começar com um novo teste!

Antes de chegar na etapa 5, você estará utilizando um processo denominado Codificação por Intenção. Quando você pratica Codificação por Intenção, o código é escrito de cima para baixo em vez de baixo para cima, ou seja, primeiro você cria um código de acesso a rotina, depois você cria a rotina em si. Em vez de pensar, "Eu preciso de uma classe com estes métodos" basta escrever o código desejado, antes que a classe realmente exista. Se tentar compilar seu código, não conseguirá, o compilador não poderá encontrar a classe. Este é um bom sinal, pois como disse acima, a falta de compilar, conta como uma falha no teste. O que vai fazer aqui é experimentar a intenção do código que está escrito. Isto não só ajuda a produzir códigos bem testados, como resulta também em código que é mais fácil de ler, mais fácil de depurar e com uma melhor concepção.

No desenvolvimento de software, testes tradicionais foram pensados para verificar se um trecho de código foi escrito corretamente. Quando você fizer TDD, no entanto, os seus testes são utilizados para definir o comportamento de uma classe antes de escrever a mesma. Não vou arriscar dizer que este método é mais fácil do que as velhas formas, mas na minha experiência é muito melhor.

Vamos ver uma pequena amostra desta metodologia. Suponhamos que você necessita escrever o simples e famoso sistema de conta bancária. Esse sistema consiste em fazer uma classe que possa representar uma Conta, possuindo o nome do proprietário, o saldo, e o estado da conta. Também pode ser feitos Depósitos, Saques e rendimento de juros. Tudo isso em um sistema simples num projeto Console Application. Veja a imagem abaixo que ilustra melhor o exemplo.

Antes de criar efetivamente a classe Conta, deve-se criar uma classe de teste, esses testes devem estar de acordo com as funcionalidades que imaginamos (“ou vemos na imagem acima”) que a classe Conta irá representar, com o desenvolvimento dessa classe iniciamos o TDD, o nome da classe de teste neste exemplo será ContaTests. Agora que já temos em mente o que a classe ContaTests deve ser capaz de fazer, vamos criar os testes com o NUnit Framework, conforme mostra o código abaixo:

using System;
using NUnit.Framework;

namespace TDD.Banco
{
    [TestFixture]
    public class ContaTests
    {
        [Test]
        public void TestDeposito()
        {
            Conta conta = new Conta("Test");
            conta.Deposito(125.0);
            conta.Deposito(25.0);
            Assert.AreEqual(150.0, conta.Balanco);
        }

        [Test]
        public void TestSaque()
        {
            Conta conta = new Conta("Test");
            conta.Saque(125.0);
            conta.Saque(25.0);
            Assert.AreEqual(-150.0, conta.Balanco);
        }

        [Test]
        public void TestJuro()
        {
            Conta conta = new Conta("Test");
            conta.Deposito(100.0);
            conta.AplicarJuro();
            Assert.AreEqual(105.0, conta.Balanco);
        }

        [Test]
        public void TestEstado()
        {
            Conta conta = new Conta("Test");
            conta.Deposito(1100.0);
            Assert.AreEqual("Ouro", conta.Estado);
            conta.Saque(600.0);
            Assert.AreEqual("Cinza", conta.Estado);
            conta.Saque(600.0);
            Assert.AreEqual("Vermelho", conta.Estado);
        }
    }
}

Depois de escrever o código acima na classe ContaTests é hora de compilá-la. É óbviu que a classe de testes não irá compilar, uma vez que a não existi a classe Conta. Isto ilustra o principal princípio de TDD, não escrever qualquer código sem que se tenha um teste pronto para ele. Lembro, quando os seus códigos de teste não compilarem, isso contará como uma falha de teste, e será cumprida a primeira etapa de TDD. Agora vamos criar a classe Conta com código suficiente apenas para fazer os testes compilar.

using System;

namespace TDD.Banco
{
    public class Conta
    {
        private String estado;       
        private String proprietario;

        public Conta(string proprietario)
        {           
            this.proprietario = proprietario;
            estado = "";           
        }

        public double Balanco
        {
            get { return 0.0; }
        }

        public String Estado
        {
            get { return estado; }
            set { estado = value; }
        }

        public void Deposito(double valor)
        {           
        }

        public void Saque(double valor)
        {           
        }

        public void AplicarJuro()
        {           
        }

        private void AlteraEstado()
        {           
        }
    }
}

Logo após criarmos a classe Conta, podemos compilar o projeto que não terá mais erros. Com isso podemos rodar nossos testes através do NUnit GUI. Para rodar os testes, rode o executável do NUnit GUI e abra o arquivo executável do projeto Console Application que foi criado nesse exemplo de teste. Clicando em “Run”, os testes serão executados e a tela do NUnit ficará conforme a imagem abaixo.

Notamos que todos os testes falharam. Isso já era esperado, pois a classe Conta foi criada apenas para compilar o projeto e os testes. Como pode ser visto na imagem, o teste “TestDeposito” mostrou a seguinte mensagem de erro: "TestDeposito: expected: <150> but was <0>". Esse resultado serviu para concluir mais uma etapa do TDD criar o teste de um método e fazer ele falhar. A próxima etapa é justamente implementar o método Deposito da classe Conta para que o teste “TestDeposito” passe sem erro.

Notém que por uma questão prática estou disponibilizando os códigos com todos os testes e métodos criados, quando se utiliza TDD essa não é uma boa prática, deve-se criar um teste para uma determinada rotina, criar a rotina apenas para compilar, rodar o teste propositalmente para que ele falhe, implementar a rotina para que o teste passe, tudo isso nessa respectiva ordem. Sabendo disso, vejam o código abaixo, com os testes implementados para que os testes passem.

using System;

namespace TDD.Banco
{
    public class Conta
    {
        private String estado;
        private double balanco;
        private String proprietario;

        public Conta(string proprietario)
        {
            // Novas contas por default são 'Cinza' e o balanco é 0.0
            this.proprietario = proprietario;
            estado = "Cinza";
            balanco = 0.0;
        }

        public double Balanco
        {
            get { return balanco; }
        }

        public String Estado
        {
            get { return estado; }
            set { estado = value; }
        }

        public void Deposito(double valor)
        {
            balanco += valor;
            AlteraEstado();
            Console.WriteLine("Depositado {0:C} --- ", valor);
            Console.WriteLine(" Balanço = {0:C}", Balanco);
            Console.WriteLine(" Estado  = {0}", Estado);
            Console.WriteLine("");
        }

        public void Saque(double valor)
        {
            balanco -= valor;
            AlteraEstado();
            Console.WriteLine("Sacado {0:C} --- ", valor);
            Console.WriteLine(" Balanço = {0:C}", Balanco);
            Console.WriteLine(" Estado  = {0}", Estado);
            Console.WriteLine("");
        }

        public void AplicarJuro()
        {
            if (!String.Equals(Estado, "Vermelho"))
            {
                balanco += 0.05 * balanco;
                Console.WriteLine("Juros Aplicados --- ");
                Console.WriteLine(" Balanco = {0:C}", Balanco);
                Console.WriteLine(" Estado  = {0}", Estado);
            }
            else
            {
                Console.WriteLine("Juro não pode ser aplicado pelo Estado da conta.");
                Console.WriteLine(" Balanco = {0:C}", Balanco);
                Console.WriteLine(" Estado  = {0}", Estado);
            }
        }

        private void AlteraEstado()
        {
            if (balanco < 0.0)
                Estado = "Vermelho";
            else if (balanco < 1000.0)
                Estado = "Cinza";
            else
                Estado = "Ouro";
        }
    }
}

Com o código acima já implementado, ao rodar os testes no NUnit, todos deverão passar como mostra a imagem abaixo. Tudo isso por que agora focamos no problema do teste, efetuamos a programação visando um objetivo, passar nos testes, por isso a importância de testar tudo que é possível.

Agora temos a classe Conta implementada e uma classe de testes ContaTest garantindo que todos os métodos estão funcionando de acordo com o previsto. Então podemos dizer que o TDD se encerra por aqui? ERRADO. Ainda falta uma etapa muito importante dentro do TDD, é a etapa do Refactoring.

Refatoração (do inglês Refactoring) é o processo de modificar um sistema de software para melhorar a estrutura interna do código sem alterar seu comportamento externo. (Wikipedia)

Devemos fazer um Refactoring tanto na nossa classe de teste quanto na nossa classe Conta. Vamos começar com a classe de teste, no código dela podemos notar que existe várias duplicações. Para cada método criamos e instanciamos um objeto do tipo Conta. Com ajuda dos atributos do NUnit, podemos refatorar essa classe excluindo essas repetições em cada método e substituindo esse código pelos método Setup e TearDown. Com isso cada vez que um teste for executado antes passará pelo Setup e depois pelo TearDown. Confira o código abaixo.

using System;
using NUnit.Framework;

namespace TDD.Banco
{
    [TestFixture]
    public class ContaTests
    {
        private Conta conta;

        [SetUp]
        public void Setup()
        {
            conta = new Conta("Test");
        }

        [TearDown]
        public void TearDown()
        {
            conta = null;
        }

        [Test]
        public void TestDeposito()
        {           
            conta.Deposito(125.0);
            conta.Deposito(25.0);
            Assert.AreEqual(150.0, conta.Balanco);
        }

        [Test]
        public void TestSaque()
        {           
            conta.Saque(125.0);
            conta.Saque(25.0);
            Assert.AreEqual(-150.0, conta.Balanco);
        }

        [Test]
        public void TestJuro()
        {           
            conta.Deposito(100.0);
            conta.AplicarJuro();
            Assert.AreEqual(105.0, conta.Balanco);
        }

        [Test]
        public void TestEstado()
        {           
            conta.Deposito(1100.0);
            Assert.AreEqual("Ouro", conta.Estado);
            conta.Saque(600.0);
            Assert.AreEqual("Cinza", conta.Estado);
            conta.Saque(600.0);
            Assert.AreEqual("Vermelho", conta.Estado);
        }
    }
}

Após a refatoração, podemos rodar os testes e conferir que todos continuam funcionando. Então vamos partir para um refactoring na classe Conta. Ao implementar a classe conta podemos constatar que de acordo com o estado da conta é tomada uma decisão diferente para cada caso. Isso impacta em várias verificações em cada rotina que se aplica uma operação que necessita uma tomada de decisão de acordo com o estado. Portanto para um melhor design da classe e para facilitar possíveis alterações duranto o tempo de vida do projeto vamos aplicar a classe Conta o Desing Pattern State, conforme imagem abaixo.

Conforme podemos ver no modelo acima, o design da classe vai mudar, porém o objetivo da classe Conta vai continuar sendo o mesmo, permitindo fazer deposito, saque e rendimentos de juro. Neste caso vamos supor que outro desenvolvedor resolveu fazer essa mudança, ele não vai seguir as metodologias TDD, não vai criar testes para as novas implementações, vai apenas alterar o design. Com isso podemos ver se o sistema está mais seguro com os testes que foram implementados. Vejam o código abaixo após o refactoring.

Classe Conta:

using System;

namespace TDD.Banco
{
    public class Conta
    {
        private Estado estado;       
        private String proprietario;

        public Conta(string proprietario)
        {
            // Novas cotas por default são 'Cinza' e tem o balanco 0.0
            this.proprietario = proprietario;
            estado = new EstadoCinza(0.0, this);           
        }

        public double Balanco
        {
            get { return estado.Balanco; }
        }

        public Estado Estado
        {
            get { return estado; }
            set { estado = value; }
        }

        public void Deposito(double valor)
        {
            estado.Deposito(valor);
            Console.WriteLine("Depositado {0:C} --- ", valor);
            Console.WriteLine(" Balanço = {0:C}", Balanco);
            Console.WriteLine(" Estado  = {0}", Estado.GetType().Name);
            Console.WriteLine("");
        }

        public void Saque(double valor)
        {
            estado.Saque(valor);
            Console.WriteLine("Sacado {0:C} --- ", valor);
            Console.WriteLine(" Balanço = {0:C}", Balanco);
            Console.WriteLine(" Estado  = {0}", Estado.GetType().Name);
            Console.WriteLine("");
        }

        public void AplicarJuro()
        {
            estado.AplicarJuro();      
            Console.WriteLine("Juros Aplicados --- ");
            Console.WriteLine(" Balanco = {0:C}", Balanco);
            Console.WriteLine(" Estado  = {0}", Estado.GetType().Name);
            Console.WriteLine("");
        }
    }
}

Classe Estado:

using System;

namespace TDD.Banco
{
    public abstract class Estado
    {
        protected Conta conta;
        protected double balanco;

        protected double juro;
        protected double limiteMin;
        protected double limiteMax;
       
        public Conta Conta
        {
            get { return conta; }
            set { conta = value; }
        }

        public double Balanco
        {
            get { return balanco; }
            set { balanco = value; }
        }

        public abstract void Deposito(double valor);
        public abstract void Saque(double valor);
        public abstract void AplicarJuro();
    }
}

Classe EstadoVermelho:

using System;

namespace TDD.Banco
{
    public class EstadoVermelho : Estado
    {
        double taxaServico;
       
        public EstadoVermelho(Estado estado)
        {
            this.balanco = estado.Balanco;
            this.conta = estado.Conta;
            Initialize();
        }

        private void Initialize()
        {           
            juro = 0.0;
            limiteMin = -100.0;
            limiteMax = 0.0;
            taxaServico = 15.00;
        }

        public override void Deposito(double valor)
        {
            balanco += valor;
            AlteraEstado();
        }

        public override void Saque(double valor)
        {
            valor = valor - taxaServico;
            Console.WriteLine("Sem saldo para efetuar saque!");
        }

        public override void AplicarJuro()
        {
            // Não tem juro estado vermelho
        }

        private void AlteraEstado()
        {
            if (balanco > limiteMax)
            {
                conta.Estado = new EstadoCinza(this);
            }
        }

        public override String ToString()
        {
            return "Vermelho";
        }
    }
}

Classe EstadoCinza:

using System;

namespace TDD.Banco
{
    public class EstadoCinza : Estado
    {
        public EstadoCinza(Estado estado) :
            this( estado.Balanco, estado.Conta)
        {   
        }

        public EstadoCinza(double balanco, Conta conta)
        {
            this.balanco = balanco;
            this.conta = conta;
            Initialize();
        }       

        private void Initialize()
        {
            juro = 0.05;
            limiteMin = 0.0;
            limiteMax = 1000.0;           
        }

        public override void Deposito(double valor)
        {
            balanco += valor;
            AlteraEstado();
        }

        public override void Saque(double valor)
        {
            balanco -= valor;
            AlteraEstado();
        }

        public override void AplicarJuro()
        {
            balanco += juro * balanco;
            AlteraEstado();
        }

        private void AlteraEstado()
        {
            if (balanco < estado =" new"> limiteMax)
            {
                conta.Estado = new EstadoOuro(this);
            }
        }

        public override String ToString()
        {
            return "Cinza";
        }
    }
}

Classe EstadoOuro:

using System;

namespace TDD.Banco
{
    public class EstadoOuro : Estado
    {
        public EstadoOuro(Estado estado) :
            this(estado.Balanco, estado.Conta)
        {
        }

        public EstadoOuro(double balanco, Conta conta)
        {
            this.balanco = balanco;
            this.conta = conta;
            Initialize();
        }

        private void Initialize()
        {
            juro = 0.05;
            limiteMin = 1000.0;
            limiteMax = 1000000.0;
        }

        public override void Deposito(double valor)
        {
            balanco += valor;
            AlteraEstado();
        }

        public override void Saque(double valor)
        {
            balanco -= valor;
            AlteraEstado();
        }

        public override void AplicarJuro()
        {
            balanco += juro * balanco;
            AlteraEstado();
        }

        private void AlteraEstado()
        {
            if (balanco < 0.0)
            {
                conta.Estado = new EstadoVermelho(this);
            }
            else if (balanco < limiteMin)
            {
                conta.Estado = new EstadoCinza(this);
            }
        }

        public override String ToString()
        {
            return "Ouro";
        }
    }
}

Após terminarmos as implementações, podemos rodar os testes no NUnit e verificar se algum deles falhou, indicando que alguma alteração mecheu no funcionamento do sistema. Neste exemplo foi deixado propositalmente duas alterações para que fossem pegas pelos testes. Como mostra a imagem do NUnit abaixo.

Dois testes falharam durante a execução, o primeiro deles foi o “TestEstado” onde esperava uma string “Ouro” e veio como retorno um tipo Ouro. No segundo teste que falhou, o “TestSaque” era esperado -150 no balanco e retornou -125, isso devido a uma alteração na lógica da conta com estado Vermelho, só pode sacar se tiver fundos. Vamos corrigir os testes, para que eles fiquem adequados as novas alterações.

using System;
using NUnit.Framework;

namespace TDD.Banco
{
    [TestFixture]
    public class ContaTests
    {
        private Conta conta;

        [SetUp]
        public void Setup()
        {
            conta = new Conta("Test");
        }

        [TearDown]
        public void TearDown()
        {
            conta = null;
        }

        [Test]
        public void TestDeposito()
        {           
            conta.Deposito(125.0);
            conta.Deposito(25.0);
            Assert.AreEqual(150.0, conta.Balanco);
        }

        [Test]
        public void TestSaque()
        {
            conta.Deposito(200.0);
            conta.Saque(125.0);
            conta.Saque(25.0);
            Assert.AreEqual(50.0, conta.Balanco);
        }

        [Test]
        public void TestSaqueVermelho()
        {           
            conta.Saque(125.0);
            conta.Saque(25.0);
            Assert.AreEqual(-125.0, conta.Balanco);
        }

        [Test]
        public void TestJuro()
        {           
            conta.Deposito(100.0);
            conta.AplicarJuro();
            Assert.AreEqual(105.0, conta.Balanco);
        }

        [Test]
        public void TestEstado()
        {           
            conta.Deposito(1100.0);
            Assert.AreEqual("Ouro", conta.Estado.ToString());
            conta.Saque(600.0);
            Assert.AreEqual("Cinza", conta.Estado.ToString());
            conta.Saque(600.0);
            Assert.AreEqual("Vermelho", conta.Estado.ToString());
        }
    }
}

Uma dica importante quanto à metodologia de TDD é a seguinte: toda vez que você encontrar um bug, primeiramente crie um teste que faça o bug aparecer. Só então, escreva o código que vai corrigir o bug. Com isso, o seu conjunto de testes melhora com o tempo, fica mais completo, e você vai ter certeza que daquele ponto em diante que o bug nunca mais voltará despercebido. É o que foi feito no caso do teste “TestSaque”, foi criado um teste “TestSaqueVermelho” apenas para simular o erro e depois corrigi-lo.

Podem acreditar, depois de usar NUnit por um tempo, vocês vão ver a importância dos testes, principalmente depois de fazer grandes alterações no sistema: se todos os testes passarem, você pode ter certeza que não quebrou nada, ao invés de só rezar. Também vai ter certeza que não danificou funcionalidades implementadas por outros programadores, o que em um ambiente de produção é fundamental. Você se sente mais seguro para fazer alterações, por que sabe que possívelmente pegará os erros que podem vir a ocorrer.

Como puderam notar a classe Conta com o novo design acabou ficando dependente de outras classes como EstadoVermelho, EstadoCinza e EstadoOuro. Testes de unidade procuram testar classes de um sistema isoladamente. Classes em um sistema normalmente alcançam seus objetivos com a ajuda de outras. Não funcionam isoladamente, freqüentemente se comunicam com outros elementos da aplicação. Quando construímos um teste de unidade, um dos principais desafios é exatamente isolar a classe que está sendo testada, para que nenhuma outra classe do sistema seja envolvida no teste.

Uma solução eficaz é o uso de mock objects (objetos “de mentira” ou objetos substitutos), que permitem isolar as classes de um sistema de forma bastante simples. No nosso exemplo, a classe Conta depende diretamente da classe EstadoVermelho. Usar um mock object significa que, quando estivermos testando, ao invés de usarmos a classe EstadoVermelho, usaremos uma outra, que “finge” ser essa classe, mas é mais simples e mais fácil de ser usada durante os testes (além disso, temos total controle sobre ela, pois é criada especialmente para os testes).

Mas isso pretendo mostrar em um próximo post, refatornado a nossa classe de teste, permitindo o uso de mock objects e criando novas classes de testes para cada classe separadamente.

Até a próxima…

Comments (0)