Faz sentido escrever testes para código legado quando não há tempo para uma refatoração completa?

72

Eu costumo tentar seguir o conselho do livro Trabalhando efetivamente com o legado de bacalhau e . Eu quebro dependências, movo partes do código para métodos @VisibleForTesting public static e para novas classes para tornar o código (ou pelo menos parte dele) testável. E escrevo testes para ter certeza de que não quebro nada quando estou modificando ou adicionando novas funções.

Um colega diz que eu não deveria fazer isso. Seu raciocínio:

  • O código original pode não funcionar corretamente em primeiro lugar. E escrever testes para isso torna as futuras correções e modificações mais difíceis, pois os desenvolvedores também precisam entender e modificar os testes.
  • Se for um código GUI com alguma lógica (~ 12 linhas, bloco 2-3 if / else, por exemplo), um teste não vale a pena, pois o código é muito trivial para começar.
  • Padrões ruins semelhantes podem existir em outras partes da base de código também (o que eu não vi ainda, sou bastante novo); será mais fácil limpá-los em uma grande refatoração. A extração da lógica poderia minar essa possibilidade futura.

Devo evitar extrair partes testáveis e escrever testes se não tivermos tempo para uma refatoração completa? Existe alguma desvantagem para isso que eu deva considerar?

    
por is4 06.02.2014 / 08:15
fonte

10 respostas

100

Esta é a minha impressão pessoal não científica: todas as três razões parecem ilusões cognitivas generalizadas, mas falsas.

  1. Claro, o código existente pode estar errado. Também pode estar certo. Como a aplicação como um todo parece ter valor para você (do contrário, você simplesmente a descartaria), na ausência de informações mais específicas, você deve assumir que ela é predominantemente correta. "Testes de escrita tornam as coisas mais difíceis porque há mais código envolvido no geral" é uma atitude simplista e muito errada.
  2. Por todos os meios, dedique seus esforços de refatoração, teste e melhoria nos lugares em que eles agregam o maior valor com o mínimo de esforço. Geralmente, as sub-rotinas de GUI com formatação de valor não são a primeira prioridade. Mas não testar algo porque "é simples" é também uma atitude muito errada. Praticamente todos os erros graves são cometidos porque as pessoas achavam que entendiam algo melhor do que na verdade.
  3. "Vamos fazer tudo de uma só vez no futuro" é um bom pensamento. Normalmente, o grande swoop permanece firme no futuro, enquanto no presente nada acontece. Eu, estou firme na convicção "lenta e firme vence a corrida".
por 06.02.2014 / 08:28
fonte
50

Algumas reflexões:

Quando você refatora o código legado, não importa se alguns dos testes que você escreve contradizem as especificações ideais. O que importa é que eles testem o comportamento atual do programa . Refatorar é tomar minúsculas etapas iso-funcionais para tornar o código mais limpo; você não quer se engajar na correção de erros enquanto você está refatorando. Além disso, se você encontrar um bug flagrante, não será perdido. Você sempre pode escrever um teste de regressão para ele e desabilitá-lo temporariamente ou inserir uma tarefa de correção no seu backlog para mais tarde. Uma coisa de cada vez.

Concordo que o código GUI puro é difícil de testar e talvez não seja um bom ajuste para a refatoração " Trabalhando efetivamente ... ". No entanto, isso não significa que você não deve extrair um comportamento que não tenha nada a ver com a camada da GUI e testar o código extraído. E "12 linhas, bloco 2-3 if / else" não é trivial. Todo o código com pelo menos um pouco de lógica condicional deve ser testado.

Na minha experiência, grandes refatorações não são fáceis e elas raramente funcionam. Se você não definir metas precisas e minúsculas, há um grande risco de embarcar em um retrabalho sem fim, onde você nunca conseguirá se levantar. Quanto maior a mudança, mais você corre o risco de quebrar alguma coisa e, quanto mais problemas, descobrirá onde você falhou.

