Como você mantém seus testes de unidade funcionando ao refatorar?

29

Em outra pergunta, foi revelado que uma das dificuldades do TDD é manter o conjunto de testes sincronizado com a base de código durante e após a refatoração.

Agora, sou um grande fã de refatoração. Eu não vou desistir de fazer TDD. Mas também experimentei os problemas dos testes escritos de tal maneira que a refatoração menor leva a muitas falhas no teste.

Como você evita interromper os testes ao refatorar?

  • Você escreve os testes 'melhor'? Se sim, o que você deve procurar?
  • Você evita certos tipos de refatoração?
  • Existem ferramentas de refatoração de teste?

Edit: Eu escrevi uma nova pergunta que perguntou o que eu queria perguntar (mas manteve essa como uma variante interessante).

Alex Feinman
fonte
7
Eu pensaria que, com o TDD, seu primeiro passo na refatoração é escrever um teste que falha e refatorar o código para fazê-lo funcionar.
Matt Ellen
Seu IDE não pode descobrir como refatorar os testes também?
@ Thorbjørn Ravn Andersen, sim, e eu escrevi uma nova pergunta que perguntou o que eu queria perguntar (mas manteve um presente como uma variante interessante, ver a resposta de azheglov, que é essencialmente o que você diz)
Alex Feinman
Considerou adicionar essa informação a esta pergunta?

Respostas:

35

O que você está tentando fazer não é realmente refatorar. Com a refatoração, por definição, você não muda o que seu software faz, mas como ele o faz.

Comece com todos os testes verdes (todos aprovados) e faça as modificações "sob o capô" (por exemplo, mova um método de uma classe derivada para a base, extraia um método ou encapsule um Composite com um Builder , etc.). Seus testes ainda devem passar.

O que você está descrevendo parece não ser refatoração, mas uma reformulação, que também aumenta a funcionalidade do seu software em teste. TDD e refatoração (como tentei defini-lo aqui) não estão em conflito. Você ainda pode refatorar (verde-verde) e aplicar TDD (vermelho-verde) para desenvolver a funcionalidade "delta".

azheglov
fonte
7
O mesmo código X copiou 15 lugares. Personalizado em cada local. Você a torna uma biblioteca comum e parametriza o X ou usa o padrão de estratégia para permitir essas diferenças. Eu garanto que os testes de unidade para X falharão. Os clientes do X falharão porque a interface pública muda ligeiramente. Redesenhar ou refatorar? Eu chamo de refatoração, mas de qualquer forma ele quebra todos os tipos de coisas. A linha inferior é que você não pode refatorar a menos que saiba exatamente como tudo se encaixa. A correção dos testes é entediante, mas, no final, trivial.
18711 Kevin
3
Se os testes precisam de ajuste constante, provavelmente é uma dica de ter testes muito detalhados. Por exemplo, suponha que um pedaço de código precise acionar os eventos A, B e C sob certas circunstâncias, em nenhuma ordem específica. O código antigo faz isso na ordem ABC e os testes esperam os eventos nessa ordem. Se o código refatorado emitir eventos na ordem ACB, ele ainda funcionará de acordo com as especificações, mas o teste falhará.
Otto
3
@ Kevin: Eu acredito que o que você descreve é ​​uma reformulação, porque a interface pública muda. A definição de refatoração de Fowler ("alterar a estrutura interna [do código] sem alterar seu comportamento externo") é bastante clara sobre isso.
azheglov
3
@azheglov: talvez, mas na minha experiência, se a implementação for ruim, a interface também é #
Kevin
2
Uma pergunta perfeitamente válida e clara termina em uma discussão do “significado da palavra”. Quem se importa como você chama, vamos discutir isso em outro lugar. Nesse meio tempo, essa resposta está omitindo completamente qualquer resposta real, mas ainda tendo mais votos positivos de longe. Entendo por que as pessoas se referem ao TDD como uma religião.
Dirk Boer
21

Um dos benefícios de ter testes de unidade é para que você possa refatorar com confiança.

