Por que escrever testes para o código que refatorarei?

15

Estou refatorando uma enorme classe de código legado. A refatoração (presumo) defende isso:

  1. escreva testes para a classe herdada
  2. refatorar o pedaço fora da classe

Problema: depois que refatorar a classe, meus testes na etapa 1 precisarão ser alterados. Por exemplo, o que antes era um método legado, agora pode ser uma classe separada. O que era um método agora pode ser de vários métodos. Todo o cenário da classe herdada pode ser destruído em algo novo e, portanto, os testes que escrevo na etapa 1 serão quase nulos. Em essência, adicionarei a Etapa 3. reescrever profusamente meus testes

Qual é o objetivo então escrever testes antes de refatorar? Parece mais um exercício acadêmico de criar mais trabalho para mim. Estou escrevendo testes para o método agora e estou aprendendo mais sobre como testar as coisas e como o método legado funciona. Pode-se aprender isso apenas lendo o próprio código legado, mas escrever testes é quase como esfregar meu nariz nele e também documentar esse conhecimento temporário em testes separados. Portanto, dessa maneira, quase não tenho escolha a não ser aprender o que o código está fazendo. Eu disse temporariamente aqui, porque refatorarei o código e toda a minha documentação e testes serão nulos e sem efeito por uma parte significativa, exceto que meu conhecimento permanecerá e permitirá que eu seja mais atualizado na refatoração.

Essa é a verdadeira razão para escrever testes antes de refatorar - para me ajudar a entender melhor o código? Tem que haver outra razão!

Por favor explique!

Nota:

Existe este post: faz sentido escrever testes para código legado quando não há tempo para uma refatoração completa? mas diz "escrever testes antes do refator", mas não diz "por que" ou o que fazer se "escrever testes" parecer "trabalho ocupado que será destruído em breve"

Dennis
fonte
1
Sua premissa está incorreta. Você não vai mudar seus testes. Você escreverá novos testes. A etapa 3 será "exclua todos os testes que agora estão desativados".
Pd #
1
A etapa 3 pode ler "Escreva novos testes. Exclua testes desativados". Eu acho que ainda equivale a destruir obra original
Dennis
3
Não, você deseja escrever os novos testes durante a etapa 2. E sim, a etapa 1 é destruída. Mas foi uma perda de tempo? Não, porque isso garante a você que não está quebrando nada durante a etapa 2. Seus novos testes não.
Pd #
3
@Dennis - embora eu compartilhe as mesmas preocupações que você tem com relação às situações, poderíamos considerar a maioria dos esforços de refatoração como "destruindo o trabalho original", mas se nunca o destruíssemos, nunca nos afastaríamos do código de espaguete com 10 mil linhas em um Arquivo. Provavelmente o mesmo deve acontecer em testes de unidade, eles andam de mãos dadas com o código que estão testando. À medida que o código evolui e as coisas são movidas e / ou excluídas, os testes de unidade também devem evoluir com ele.
DXM 21/03
"Compreender o código" não é uma pequena vantagem. Como você espera refatorar um programa que não entende? É inevitável, e que melhor maneira de demonstrar a verdadeira compreensão de um programa do que escrever um teste completo. Além disso, deve-se dizer que, quanto mais abstrato for o teste, menor a probabilidade de que você precise copiá-lo mais tarde; portanto, se houver, fique com o teste de alto nível primeiro.
Neil

Respostas:

46

Refatorar é limpar um pedaço de código (por exemplo, melhorar o estilo, o design ou os algoritmos), sem alterar o comportamento (visível externamente). Você escreve testes para não se certificar de que o código antes e depois da refatoração é o mesmo; em vez disso, você escreve testes como um indicador de que seu aplicativo antes e depois da refatoração se comporta da mesma maneira: O novo código é compatível e nenhum novo erro foi introduzido.

Sua principal preocupação deve ser escrever testes de unidade para a interface pública do seu software. Essa interface não deve ser alterada; portanto, os testes (que são uma verificação automática dessa interface) também não devem ser alterados.

