Como escrever testes de unidade antes de refatorar?

55

Li algumas respostas a perguntas de uma linha semelhante, como "Como você mantém seus testes de unidade funcionando ao refatorar?". No meu caso, o cenário é um pouco diferente em que Recebi um projeto para revisar e alinhar alguns padrões que temos, atualmente não há testes para o projeto!

Eu identifiquei várias coisas que eu acho que poderiam ter sido feitas melhor, como NÃO misturar o código do tipo DAO em uma camada de serviço.

Antes de refatorar, pareceu uma boa ideia escrever testes para o código existente. O problema parece-me é que quando eu refactor então os testes vão quebrar como eu estou mudando onde certa lógica é feita e os testes serão escritos com a estrutura anterior em mente (dependências ridicularizadas, etc.)

No meu caso, qual seria a melhor maneira de proceder? Estou tentado a escrever os testes em torno do código refatorado, mas estou ciente de que há um risco de refatorar as coisas incorretamente que poderia mudar o comportamento desejado.

Se isso é um refator ou um novo design Estou feliz por minha compreensão desses termos a serem corrigidos, atualmente estou trabalhando na seguinte definição para refatoração: "Com a refatoração, por definição, você não muda a sua software faz, você muda como faz isso ". Então eu não estou mudando o que o software faz, eu estou mudando como / onde ele faz isso.

Igualmente, posso ver o argumento de que, se estou mudando a assinatura de métodos que poderiam ser considerados um novo design.

Aqui está um breve exemplo

MyDocumentService.java (atual)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (refatorado / redesenhado o que for)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}
    
por PDStat 16.05.2016 / 15:27
fonte

9 respostas

56

Você está procurando testes que verificam regressões . ou seja, quebrar algum comportamento existente. Gostaria de começar identificando em que nível esse comportamento permanecerá o mesmo e que a interface que direciona esse comportamento permanecerá a mesma e comece a fazer testes nesse ponto.

Agora você tem alguns testes que afirmam que o que você faz abaixo deste nível, seu comportamento permanece o mesmo.

Você está certo em questionar como os testes e o código podem permanecer em sincronia. Se a sua interface para um componente permanecer a mesma, você poderá escrever um teste em torno disso e declarar as mesmas condições para ambas as implementações (conforme você cria a nova implementação). Se não, então você tem que aceitar que um teste para um componente redundante é um teste redundante.

    
por 16.05.2016 / 15:53
fonte
40

A prática recomendada é começar a escrever "testes de pin-down" que testam o comportamento atual do código, possivelmente incluindo bugs, mas sem exigir que você mergulhe na loucura de discernir se um determinado comportamento que viola documentos de requisitos é um bug, uma solução alternativa para algo que você não conhece ou representa uma alteração não documentada nos requisitos.

Faz mais sentido que esses testes de pinagem estejam em um nível alto, ou seja, integração, em vez de testes de unidade, para que eles continuem funcionando quando você iniciar a refatoração.

Mas algumas refatorações podem ser necessárias para tornar o código testável - basta ter o cuidado de manter refatorações "seguras". Por exemplo, em quase todos os casos, os métodos privados podem ser tornados públicos sem quebrar nada.

    
por 16.05.2016 / 16:07
fonte
13

Sugiro que, se você ainda não o fez, leia Trabalhando efetivamente com o código herdado e < Refactoring - Melhorando o Design do Código Existente .

[..] The problem it appears to me is that when I do refactor then those tests will break as I'm changing where certain logic is done and the tests will be written with the previous structure in mind (mocked dependencies etc.) [..]

Eu não vejo necessariamente isso como um problema: escreva os testes, altere a estrutura do seu código e, em seguida, ajuste a estrutura de teste também . Isto lhe dará um feedback direto se sua nova estrutura é realmente melhor que a antiga, porque se for, os testes ajustados serão mais fáceis para escrever (e assim mudar os testes deve ser relativamente simples, abaixando o risco de ter um bug recém-introduzido passar nos testes).

Além disso, como outros já escreveram: Não escreva demais testes detalhados (pelo menos não no começo). Tente ficar em um nível alto de abstração (assim, seus testes provavelmente serão melhor caracterizados como regressão ou mesmo testes de integração).

    
por 16.05.2016 / 17:43
fonte
5

Não escreva testes de unidade estritos nos quais você zomba de todas as dependências. Algumas pessoas dirão que não são testes unitários reais. Ignore-os. Esses testes são úteis e é isso que importa.

Vamos analisar seu exemplo:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

