Você pode substituir os If/Else por… Template Method


Às vezes a gente se pega vendo uns if/else por aí, se repetindo aqui e ali e pensa: “engraçado você por aqui de novo”. Esse pensamento é o seu sensor automático de code smells e ele vai ficando melhor a medida que você exercita boas práticas como OO e seus princípios, design patterns e algumas outras técnicas de refactoring.

Por falar em code smells, a duplicação de código é um dos problemas mais tranquilos de se resolver com a refatoração - especialmente porque é um problema fácil de identificar. Embora seja um problema fácil de identificar, existem várias maneiras de resolver. Uma das estruturas envolvidas em duplicação de código são os if/else, comuns em qualquer projeto. Nesse post, vou mostrar como aplicar o Template Method, um design pattern simples mas que pode ajudar ~na luta~ contra as repetições de código e deixar o seu código um pouco mais elegante.

Nesse exemplo vou pegar uma funcionalidade nova do meu pequeno plugin picked, feito para executar os testes que não foram commitados ainda (unstashed). A funcionalidade que vamos adicionar é executar os testes dos arquivos modificados na branch atual. Vamos nessa?


Um pouco de contexto

Para que vocês entendam os próximos exemplos, preciso falar brevemente sobre o pytest-picked. A ideia do plugin é executar os testes que você modificou recentemente mas que ainda não foram commitados (supondo que você está utilizando o Git). O motivo por trás é: hoje eu trabalho com uma base de código gigante e não é viável rodar todos testes de uma vez - caso comum hoje em dia.

Meu processo antes do plugin:

Escrevo/modifico um teste

Verifico os testes alterados com git status

Copio e colo no terminal com o pytest na frente

Meu processo com o plugin:

Rodo pytest --picked

PROFIT 😎

Bem simples.

O projeto ainda está no início mas desde o primeiro rascunho as pessoas já perguntavam: e os testes que foram modificados na branch atual?. Pergunta bem relevante, afinal se os testes demoram pra executar no CI, melhor garantir que ao menos os modificados na branch atual funcionam, não é? Principalmente se você está criando uma branch por meses… cof cof.

Mas vamos ver um pouco do código e como podemos adicionar essa feature.

Unstaged

Esse é o modo que roda os testes modificados (ou adicionados) porém não commitados. Para recuperar a lista dos arquivos modificados, bastava executar: git status --short:

A  setup.py
 U tests/test_pytest_picked.py
?? .pylintrc

Isso era feito a partir do método _get_git_status.

def _get_git_status():
    command = ["git", "status", "--short"]
    output = subprocess.run(command, stdout=subprocess.PIPE)
    return output.stdout.decode("utf-8")

Depois de recuperar a saída desse comando, o próximo passo era parsear essa saída:

def _extract_file_or_folder(candidate):
    start_path_index = 3
    rename_indicator = "-> "

    if rename_indicator in candidate:
        indicator_index = candidate.find(rename_indicator)
        start_path_index = indicator_index + len(rename_indicator)
    return candidate[start_path_index:]

O método que orquestrava tudo isso é o _affected_tests()

def _affected_tests(test_file_convention):
    raw_output = _get_git_status(). # olha o _get_git_status aqui!

    [...]

    folders, files = [], []
    for candidate in raw_output.splitlines():
        file_or_folder = _extract_file_or_folder(candidate)
		 [...]
    return files, folders

Para executar esse modo: pytest --picked ou pytest --picked --mode=unstaged

Como vocês podem ver, todo o código está bem acoplado a um único jeito de verificar os testes afetados. Isso significa que para adicionar um novo jeito nós temos que mexer em várias partes do código. Se muitas partes precisam ser tocadas para adicionar ou modificar um comportamento, é um indício de que temos um alto acoplamento no código, o que significa que existe um problem no design da aplicação. Consequências disso? Mais tempo gasto na manutenção do código e aumento do risco de bugs.

Mesmo sabendo disso, por hora vamos tentar adicionar a funcionalidade de testes de branch. 😈

Branch

Esse é o modo que executa todos os testes já commitados na branch atual. Um dos comandos para recuperar os arquivos modificados de uma branch, assumindo que sua branch principal de desenvolvimento é a master, é git diff --name-only master:

pytest_picked.py
pytest_picked/plugin.py
setup.py
tests/test_pytest_picked.py

Para deixar o plugin ciente que temos uma nova opção, adicionamos o famigerado if:

def _get_git_status(mode="unstaged"):
    if mode == "unstaged":
        command = ["git", "status", "--short"]
    else:
        command = ["git", "diff", "--name-only", "master"]