No entanto, os testes também são úteis para localizar erros, portanto, pode fazer sentido escrever testes para partes particulares do seu software. Espera-se que esses testes sejam alterados durante a refatoração. Se você deseja alterar um detalhe de implementação (como a nomeação de uma função privada), primeiro atualiza os testes para refletir suas expectativas alteradas e, em seguida, verifique se o teste falha (suas expectativas não são atendidas) e depois altera o código real e verifique se todos os testes passam novamente. Em nenhum momento os testes para a interface pública devem começar a falhar.

Isso é mais difícil ao realizar alterações em uma escala maior, por exemplo, redesenhando várias partes dependentes de código. Mas haverá algum tipo de limite e, nesse limite, você poderá escrever testes.

amon
fonte
6
+1. Leia minha mente, escreveu minha resposta. Ponto importante: pode ser necessário escrever testes de unidade para mostrar que os mesmos erros ainda existem após a refatoração!
David.pfx 22/03
Pergunta: por que, no exemplo de alteração de nome da função, você altera o teste primeiro para garantir que ele falhe? Quero dizer que é claro que falhará quando você a alterar - você interrompeu a conexão que os vinculadores usam para amarrar o código! Você talvez esteja esperando que exista outra função privada existente com o nome que você acabou de escolher recentemente e verifique se não é esse o caso, caso você a tenha perdido? Vejo que isso lhe dará uma certa garantia na fronteira com o TOC, mas, neste caso, parece um exagero. Existe alguma razão possível para o teste no seu exemplo não falhar?
Dennis
^ cont: como técnica geral, vejo que é bom fazer a verificação passo a passo do seu código para detectar que as coisas estão erradas o mais cedo possível. Assim como você pode não ficar doente se não lavar as mãos toda vez, mas simplesmente lavar as mãos como um hábito o manterá mais saudável em geral, se você entrar em contato com coisas contaminadas ou não. Aqui, você pode lavar as mãos de maneira supérflua às vezes ou testar o código de maneira supérflua às vezes, mas isso ajuda a manter você e seu código saudáveis. É esse o seu ponto?
Dennis
@Dennis, na verdade, eu estava inconscientemente descrevendo um experimento cientificamente correto: não podemos dizer qual parâmetro realmente afetou o resultado ao alterar mais um parâmetro. Lembre-se de que os testes são códigos e todos os códigos contêm bugs. Você vai ao inferno dos programadores por não executar os testes antes de tocar no código? Certamente que não: durante a execução dos testes seria ideal, seu julgamento profissional será necessário. Observe ainda que um teste falhou se não for compilado e que minha resposta também é aplicável a idiomas dinâmicos, não apenas a idiomas estáticos com um vinculador.
amon
2
Ao corrigir vários erros durante a refatoração, percebo que não teria feito o código se mover tão facilmente sem ter testes. Os testes me alertam sobre as "diferenças" comportamentais / funcionais que introduzo alterando meu código.
Dennis
7

Ah, mantendo sistemas legados.

Idealmente, seus testes tratam a classe apenas por meio de sua interface com o restante da base de código, outros sistemas e / ou interface do usuário. Interfaces. Você não pode refatorar a interface sem afetar os componentes upstream ou downstream. Se é tudo uma bagunça fortemente acoplada, é melhor considerar o esforço de reescrever em vez de refatorar, mas é em grande parte semântica.

Edit: Digamos que parte do seu código mede algo e tem uma função que simplesmente retorna um valor. A única interface está chamando a função / método / outros enfeites e recebendo o valor retornado. É um acoplamento frouxo e fácil de testar na unidade. Se o seu programa principal possui um subcomponente que gerencia um buffer, e todas as chamadas para ele dependem do próprio buffer, de algumas variáveis ​​de controle e retrocede mensagens de erro por outra seção do código, você pode dizer que está bem acoplado e teste de unidade difícil. Você ainda pode fazer isso com quantidades suficientes de objetos simulados e outros enfeites, mas fica confuso. Especialmente em c. Qualquer quantidade de refatoração de como o buffer funciona interromperá o subcomponente.
End Edit

Se você estiver testando sua classe através de interfaces que permanecem estáveis, seus testes deverão ser válidos antes e depois da refatoração. Isso permite que você faça alterações com confiança de que não a quebrou. Pelo menos, mais confiança.