Melhorar as coisas progressivamente com pequenas refatorações ad hoc não está "minando possibilidades futuras", está capacitando-as - solidificando o terreno pantanoso onde está sua aplicação. Você definitivamente deveria fazer isso.

    
por 06.02.2014 / 12:38
fonte
17

Além disso, re: "O código original pode não funcionar corretamente" - isso não significa que você apenas alterou o comportamento do código sem se preocupar com o impacto. Outro código pode depender do que parece ser um comportamento interrompido ou efeitos colaterais da implementação atual. A cobertura de teste do aplicativo existente deve facilitar a refatoração posterior, porque ele ajudará você a descobrir quando acidentalmente você quebrou alguma coisa. Você deve testar as partes mais importantes primeiro.

    
por 06.02.2014 / 12:01
fonte
14

A resposta de Kilian cobre os aspectos mais importantes, mas eu quero expandir nos pontos 1 e 3.

Se um desenvolvedor quiser alterar (refatorar, estender, depurar) o código, ela terá que entender. Ela precisa garantir que suas alterações afetem exatamente o comportamento que ela quer (nada no caso de refatoração) e nada mais.

Se há testes, então ela tem que entender os testes também, claro. Ao mesmo tempo, os testes devem ajudá-la a entender o código principal, e os testes são muito mais fáceis de entender do que o código funcional (a menos que sejam testes ruins). E os testes ajudam a mostrar o que mudou no comportamento do código antigo. Mesmo que o código original esteja errado e o teste teste esse comportamento errado, isso ainda é uma vantagem.

No entanto, isso requer que os testes sejam documentados como teste de comportamento preexistente, não uma especificação.

Alguns pensamentos sobre o ponto 3 também: além do fato de que o "grande swoop" raramente acontece realmente, há também outra coisa: não é realmente mais fácil. Para ser mais fácil, várias condições teriam que ser aplicadas:

  • O antipadrão a ser refatorado precisa ser facilmente encontrado. Todos os seus singletons são chamados de XYZSingleton ? Seu getter de instância é sempre chamado de getInstance() ? E como você encontra suas hierarquias excessivamente profundas? Como você procura por seus objetos divinos? Eles exigem análise de métricas de código e, em seguida, inspecionam manualmente as métricas. Ou você simplesmente tropeça neles enquanto trabalha, como você fez.
  • A refatoração precisa ser mecânica. Na maioria dos casos, a parte difícil da refatoração é entender bem o código existente para saber como alterá-lo. Singletons novamente: se o singleton sumiu, como você obtém as informações necessárias para seus usuários? Isso geralmente significa entender o callgraph local para que você saiba de onde obter as informações. Agora, o que é mais fácil: pesquisar os dez singletons em seu aplicativo, entender os usos de cada um deles (o que leva a precisar entender 60% da base de código) e excluí-los? Ou pegando o código que você já entendeu (porque você está trabalhando nisso agora) e rasgando os singletons que estão sendo usados lá fora? Se a refatoração não é tão mecânica que requer pouco ou nenhum conhecimento do código circundante, não há uso em agrupá-lo.
  • A refatoração precisa ser automatizada. Isso é um pouco baseado em opinião, mas aqui vai. Um pouco de refatoração é divertido e satisfatório. Muita refatoração é tediosa e chata. Deixar o pedaço de código que você acabou de trabalhar em um estado melhor lhe dá uma sensação agradável e calorosa, antes de passar para coisas mais interessantes. Tentar refatorar toda uma base de código deixará você frustrado e irritado com os programadores idiotas que a escreveram. Se você quiser fazer uma grande refatoração, então ela precisa ser amplamente automatizada para minimizar a frustração. Isso é, de certo modo, uma fusão dos dois primeiros pontos: você pode automatizar a refatoração apenas se puder automatizar a localização do código incorreto (ou seja, facilmente encontrado) e automatizar a alteração (ou seja, mecânico).
  • A melhoria gradual contribui para um melhor business case. A grande refatoração de swoop é incrivelmente disruptiva. Se você refatorar uma parte do código, invariavelmente você entra em conflitos de mesclagem com outras pessoas que trabalham nela, porque você acabou de dividir o método que eles estavam modificando em cinco partes. Quando você refaz um código de tamanho razoável, você tem conflitos com algumas pessoas (1-2 ao dividir a megafunção de 600 linhas, 2-4 ao dividir o objeto deus, 5 ao extrair o singleton de um módulo ), mas você teria tido esses conflitos de qualquer maneira por causa de suas edições principais. Quando você faz uma refatoração em toda a base de código, você entra em conflito com todos . Sem mencionar que liga alguns desenvolvedores por dias. Melhoria gradual faz com que cada modificação de código demore um pouco mais. Isso torna mais previsível, e não há um período de tempo tão visível quando nada acontece, exceto a limpeza.