Se a refatoração não alterar a interface pública, você deixa os testes de unidade como estão e garante, após a refatoração, que todos passem.

Se a refatoração alterar a interface pública, os testes deverão ser reescritos primeiro. Refatorar até que os novos testes sejam aprovados.

Eu nunca evitaria qualquer refatoração porque interrompe os testes. Escrever testes de unidade pode ser uma dor de cabeça, mas vale a pena a longo prazo.

Tim Murphy
fonte
7

Ao contrário das outras respostas, é importante observar que algumas formas de teste podem se tornar frágeis quando o sistema em teste (SUT) é refatorado, se o teste for uma caixa branca.

Se eu estiver usando uma estrutura de simulação que verifique a ordem dos métodos chamados nas zombarias (quando a ordem é irrelevante porque as chamadas são livres de efeitos colaterais); se meu código for mais limpo com essas chamadas de método em uma ordem diferente e eu refatorar, meu teste será interrompido. Em geral, as zombarias podem introduzir fragilidade nos testes.

Se eu estiver verificando o estado interno do meu SUT, expondo seus membros privados ou protegidos (poderíamos usar "friend" no visual basic ou escalar o nível de acesso "internal" e usar "internalsvisibleto" em c #; em muitos idiomas OO, incluindo c # uma " subclasse específica de teste " pode ser usada) e, de repente, o estado interno da classe será importante - você pode refatorar a classe como uma caixa preta, mas os testes da caixa branca falharão. Suponha que um único campo seja reutilizado para significar coisas diferentes (não é uma boa prática!) Quando o SUT muda de estado - se o dividirmos em dois campos, talvez seja necessário reescrever testes quebrados.

As subclasses específicas de teste também podem ser usadas para testar métodos protegidos - o que pode significar que um refator do ponto de vista do código de produção é uma mudança radical do ponto de vista do código de teste. Mover algumas linhas para dentro ou fora de um método protegido pode não ter efeitos colaterais na produção, mas interromper um teste.

Se eu usar " ganchos de teste " ou qualquer outro código de compilação condicional ou específico do teste, pode ser difícil garantir que os testes não sejam interrompidos devido a dependências frágeis da lógica interna.

Portanto, para evitar que os testes sejam acoplados aos detalhes internos íntimos do SUT, isso pode ajudar a:

  • Use stubs em vez de zombarias, sempre que possível. Para mais informações, consulte o blog de Fabio Periera sobre testes tautológicos e meu blog sobre testes tautológicos .
  • Se estiver usando zombarias, evite verificar a ordem dos métodos chamados, a menos que seja importante.
  • Tente evitar verificar o estado interno do seu SUT - use sua API externa, se possível.
  • Tente evitar lógica específica de teste no código de produção
  • Tente evitar o uso de subclasses específicas de teste.

Todos os pontos acima são exemplos de acoplamento de caixa branca usado em testes. Portanto, para evitar completamente a refatoração dos testes de quebra, use o teste de caixa preta do SUT.

Isenção de responsabilidade: Com o objetivo de discutir a refatoração aqui, estou usando a palavra um pouco mais amplamente para incluir alterações na implementação interna sem efeitos externos visíveis. Alguns puristas podem discordar e se referir exclusivamente ao livro Refatoração de Martin Fowler e Kent Beck - que descreve operações de refatoração atômica.

Na prática, tendemos a tomar etapas ininterruptas um pouco maiores do que as operações atômicas descritas lá e, em particular, alterações que deixam o código de produção se comportando de forma idêntica do lado de fora podem não deixar os testes aprovados. Mas acho justo incluir "um algoritmo substituto para outro algoritmo que tenha comportamento idêntico" como um refator, e acho que Fowler concorda. O próprio Martin Fowler diz que a refatoração pode quebrar os testes:

Ao escrever um teste de simulação, você está testando as chamadas de saída do SUT para garantir que ele fale adequadamente com seus fornecedores. Um teste clássico se preocupa apenas com o estado final - e não como esse estado foi derivado. Os testes mockistas são, portanto, mais acoplados à implementação de um método. Mudar a natureza das chamadas para os colaboradores geralmente causa um teste de simulação.

[...]

O acoplamento à implementação também interfere na refatoração, pois as alterações na implementação têm muito mais probabilidade de interromper os testes do que nos testes clássicos.

Fowler - Zombarias não são stubs

perfeccionista
fonte
Fowler escreveu literalmente o livro sobre refatoração; e o livro mais autoritário sobre teste de unidade (xUnit Test Patterns de Gerard Meszaros) está na série "assinatura" de Fowler; portanto, quando ele diz que a refatoração pode interromper um teste, ele provavelmente está certo.
perfeccionista
5

Se seus testes são interrompidos quando você está refatorando, então você não está, por definição, refatorando, que está "alterando a estrutura do seu programa sem alterar o comportamento do seu programa".

Às vezes, você precisa alterar o comportamento de seus testes. Talvez você precise mesclar dois métodos juntos (digamos, bind () e listen () em uma classe de soquete TCP de escuta), para que outras partes do seu código tentem e falhem ao usar a API agora alterada. Mas isso não é refatoração!

Frank Shearar
fonte
E se ele apenas mudar o nome de um método testado pelos testes? Os testes falharão, a menos que você os renomeie também. Aqui ele não está mudando o comportamento do programa.
Oscar Mederos
2
Nesse caso, seus testes também estão sendo refatorados. Porém, você precisa ter cuidado: primeiro renomeia o método e depois executa seu teste. Ele deve falhar pelos motivos certos (não pode compilar (C #), você recebe uma exceção MessageNotUnderstood (Smalltalk), nada parece acontecer (padrão de alimentação nula do Objective-C)). Então você altera seu teste, sabendo que não introduziu nenhum erro acidentalmente. "Se seus testes terminarem" significa "se seus testes terminarem depois que você terminar a refatoração", em outras palavras. Tente manter os trocos pequenos!
Frank Shearar
11
Os testes de unidade são inerentemente acoplados à estrutura do código. Por exemplo, Fowler possui muitos em refactoring.com/catalog que afetariam os testes de unidade (por exemplo, ocultar método, método embutido, substituir código de erro por exceção e assim por diante).
21918 Kristian H
falso. A fusão de dois métodos é obviamente uma refatoração que possui nomes oficiais (por exemplo, refatoração de método em linha se encaixa na definição) e interromperá os testes de um método que está embutido - alguns dos casos de teste agora devem ser reescritos / testados por outros meios. Não preciso alterar o comportamento de um programa para interromper os testes de unidade, tudo o que preciso fazer é reestruturar os internos que possuem testes de unidade associados a eles. Enquanto o comportamento de um programa não for alterado, isso ainda se ajusta à definição de refatoração.
KolA 26/03
Eu escrevi o acima assumindo testes bem escritos: se você estiver testando sua implementação - se a estrutura do teste espelhar as partes internas do código em teste, com certeza. Nesse caso, teste o contrato da unidade, não a implementação.
Frank Shearar 26/03
4

Penso que o problema com esta pergunta é que pessoas diferentes estão adotando a palavra 'refatoração' de maneira diferente. Eu acho que é melhor definir cuidadosamente algumas coisas que você provavelmente quer dizer:

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

Como uma outra pessoa já observou, se você mantém a API igual e todos os seus testes de regressão operam na API pública, você não deve ter problemas. A refatoração não deve causar problemas. Qualquer teste que falhou, significa que seu código antigo teve um erro e seu teste está com defeito ou seu novo código tem um erro.

Mas isso é bastante óbvio. Então, provavelmente, você quer dizer com refatoração que está alterando a API.

Então, deixe-me responder como abordar isso!

  • Primeiro, crie uma NEW API, que faça o que você deseja que seja o seu comportamento da NEW API. Se essa nova API tiver o mesmo nome que uma API ANTIGA, anexarei o nome _NEW ao novo nome da API.

    int DoSomethingInterestingAPI ();

torna-se:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

OK - nesse estágio - todos os seus testes de regressão ainda passam - usando o nome DoSomethingInterestingAPI ().

NEXT, consulte seu código e altere todas as chamadas para DoSomethingInterestingAPI () para a variante apropriada de DoSomethingInterestingAPI_NEW (). Isso inclui atualizar / reescrever as partes de seus testes de regressão que precisam ser alteradas para usar a nova API.

NEXT, marque DoSomethingInterestingAPI_OLD () como [[obsoleto ()]]. Mantenha a API reprovada pelo tempo que desejar (até atualizar com segurança todo o código que possa depender dela).

Com essa abordagem, quaisquer falhas nos testes de regressão são simplesmente erros nesse teste de regressão ou identificam erros no seu código - exatamente como você deseja. Esse processo faseado de revisar uma API criando explicitamente as versões _NEW e _OLD da API permite que você coexista por um tempo bits do código novo e antigo.

Lewis Pringle
fonte
Gosto dessa resposta porque torna óbvio que os testes de unidade no SUT são iguais aos clientes externos de uma API publicada. O que você prescreve é ​​muito semelhante ao protocolo SemVer para gerenciar a biblioteca / componente publicado, a fim de evitar o "inferno da dependência". No entanto, isso tem um custo de tempo e flexibilidade, extrapolar essa abordagem para a interface pública de cada unidade micro significa extrapolar custos também. Uma abordagem mais flexível é separar os testes da implementação o máximo possível, ou seja, testes de integração ou uma DSL separada para descrever as entradas e saídas do teste
KolA
1

Eu suponho que seus testes de unidade são de uma granularidade que eu chamaria de "estúpido" :) ou seja, eles testam as minúcias absolutas de cada classe e função. Afaste-se das ferramentas do gerador de código e escreva testes que se aplicam a uma superfície maior, para refatorar os internos o quanto quiser, sabendo que as interfaces para seus aplicativos não foram alteradas e seus testes ainda funcionam.

Se você deseja ter testes de unidade que testam todos os métodos, espere ter que refatorá-los ao mesmo tempo.

gbjbaanb
fonte
11
A resposta mais útil que realmente aborda a questão - não construa sua cobertura de teste em uma base instável de trivialidades internas, ou espere que ela se desmorone constantemente - mas a mais prejudicada porque o TDD prescreve que faça exatamente o oposto. É isso que você obtém por apontar a verdade inconveniente sobre uma abordagem exagerada.
KolA 26/03
1

mantendo o conjunto de testes sincronizado com a base de código durante e após a refatoração

O que dificulta é o acoplamento . Todos os testes vêm com algum grau de acoplamento aos detalhes da implementação, mas os testes de unidade (independentemente de serem TDD ou não) são especialmente ruins porque interferem com os internos: mais testes de unidade equivalem a mais código acoplado a unidades, ou seja, assinaturas de métodos / qualquer outra interface pública de unidades - pelo menos.

"Unidades", por definição, são detalhes de implementação de baixo nível; a interface das unidades pode e deve mudar / dividir / mesclar e, de outra forma, sofrer alterações à medida que o sistema evolui. A abundância de testes de unidade pode realmente impedir essa evolução mais do que ajuda.

Como evitar a quebra de testes ao refatorar? Evite o acoplamento. Na prática, significa evitar o máximo de testes de unidade possível e preferir testes de nível / integração mais agnósticos nos detalhes da implementação. Lembre-se, porém, de que não existe uma bala de prata, os testes ainda precisam se encaixar em algum nível, mas, idealmente, deve ser uma interface explicitamente versionada usando o Semantic Versioning, ou seja, geralmente no nível de API / aplicativo publicado (você não quer fazer o SemVer para cada unidade em sua solução).

Cola
fonte
0

Seus testes estão muito acoplados à implementação e não ao requisito.

considere escrever seus testes com comentários como este:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

Dessa forma, você não pode refatorar o significado dos testes.

mcintyre321
fonte