Também permite fazer alterações incrementais. Se esse é um projeto grande, acho que você não quer simplesmente desmembrar tudo, criar um sistema totalmente novo e começar a desenvolver testes. Você pode alterar uma parte, testá-lo e garantir que a alteração não derrube o resto do sistema. Ou, se houver, você pode pelo menos ver a bagunça gigante emaranhada se desenvolvendo, em vez de ser surpreendida por ela ao soltar.

Embora você possa dividir um método em três, eles ainda farão a mesma coisa que o método anterior, para que você possa fazer o teste do método antigo e dividi-lo em três. O esforço de escrever o primeiro teste não é desperdiçado.

Além disso, tratar o conhecimento do sistema legado como "conhecimento temporário" não está indo bem. Saber como era antes é essencial quando se trata de sistemas legados. Muito útil para a velha questão de "por que diabos isso acontece?"

Philip
fonte
Acho que entendi, mas você me perdeu nas interfaces. ou seja, testes que estou escrevendo agora, verifique se determinadas variáveis ​​foram preenchidas corretamente, depois de chamar o método em teste. Se essas variáveis ​​forem alteradas ou refatoradas, também serão meus testes. A classe herdada existente com a qual estou trabalhando não possui interfaces / getters / setters por seh, o que faria alterações variáveis ​​ou menos trabalhosas. Mas, novamente, não sei ao certo o que você quer dizer com interfaces quando se trata de código legado. Talvez eu possa criar alguns? Mas isso será refatoração.
Dennis
1
Sim, se você tem uma classe divina que faz tudo, então realmente não há interfaces. Mas se chamar outra classe, a classe superior espera que se comporte de uma certa maneira, e os testes de unidade podem verificar se o fazem. Ainda assim, eu não diria que você não precisará atualizar seus testes de unidade durante a refatoração.
Philip
4

Minha própria resposta / realização:

Ao corrigir vários erros durante a refatoração, percebo que não teria feito o código se mover tão facilmente sem ter testes. Os testes me alertam sobre as "diferenças" comportamentais / funcionais que apresento alterando meu código.

Você não precisa estar hiperconsciente quando tiver bons testes. Você pode editar seu código de maneira mais descontraída. Os testes fazem as verificações e verificações de sanidade para você.

Além disso, meus testes permaneceram praticamente os mesmos que eu refatorei e não foram destruídos. Na verdade, notei algumas oportunidades extras para adicionar asserções aos meus testes à medida que me aprofundava no código.

ATUALIZAR

Bem, agora estou alterando bastante meus testes: / Como refatorei a função original (removi a função e criei uma nova classe de limpador, movendo o cotão que costumava estar dentro da função fora da nova classe), agora o código em teste que eu executei antes leva parâmetros diferentes sob um nome de classe diferente e produz resultados diferentes (o código original com o cotão tinha mais resultados para testar). E assim meus testes precisam refletir essas alterações e basicamente estou reescrevendo meus testes em algo novo.

Suponho que existem outras soluções que posso fazer para evitar a reescrita de testes. ou seja, mantenha o nome da função antiga com o novo código e o cotão dentro dele ... mas não sei se é a melhor ideia e ainda não tenho muita experiência para julgar o que fazer.

Dennis
fonte
Parece mais que você redesenhou o aplicativo junto com a refatoração.
Jeffo
Quando é refatoração e quando é reprojetado? ou seja, ao refatorar, é difícil não dividir classes maiores e pesadas em classes menores, e também movê-las. Então, sim, não tenho muita certeza da distinção, mas talvez esteja fazendo as duas coisas.
Dennis
3

Use seus testes para direcionar seu código ao fazê-lo. No código legado, isso significa escrever testes para o código que você vai alterar. Dessa forma, eles não são um artefato separado. Os testes devem ser sobre o que o código precisa alcançar e não sobre o interior de como ele o faz.

Geralmente, você deseja adicionar testes ao código que não possui nenhum) para o código que você vai refatorar para garantir que o comportamento dos códigos continue funcionando conforme o esperado. Assim, a execução contínua do conjunto de testes durante a refatoração é uma rede de segurança fantástica. O pensamento de alterar o código sem um conjunto de testes para confirmar que as mudanças não estão afetando algo imprevisto é assustador.