Seu teste provavelmente se parece com algo assim:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Em vez de zombar de DocumentDao, zombe de suas dependências:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Agora, você pode mover a lógica de MyDocumentService para DocumentDao sem a quebra dos testes. Os testes mostrarão que a funcionalidade é a mesma (até onde você testou).

    
por 16.05.2016 / 17:26
fonte
3

Como você diz, se você mudar o comportamento, então é uma transformação e não um refatorador. Em que nível você muda o comportamento é o que faz a diferença.

Se não houver testes formais no nível mais alto, tente encontrar um conjunto de requisitos que os clientes (código de chamada ou humanos) precisem permanecer os mesmos após o novo design para que seu código seja considerado funcionando. Essa é a lista de casos de teste que você precisa implementar.

Para resolver sua questão sobre a mudança de implementações que exigem mudanças nos casos de teste, sugiro que você dê uma olhada no Detroit (clássico) x Londres (mockista) TDD. Martin Fowler fala sobre isso em seu ótimo artigo Mocks não são stubs mas muitas pessoas têm opiniões. Se você começar no nível mais alto, onde suas funções externas não podem mudar, e trabalhar seu caminho para baixo, então os requisitos devem permanecer razoavelmente estáveis até você chegar a um nível que realmente precise mudar.

Sem nenhum teste, isso será difícil, e você pode querer considerar executar os clientes por meio de caminhos de código duplo (e registrar as diferenças) até ter certeza de que seu novo código faz exatamente o que precisa fazer.

    
por 16.05.2016 / 16:05
fonte
3

Aqui minha abordagem. Tem um custo em termos de tempo porque é um teste de refatoração em 4 fases.

O que vou expor pode ser melhor em componentes com mais complexidade do que o exposto no exemplo da pergunta.

De qualquer forma, a estratégia é válida para qualquer candidato componente a ser normalizado por uma interface (DAO, Serviços, Controladores, ...).

1. A interface

Vamos reunir todos os métodos públicos de MyDocumentService e vamos colocá-los todos juntos em uma interface. Por exemplo. Se já existir, use esse em vez de definir um novo .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Em seguida, forçamos MyDocumentService a implementar essa nova interface.

Até aí tudo bem. Nenhuma mudança importante foi feita, nós respeitamos o contrato atual e os behaivos permanecem intocados.

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. Teste de unidade de código legado

Aqui temos o trabalho duro. Para configurar um conjunto de testes. Devemos definir o maior número possível de casos: casos de sucesso e também casos de erro. Estes últimos são para o bem da qualidade do resultado.

Agora, em vez de testar MyDocumentService , vamos usar a interface como o contrato a ser testado.

Eu não vou entrar em detalhes, então me perdoe Se meu código parece muito simples ou muito agnóstico

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

Esse estágio demora mais do que qualquer outro nessa abordagem. E é o mais importante porque definirá o ponto de referência para futuras comparações.

Nota: Devido a nenhuma grande mudança foi feita e o comportamento permanece intacto. Eu sugiro fazer uma tag aqui no SCM. Tag ou branch não importam. Basta fazer uma versão.

Nós queremos isso para rollbacks, comparações de versões e pode ser para execuções paralelas do código antigo e do novo.

3. Refatoração

Refator será implementado em um novo componente. Nós não faremos nenhuma alteração no código existente. O primeiro passo é tão fácil quanto copiar e colar MyDocumentService e renomeá-lo para CustomDocumentService (por exemplo).

Nova classe continua implementando o DocumentService . Em seguida, refatorize getAllDocuments () . (Vamos começar por um. Pin-refactors)

Isso pode exigir algumas alterações na interface / métodos do DAO. Em caso afirmativo, não altere o código existente. Implemente seu próprio método na interface do DAO. Anote o código antigo como Depreciado e você saberá mais tarde o que deve ser removido.

É importante não quebrar / alterar a implementação existente. Queremos executar os dois serviços em paralelo e comparar os resultados.

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4. Atualizando o DocumentServiceTestSuite

Ok, agora é a parte mais fácil. Para adicionar os testes do novo componente.

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

Agora temos oldResult e newResult ambos validados independentemente, mas também podemos comparar um com o outro. Esta última validação é opcional e depende do resultado. Pode ser que não seja comparável.

Pode não ficar muito preocupado em comparar duas coleções dessa maneira, mas seria válido para qualquer outro tipo de objeto (pojos, entidades de modelo de dados, DTOs, Wrappers, tipos nativos ...)

Notas

Eu não ousaria dizer como fazer testes unitários ou como usar bibliotecas simuladas. Eu não ouso dizer como você tem que fazer o refatorador. O que eu queria fazer é sugerir uma estratégia global. Como levar isso para frente depende de você. Você sabe exatamente como o código é, sua complexidade e se essa estratégia vale a pena tentar. Fatos como tempo e recursos são importantes aqui. Também importa o que você espera desses testes no futuro.

