Vamos falar sobre as razões para alterar um software e também quais são as técnicas para fazer uma alteração sem muito sofrimento com técnicas certas para entregar o máximo de valor ao cliente e reduzir a margem de falhas nas mudanças do código.

1. Adicionar uma nova funcionalidade

2. Resolver um bug

3. Melhorar o design

4. Otimizar o uso de recursos do sistema

Neste artigo, vamos falar baseado neste livro como fazer alterações em um software mais facilidade e menos sofrimento, mostrando algumas heurísticas que podem ser usadas em várias situações quando você se depara com um código que não está perfeito para ser alterado.

Neste artigo vamos falar sobre alguns pontos interessantes quando queremos fazer mudanças no sistema:

  • Ferramentas (Mock, Stubs e Injeção de Dependências)
  • O que fazer quando não temos tempo para fazer as mudanças (métodos Sprout e Wrap)
  • O que fazer quando o tempo de compilação ou ao adicionar uma nova funcionalidade tenho que mudar em vários pontos
  • Como identificar quais métodos testar devido a uma determinada alteração?
  • Quais testes devem ser criados quando vai ser feita uma mudança em um código complexo? (Testes de caracterização)
  • Trabalhando com feedback

    O Algoritmo para mudança de código

    1. Identificar os pontos de mudança

    2. Buscar pontos de testes

    3. Quebrar dependências

    4. Escrever testes

    5. Fazer as mudanças e refactorings

    Algumas ferramentas

    Mock

    Um dos grandes problemas que nós temos quando trabalhamos com código legado são as dependências. Se você quer executar um pedaço específico do código e ver como ele vai se comportar, com uma boa frequência nós temos que lidar com alguma dependência com outros pedaços de código.

    Então, se quisermos eliminar esta dependência para não ter o outro código sendo executado, nós temos que colocar algo no lugar para entregar os valores corretos quando nós estivermos testando, desta forma nós seremos capazes de testar nosso código mais profundamente.

    Quando queremos fazer isso em códigos que são orientados a objetos, nós podemos implementar Mock Objects.

    Para exemplificar todo este processo, vamos criar uma simples aplicação de gerenciamento de pedidos em que ele irá apenas gravar em um banco de dados e enviará um e-mail ao cliente informando que o pedido foi realizado.

    Primeiro temos as classes de envio de e-mail e também conexão com o banco de dados. Não vamos trabalhar a implementação delas ainda, mas considere que este código é demorado, tem conexão a outros servidores na internet e usar eles diretamente farão com que os nossos Unit Tests sejam lentos e inconsistentes.

    class Email:
        def send(self, name, email, product, value):
            # Faz todo o processo de envio de e-mail
            return True
    
    class Database:
        def save(self, name, email, product, value):
            # Faz todo o processo de salvamento no banco de dados
            return True
    

    Agora vamos fazer a implementação da nossa classe de pedidos:

    class Order:
        def __init__(self, database_provider, email_provider):
            self._database_provider = database_provider
            self._email_provider = email_provider
    
        def execute(self, name, email, product, value):
            if self._database_provider.save(name=name, email=email, product=product, value=value):
                return self._email_provider.send(name, email, product, value)
            return False
    

    Veja que logo no construtor da classe estamos informando quais são os objetos responsáveis por fazer a comunicação com o banco de dados e também envio de e-mails.

    Esta é uma técnica chamada Injeção de dependências e facilita bastante a criação de Unit Tests. Uma vez que estes objetos podem ser modificados para simplesmente retornar um valor específico quando for chamado através dos Mocks.

    Vamos agora criar um Unit Test simples, modificando o comportamento da classe de envio de e-mails de modo que ele simule uma falha no processo.

    import unittest
    from unittest.mock import MagicMock
    
    class TestOrders(unittest.TestCase):
        def test_order_send_email_process_failed_must_return_False(self):
                      email = Email()
    
            email.send = MagicMock(return_value=False)
            db = Database()
            db.save = MagicMock(return_value=True)
            order = Order(database_provider=db, email_provider=email)
            self.assertFalse(order.execute(name='Thiago', email='contato@thiagobrito.com.br', product='Livro', value=20))
    

    Stubs

    É uma versão testável de uma determinada função dentro do seu código, ou seja, em casos de funções como Sleep, em que ele vai levar um tempo específico para terminar sua execução ou funções que retornam a data atual, você pode substituir por um stub para não afetar a execução dos seus testes.

    Fazendo alterações no software

    Eu não tenho muito tempo e tenho que fazer as mudanças

    É sempre melhor trabalhar para fazer as mudanças da forma mais rápida possível. Imagine que você levará 2 horas para fazer uma mudança no seu código com todos os Unit Tests e com a melhor qualidade possível, muito provavelmente você nunca saberá quanto tempo levaria para fazer todas as alterações sem testes e garantir que estas mesmas alterações estejam corretas através de técnicas de debugging.

    Fazer a coisa certa, mesmo que traga uma impressão de trazer mais trabalho agora sempre vai trazer bons dividendos. A seguir existem algumas técnicas simples que farão com que você faça as mudanças com mais segurança e com um conjunto mínimo de garantias que farão com que a sua mudança seja feita com mais qualidade e mais rapidamente.

    Sprout Method or Class

    Quando você está criando uma nova funcionalidade e ela pode pode ser colocada em uma área completamente isolada de código, uma função ou classe por exemplo, faça desta forma.

    Esta é uma heurística que você pode utilizar para implementar a técnica de Sprout Method or Class:

    1. Identifique onde você quer fazer a alteração do código

    2. Se a mudança puder ser formulada em uma sequencia simples de declarações, escreva uma chamada para o novo método que vai fazer todo o trabalho necessário (deixe este método comentado por enquanto)

    3. Determine quais as variáveis locais são necessárias e coloque elas como parâmetros de entrada para a nova função

    4. Determine quais as variáveis de retorno são necessárias para fazer o trabalho na função de destino

    5. Crie a função nova utilizando TDD

    6. Ative a função nova no código na função a ser alterada que estava comentado anteriormente

    Vantagem: Você está testando os novos códigos e garantindo a qualidade deles mantendo uma boa interface entre código novo e códigos mais antigos. Testar o código novo é sempre melhor do que não testar nada.

    Desvantagem: Você está abrindo mão da qualidade da função que está sendo modificada, pois não está colocando ela efetivamente em uma bateria eficiente de testes.

    Wrap Method or Class

    Em geral, adicionar novos comportamentos em uma função ou classe é fácil, mas nem sempre é a coisa mais certa a ser feita.

    Provavelmente você está adicionando este novo código pelo simples fato de ele ter que ser executado ao mesmo tempo que o código que você está modificando. Em geral, uma função ou classe deve fazer apenas uma tarefa, qualquer coisa diferente disso já levanta suspeitas.

    Imagine que você tem uma classe simples que faz todo o gerenciamento de um empregado da empresa. Este é apenas um pedaço pequeno do código que contém a função de pagamento:

    class Employee:
        def __init__(self, payment):
            self._payment = payment
        def pay(self):
            return self._payment.add(50 * 1.89)
    

    Agora imagine que você deseja implementar a gravação de dados para um relatório posterior. Exatamente após o envio das informações de pagamento.

    Uma forma tentadora seria colocar a gravação logo após adicionar o pagamento para o usuário dentro da própria função pay, mas isso não é recomendável. Uma forma muito melhor seria "embalar" estas funcionalidades para aumentar a clareza.

    class Employee:
        def __init__(self, payment):
            self._payment = payment
        def pay(self):
            if self.dispatch_payment():
                self.save_report()
        def save_report(self):
            # guarda informações do relatório aqui
            return True
        def dispatch_payment(self):
            return self._payment.add(50 * 1.89)
    

    Vantagem: Diferente do Sprout Method a função que está sendo alterada não aumenta de tamanho e nem de comportamento. Quando você embala você não esta alterando o proposito de uma função especifica adicionando novas funcionalidades.

    Desvantagem: normalmente esta mudança leva a nomes ruins, por exemplo, no caso do dispatch_payment que teve que ser alterado para fazer esta nova implementação. O ideal seria fazer uma série de refactorings no código extraindo métodos de modo a chegar em nomes melhores, mas neste caso só é prudente fazer isso com uma boa bateria de testes na função a ser modificada.

    Sempre leva muito tempo pra fazer alterações no código devido a compilação

    Em geral as mudanças levam um tempo muito grande devido ao Lag Time, ou seja, este é o tempo onde uma mudança é feita e o tempo que ela leva para ser percebida no produto final. 

    O que precisamos fazer é reduzir ao máximo esse tempo para que possamos ir seguindo passo a passo nas mudanças da maneira mais rápida possível.

    O principal é que você deve começar a trabalhar somente nas classes que você deseja criar uma base de testes para elas. Desta forma, em linguagens onde é necessário um processo de compilação (Ex.: C++), você terá uma quantidade de código menor na sua base de testes.

    Uma técnica que ajuda muito a reduzir o tempo que levamos para fazer alterações no código, além das já mostradas anteriormente é a Dependency Inversion.

    O principio da inversão de dependência é quando suas classes dependem de uma interface, esta dependência é geralmente mínima e não intrusiva. Seu código não precisa mudar a não ser que a interface seja alterada, você pode editar as classes que implementam a interface ou adicionar novas classes que implementam a interface, todas sem impactar outros códigos que usam esta interface. Por esta razão, é sempre melhor depender de interfaces ou classes abstratas do que depender de classes concretas. Quando você depende de pontos menos voláteis de código, você minimiza a chance de que mudanças específicas gerem uma compilação massiva de código.

    Eu devo fazer uma mudança quais métodos devo testar?

    Algumas vezes temos que fazer mudanças no código e precisamos ter uma base de testes que mostra se o código realmente está se comportando de acordo com o esperado.

    Entender este comportamento é fácil quando nós temos uma base de código pequena e simples, mas nem sempre é isso que acontece em sistemas legado. Em geral uma forma de abordar a situação é testarmos todas as funções para validar o seu comportamento, mas nem sempre isso é possível.

    Em sistemas legado, tendemos a gastar muito mais tempo analisando quais serão os efeitos colaterais ao fazer uma determinada mudança.

    Uma forma de fazer isso é pegar uma área do código e ir fazendo uma análise do que vai acontecer no sistema caso esta parte do código pare de funcionar. 

    Uma técnica para identificar o que e como testar é verificar todos os pontos de código que estão utilizando a área que será alterada e criar testes que simulem esta utilização da forma mais macro possível.

    Se for possível criar testes que simulam o possível efeito colateral do ponto que será alterado é melhor ainda. Desta forma, você pode "espalhar sensores" em sua base de código que te sinalizará caso efeitos colaterais conforme suas mudanças forem sendo realizadas.

    Pontos de interceptação

    Ponto de interceptação é um ponto onde você poderá identificar os efeitos de uma mudança em particular no código. 

    Em geral a ideia é identificar e testar estes pontos de interceptação que estão próximos do código que você quer alterar. O primeiro motivo disso é a segurança. Em geral podemos dizer que "nós estamos testando isso porque ele afetará isso e aquilo outro que é o que estamos testando."

    Eu tenho que fazer mudanças, mas não sei quais testes criar

    O objetivo de criar Unit Tests deve ser sempre para garantir que um determinado comportamento é o esperado.

    Quando trabalhamos com códigos legado, pode ser que não tenhamos nenhum tipo de teste e, nestas situações é importante criar o máximo de testes em volta da área a ser modificada para ter uma rede de segurança mínima.

    Testes de caracterização

    Uma das formas mais diretas de fazer um teste é supor o que a função alvo deve fazer e criar testes que garantam este comportamento. Em geral, em qualquer sistema legado, é mais importante sabermos o que ele faz do que é esperado que ele faça.

    Os testes que tem como objetivo manter o comportamento atual é o que é conhecido como testes de caracterização, ou seja, são os testes que documentam efetivamente o comportamento atual do código, independente se ele está correto ou não. Uma vez que estes testes quebram, é um indicativo que as mudanças feitas alteraram o comportamento do sistema e deve ser verificado.

    Esta é a sequencia que pode ser usada para criar testes de caracterização:

    1. Coloque um pedaço de código dentro da bateria de testes

    2. Crie um assert que você sabe que vai falhar

    3. Deixe que este assert te mostre como é que o código funcione

    4. Modifique o teste de modo que ele esteja de acordo com o comportamento do código

    5. Repita em outras áreas do código.

    Quando nós refatoramos o código é sempre importante saber se o comportamento continua o mesmo depois das mudanças. Os testes de caracterização tem exatamente este objetivo, garantir que o comportamento (correto ou não) continua existindo após as mudanças feitas.