Quanto ao âmago da questão de atualizar testes antigos, escrever novos testes, excluir testes antigos etc. Apenas vejo isso como parte do custo do desenvolvimento de software profissional moderno.

Michael Durrant
fonte
Seu primeiro parágrafo parece estar defendendo a ignorância da etapa 1 e escrevendo testes à medida que ele avança; seu segundo parágrafo parece contradizer isso.
Pdr
Atualizei minha resposta.
Michael Durrant
2

Qual é o objetivo da refatoração no seu caso específico?

Suponhamos, com o objetivo de responder à minha resposta, que todos acreditamos (até certo ponto) no TDD (Test-Driven Development).

Se o objetivo da sua refatoração é limpar o código existente sem alterar o comportamento existente, escrever testes antes da refatoração é como garantir que você não alterou o comportamento do código, se for bem-sucedido, os testes serão bem-sucedidos antes e depois você refatorar.

  • Os testes ajudarão você a garantir que seu novo trabalho realmente funcione.

  • Os testes provavelmente também descobrirão casos em que o trabalho original não funciona.

Mas como você realmente faz uma refatoração significativa sem afetar o comportamento até certo ponto?

Aqui está uma pequena lista de algumas coisas que podem acontecer durante a refatoração:

  • renomear variável
  • renomear função
  • adicionar função
  • função de exclusão
  • dividir a função em duas ou mais funções
  • combinar duas ou mais funções em uma função
  • classe dividida
  • combinar aulas
  • renomear classe

Vou argumentar que cada uma dessas atividades listadas muda o comportamento de alguma maneira.

E vou argumentar que, se sua refatoração mudar o comportamento, seus testes ainda serão da maneira que você garantirá que não quebrou nada.

Talvez o comportamento não mude no nível da macro, mas o objetivo do teste de unidade não é garantir o comportamento da macro. Isso é teste de integração . O objetivo do teste de unidade é garantir que os bits e partes individuais dos quais você constrói seu produto não sejam quebrados. Corrente, elo mais fraco, etc.

Que tal esse cenário:

  • Presuma que você tem function bar()

  • function foo() faz uma chamada para bar()

  • function flee() também faz uma chamada para funcionar bar()

  • Só por variedade, flam()faz uma ligação parafoo()

  • Tudo funciona esplendidamente (aparentemente, pelo menos).

  • Você refatora ...

  • bar() é renomeado para barista()

  • flee() é alterado para chamar barista()

  • foo()não é alterado para chamarbarista()

Obviamente, seus testes para ambos foo()e flam()agora falham.

Talvez você não tenha percebido o foo()chamado bar()em primeiro lugar. Você certamente não percebeu que isso flam()dependia bar()por meio de foo().

Tanto faz. O ponto é que seus testes descobrirão o comportamento recentemente quebrado de ambos foo()e flam(), de maneira incremental, durante o trabalho de refatoração.

Os testes acabam ajudando você a refatorar bem.

A menos que você não tenha nenhum teste.

Esse é um exemplo artificial. Há quem argumente que, se mudar os bar()intervalos foo(), foo()era muito complexo para começar e deveria ser dividido. Mas os procedimentos podem chamar outros procedimentos por um motivo e é impossível eliminar toda a complexidade, certo? Nosso trabalho é gerenciar razoavelmente bem a complexidade.

Considere outro cenário.

Você está construindo um edifício.

Você constrói um andaime para ajudar a garantir que o edifício seja construído corretamente.

O andaime ajuda a construir um poço de elevador, entre outras coisas. Depois, você derruba o andaime, mas o poço do elevador permanece. Você destruiu o "trabalho original" destruindo os andaimes.

A analogia é tênue, mas o ponto é que não é inédito criar ferramentas para ajudá-lo a criar produtos. Mesmo que as ferramentas não sejam permanentes, elas são úteis (até necessárias). Carpinteiros fazem gabaritos o tempo todo, às vezes apenas para um emprego. Depois eles separam os gabaritos, às vezes usando as peças para construir outros gabaritos para outros trabalhos, às vezes não. Mas isso não torna os gabaritos inúteis ou desperdiçados.

Craig
fonte