Eu comecei meus exemplos por um serviço e gostaria de seguir com DAO e assim por diante. Indo profundamente nos níveis de dependência. Mais ou menos pode ser descrito como uma estratégia up-bottom . No entanto, para pequenas alterações / refatoradores ( como o exposto no exemplo da turnê ), um bottom up facilitaria a tarefa. Porque o escopo das mudanças é pequeno.

Por fim, cabe a você remover o código reprovado e redirecionar as dependências antigas para o novo.

Remova também os testes reprovados e o trabalho está concluído. Se você criou uma versão antiga da solução antiga com seus testes, é possível verificar e comparar um ao outro a qualquer momento.

Em consequência de tantos trabalhos, você tem código legado testado, validado e versionado. E novo código, testado, validado e pronto para ser versionado.

    
por 16.05.2016 / 22:22
fonte
3

tl; dr Não escreva testes unitários. Escreva testes em um nível mais apropriado.

Dada sua definição de trabalho de refatoração:

you don't change what your software does, you change how it does it

existe muito amplo espectro. De um lado, há uma mudança independente no método particular, talvez usando um algoritmo mais eficiente. Do outro lado está portando para outro idioma.

Qualquer que seja o nível de refatoração / reprojeto, é importante ter testes que operem nesse nível ou acima.

Os testes automatizados são geralmente classificados por nível como:

  • Testes de unidade - Componentes individuais (classes, métodos)

  • Testes de integração - Interações entre componentes

  • Testes do sistema - A aplicação completa

Escreva o nível de teste que pode suportar a refatoração essencialmente intocada.

Pense:

What essential, publicly-visible behavior will the application have both before and after the refactoring? How can I test that thing still works the same?

    
por 18.05.2016 / 00:05
fonte
2

Não perca tempo escrevendo testes que se encaixam em pontos nos quais você pode antecipar que a interface vai mudar de uma maneira não trivial. Isso geralmente é um sinal de que você está tentando testar classes de unidade que são "colaborativas" por natureza - cujo valor não está naquilo que elas próprias fazem, mas em como elas interagem com um número de classes relacionadas para produzir um comportamento valioso. . É esse comportamento que você quer testar, o que significa que você quer estar testando em um nível mais alto. Testar abaixo desse nível geralmente requer muita zombaria feia, e os testes resultantes podem ser mais um empecilho para o desenvolvimento do que uma ajuda para defender o comportamento.

Não fique muito indeciso sobre se você está fazendo um refator, redesenho ou o que for. Você pode fazer alterações que, no nível inferior, constituem um novo design de um número de componentes, mas em um nível de integração mais alto simplesmente equivalem a um refatorador. O ponto é ter clareza sobre qual comportamento tem valor para você e defender esse comportamento conforme você for.

Pode ser útil considerar como você escreve seus testes - eu poderia descrever facilmente para um QA, um proprietário de produto ou um usuário, o que esse teste está realmente testando? Se parece que descrever o teste seria muito esotérico e técnico, talvez você esteja testando no nível errado. Teste nos pontos / níveis que "fazem sentido" e não reduza seu código com testes em todos os níveis.

    
por 16.05.2016 / 22:50
fonte
1

Sua primeira tarefa é tentar criar a "assinatura de método ideal" para seus testes. Esforce-se para torná-lo um função pura . Isso deve ser independente do código que está realmente em teste; é uma pequena camada de adaptador. Escreva seu código para esta camada de adaptador. Agora, ao refatorar seu código, você só precisa alterar a camada do adaptador. Aqui está um exemplo simples:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

Os testes são bons, mas o código em teste tem uma API incorreta. Posso refatorá-lo sem alterar os testes simplesmente atualizando minha camada de adaptador:

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

Este exemplo parece ser uma coisa bastante óbvia a se fazer pelo princípio Don't Repeat Yourself, mas pode não ser tão óbvio em outros casos. A vantagem vai além do DRY - a vantagem real é o desacoplamento dos testes do código em teste.

Naturalmente, esta técnica pode não ser aconselhável em todas as situações. Por exemplo, não haveria razão para escrever adaptadores para POCOs / POJOs porque eles realmente não têm uma API que possa ser alterada independentemente do código de teste. Além disso, se você estiver escrevendo um pequeno número de testes, uma camada de adaptador relativamente grande provavelmente será um desperdício de esforço.

    
por 16.05.2016 / 19:36
fonte