por 06.02.2014 / 11:47
fonte
12

Há uma cultura em algumas empresas em que elas são reticentes em permitir que os desenvolvedores em qualquer momento aprimorem códigos que não gerem valor adicional diretamente. nova funcionalidade.

Eu provavelmente estou pregando para os convertidos aqui, mas isso é claramente falsa economia. Código limpo e conciso beneficia os desenvolvedores subseqüentes. É apenas que o retorno não é imediatamente evidente.

Eu assino pessoalmente o Princípio do escoteiro mas outros (como você viu) não.

Dito isto, o software sofre de entropia e acumula dívida técnica. Desenvolvedores anteriores com pouco tempo (ou talvez apenas preguiçosos ou inexperientes) podem ter implementado soluções sub-ótimas de bugs sobre as bem projetadas. Embora possa parecer desejável refatorá-los, você corre o risco de introduzir novos bugs no código de trabalho (para os usuários de qualquer maneira).

Algumas mudanças são de menor risco que outras. Por exemplo, onde eu trabalho lá tende a ser um monte de código duplicado que pode seguramente ser levado a uma sub-rotina com impacto mínimo.

Em última análise, você precisa fazer um julgamento sobre até onde vai a refatoração, mas há inegavelmente valor ao adicionar testes automatizados se eles já não existirem.

    
por 06.02.2014 / 10:17
fonte
4

Na minha experiência, um teste de caracterização funciona bem. Ele oferece uma cobertura de teste ampla, mas não muito específica, com relativa rapidez, mas pode ser difícil de implementar para aplicativos de GUI.

Em seguida, escrevo testes de unidade para as partes que você deseja alterar e faço isso toda vez que quiser fazer uma alteração, aumentando assim a cobertura do teste de unidade ao longo do tempo.

Essa abordagem oferece uma boa ideia se as alterações afetarem outras partes do sistema e permitir que você faça as alterações necessárias antes.

    
por 07.02.2014 / 11:17
fonte
3

Re: "O código original pode não funcionar corretamente":

Os testes não estão escritos em pedra. Eles podem ser alterados. E se você testou um recurso que estava errado, deve ser fácil reescrever o teste mais corretamente. Somente o resultado esperado da função testada deveria ter mudado, afinal.

    
por 06.02.2014 / 10:15
fonte
3

Bem, sim. Respondendo como engenheiro de teste de software. Em primeiro lugar você deve testar tudo o que você faz de qualquer maneira. Porque se você não fizer isso, você não sabe se funciona ou não. Isto pode parecer óbvio para nós, mas tenho colegas que o vêem de forma diferente. Mesmo que o seu projeto seja pequeno e talvez nunca seja entregue, você deve procurar o usuário na cara e dizer que sabe que funciona porque você o testou.

O código não-trivial sempre contém bugs (citando um cara da uni; e se não houver bugs nele, é trivial) e nosso trabalho é encontrá-los antes que o cliente o faça. O código legado tem erros herdados. Se o código original não funcionar da maneira que deveria, você quer saber sobre isso, acredite em mim. Bugs são bons se você souber sobre eles, não tenha medo de encontrá-los, é para isso que servem as notas de lançamento.