Detalhe: a saída do novo comando já é diferente do que temos no modo anterior. Ou seja, vamos precisar mudar o nosso parser também! Dessa vez, passamos o modo

def _affected_tests(raw_output, test_file_convention, mode="unstaged"):
    [...]

    folders, files = [], []
    for candidate in raw_output.splitlines():
        if mode == "unstaged":  # OLHA O IF MANO
            file_or_folder = _extract_file_or_folder(candidate)
        else:
            file_or_folder = candidate
        [...]
    return files, folders

Para executar esse modo: pytest --picked --mode=branch

O código funciona mas bonito… não está não. Caso o plugin cresça mais, com outros métodos, por exemplo, essa estrutura terá que ser carregada para lá também. E mais: caso alguém decida adicionar um modo all, onde os modos unstaged e branch são combinados, um novo if/else precisa ser acrescentado.

Unstaged e Branch: remake

Pensando de maneira mais abstrata, podemos perceber que unstaged e branch tem coisas em comum: os dois modos tem um comando Git e um parser. Os dois são um modo de recuperar informações de testes que precisam ser executados.

Vamos tentar colocar essa abstração em código!

class Mode(ABC):
    [...]
     
    @abstractmethod
    def command(self):
        pass
    
    @abstractmethod
    def parser(self, candidate):
        pass

A classe acima é uma abstração de um Modo. Obrigatoriamente, os modos que a implementarem precisam ter um command e um parser. Dessa forma, elas vão se comportar da mesma maneira. Colocamos a lógica em cada implementação dessa classe e em todos os lugares onde um modo for necessário não será preciso ter mais if/elses, afinal estaremos abusando de princípios OO e do Duck Typing.

Mas ainda está faltando algumas peças. Como podemos ver na implementação do Unstaged, são poucos os lugares onde precisamos modificar o comportamento do plugin dado o seu modo. É como se eles funcionassem igual mas com diferenças sutis.

É aí que surge o Template Method, um design pattern que sugere o uso de uma classe para criar um template do funcionamento geral combinado com métodos abstratos que irão encapsular as diferenças entre eles. No nosso caso, ficaria mais ou menos assim:

class Mode(ABC):
     [...]
     def affected_tests(self):
        [...]
        for candidate in raw_output.splitlines():
            file_or_folder = self.parser(candidate)  # OLHA AQUI O PARSER!
            [...]
        return files, folders
     
     def git_output(self):  # NESSE MÉTODO O COMMAND
        output = subprocess.run(self.command(), stdout=subprocess.PIPE)
        return output.stdout.decode("utf-8")
     
    [...]
    
    @abstractmethod
    def command(self):
        pass
    
    @abstractmethod
    def parser(self, candidate):
        pass

Essa classe tem toda a estrutura necessária para qualquer modo funcionar. Quando você precisar implementar um novo modo, basta herdar dela e implementar os métodos command() e parser().

Vamos ver como ficaram os nossos modos implementados:

class Unstaged(Mode):
    def command(self):
       return ["git", "status", "--short"]
    
    def parser(self, candidate):
       """Discard the first 3 characters."""
       start_path_index = 3
       rename_indicator = "-> "
       [...]
       return candidate[start_path_index:]
class Branch(Mode):
    def command(self):
       return ["git", "diff", "--name-only", "master"]
    
    def parser(self, candidate):
       """The candidate itself."""
       return candidate

Para não dizer que não ficou nenhum if/else

if picked_mode == "branch":
    mode = Branch(test_file_convention)
else:
    mode = Unstaged(test_file_convention)

picked_files, picked_folders = mode.affected_tests()

O mais legal é que agora o código do plugin não precisa se preocupar sobre o modo, afinal não é a responsabilidade dele. Cada modo sabe como deve se comportar.

Satisfeita? Eu não. Acho que ainda dá pra tirar esse if/else:

modes = {
    "branch": Branch(test_file_convention),
    "unstaged": Unstaged(test_file_convention),
}

mode = modes[picked_mode]

picked_files, picked_folders = mode.affected_tests()

Cada vez que tivermos um novo modo, basta adicioná-lo nesse dicionário.

Agora sim. Responsabilidades separadas, lógica isolada, sem if/elses espalhados pelo código… YAS QUEEN!


Espero que vocês tenham curtido essa pequena refatoração com um pouco de Python, OO e Design Patterns. Vocês podem ver a implementação da funcionalidade branch aqui.

Em programação existem diversas formas de se resolver um mesmo problema. Essa foi só uma delas. Fiquem a vontade para dizer o que você achou.

Até mais!


comments powered by Disqus