Se bem me lembro, o livro de refatoração diz para testar constantemente de qualquer maneira, então é parte do processo.

    
por 06.02.2014 / 16:37
fonte
3

Faça a cobertura de teste automatizada.

Tenha cuidado com o pensamento positivo, tanto seu quanto de seus clientes e chefes. Tanto quanto eu gostaria de acreditar que as minhas alterações estarão corretas na primeira vez e eu só terei que testar uma vez, eu aprendi a tratar esse tipo de pensamento da mesma maneira que eu trato os emails de embuste nigeriano. Bem, principalmente; Eu nunca fui por um email fraudulento, mas recentemente (quando gritei) eu desisti de não usar as melhores práticas. Foi uma experiência dolorosa que arrastou (dispendiosamente) e assim por diante. Nunca mais!

Eu tenho uma citação favorita do quadrinho da web Freefall: "Você já trabalhou em um campo complexo onde o supervisor tem apenas uma idéia aproximada dos detalhes técnicos? ... Então você sabe o caminho mais certo para fazer com que seu supervisor o fracasso é seguir toda a sua ordem sem questionar. "

É provavelmente apropriado limitar a quantidade de tempo que você investe.

    
por 07.02.2014 / 07:46
fonte
1

Se você está lidando com grandes quantidades de código legado que não está sendo testado atualmente, obter a cobertura de teste agora, em vez de esperar por uma grande reescrita hipotética no futuro, é a decisão certa. Começar escrevendo testes unitários não é.

Sem testes automatizados, depois de fazer qualquer alteração no código, é necessário fazer alguns testes manuais para concluir o teste do aplicativo, para garantir que ele esteja funcionando. Comece escrevendo testes de integração de alto nível para substituir isso. Se o seu aplicativo ler arquivos, validá-los, processar os dados de alguma forma e exibir os resultados desejados, os testes capturam tudo isso.

O ideal é que você tenha dados de um plano de teste manual ou seja capaz de obter uma amostra de dados de produção reais para usar. Se não, já que o aplicativo está em produção, na maioria dos casos ele está fazendo o que deveria ser, portanto, basta criar dados que atingirão todos os pontos altos e assumirão que a saída está correta por enquanto. Não é pior do que tomar uma pequena função, assumindo que está fazendo o que o nome ou qualquer comentário sugere que deveria estar fazendo, e escrevendo testes assumindo que está funcionando corretamente.

IntegrationTestCase1()
{
    var input = ReadDataFile("path\to\test\data\case1in.ext");
    bool validInput = ValidateData(input);
    Assert.IsTrue(validInput);

    var processedData = ProcessData(input);
    Assert.AreEqual(0, processedData.Errors.Count);

    bool writeError = WriteFile(processedData, "temp\file.ext");
    Assert.IsFalse(writeError);

    bool filesAreEqual = CompareFiles("temp\file.ext", "path\to\test\data\case1out.ext");
    Assert.IsTrue(filesAreEqual);
}

Uma vez que você tenha o suficiente desses testes de alto nível escritos para capturar a operação normal dos aplicativos e os casos de erros mais comuns, você precisará gastar tempo no teclado para tentar capturar erros do código fazendo algo além do que você achava que deveria fazer, diminuiria significativamente a refatoração futura (ou mesmo uma grande reescrita) muito mais fácil.

Como você pode expandir a cobertura de teste de unidade, pode reduzir ou até mesmo aposentar a maioria dos testes de integração. Se os arquivos de leitura / gravação do seu aplicativo ou o acesso a um banco de dados, testando essas partes isoladamente e zombando deles ou fazendo seus testes começarem criando as estruturas de dados lidas do arquivo / banco de dados, é um lugar óbvio para começar. Na verdade, criar essa infraestrutura de teste levará muito mais tempo do que escrever um conjunto de testes rápidos e sujos; e toda vez que você executar um conjunto de 2 minutos de testes de integração em vez de gastar 30 minutos testando manualmente uma fração do que os testes de integração cobriram, você já está ganhando muito.

    
por 07.06.2014 / 19:26
fonte