Como escrever testes de unidade antes de refatorar?

55

Eu li algumas respostas para 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, pois recebi um projeto para revisar e alinhar com alguns padrões que temos; atualmente, não há testes para o projeto!

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

Antes de refatorar, parecia uma boa ideia escrever testes para o código existente. O problema que me parece é que, quando refatorar, esses testes serão interrompidos à medida que vou mudando onde certas lógicas são feitas e os testes serão escritos com a estrutura anterior em mente (dependências simuladas etc.)

No meu caso, qual seria a melhor maneira de proceder? Estou tentado a escrever os testes em torno do código refatorado, mas sei que existe o risco de refatorar coisas incorretamente que podem alterar o comportamento desejado.

Seja um refator ou um novo design, fico feliz em corrigir esses termos. Atualmente, estou trabalhando na seguinte definição para refatoração "Com a refatoração, por definição, você não altera o que o software faz, você muda como faz. " Então, eu não estou mudando o que o software faz. Eu estaria mudando como / onde ele faz.

Da mesma forma, posso ver o argumento de que, se estou alterando a assinatura de métodos, isso pode ser considerado um redesenho.

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 / reprojetado qualquer que seja)

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();
   }
}
PDStat
fonte
14
É realmente a refatoração que você planeja fazer ou reprojetar ? Porque a resposta pode ser diferente nos dois casos.
herby
4
Estou trabalhando na definição "Com a refatoração, por definição, você não muda o que seu software faz, mas como ele faz". Então eu acredito que, neste caso, é refatoração, sinta-se livre para corrigir o meu entendimento do termo
PDStat
21
Não, escreva testes de integração. A "refatoração" que você está planejando está acima do nível do teste de unidade. Apenas teste de unidade as novas classes (ou as antigas que você sabe que as mantém).
Pare de prejudicar Monica
2
Com relação à definição de refatoração, seu software define claramente o que faz? Em outras palavras, ele já é "fatorado" em módulos com APIs independentes? Caso contrário, não será possível refatorá-lo, exceto talvez no nível mais alto (voltado para o usuário). No nível do módulo, você inevitavelmente o redesenhará. Nesse caso, não perca seu tempo escrevendo testes de unidade antes de ter unidades.
Kevin Krumwiede 16/05
4
É muito provável que você precise refatorar um pouco sem a rede de segurança dos testes apenas para poder colocá-lo em um equipamento de teste. O melhor conselho que posso dar é que, se o seu IDE ou ferramenta de refatoração não fizer isso por você, não faça à mão. Continue aplicando refatorações automatizadas até conseguir colocar o CUT em um chicote. Você definitivamente vai querer pegar uma cópia do trabalho de Michael Feather, "Trabalhando efetivamente com o código legado".
precisa

Respostas:

56

Você está procurando testes que verifiquem regressões . isto é, quebrar algum comportamento existente. Começaria identificando em que nível esse comportamento permanecerá o mesmo, e que a interface que conduz esse comportamento permanecerá o mesmo, e começaria a fazer testes nesse ponto.

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

Você está certo em questionar como os testes e o código podem permanecer sincronizados. Se sua interface com um componente permanecer a mesma, você poderá escrever um teste em torno disso e afirmar as mesmas condições para ambas as implementações (à medida que cria a nova implementação). Caso contrário, você deve aceitar que um teste para um componente redundante é um teste redundante.

Brian Agnew
fonte
11
Viz, é provável que você esteja fazendo testes de integração ou de sistema, em vez de testes de unidade. Você provavelmente ainda usará uma ferramenta de "teste de unidade" para isso, mas estará pressionando mais de uma unidade de código em cada teste.
Móż 17/05
Sim. Esse é o caso. Seu teste de regressão poderia muito bem estar fazendo algo muito alto nível por exemplo, pedidos REST para um servidor e, possivelmente, um banco de dados teste posterior (ou seja, definitivamente não é uma unidade de teste!)
Brian Agnew
40

A prática recomendada é começar a escrever "testes de pin-down" que testam o comportamento atual do código, possivelmente incluindo erros, mas sem exigir que você mergulhe na loucura de discernir se um determinado comportamento que viola os documentos de requisitos é um bug, solução alternativa para algo que você não conhece, ou representa uma mudança 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 trabalhando quando você começar a refatorar.

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

Michael Borgwardt
fonte
+1 para testes de integração. Dependendo do aplicativo, você poderá começar no nível de envio de solicitações para o aplicativo Web. O que o aplicativo envia de volta não deve mudar apenas por causa da refatoração, embora se estiver enviando HTML de volta, isso certamente é menos testável.
Jpmc26
Eu gosto da frase 'pin-down' tests.
Brian Agnew
12

Sugiro - se você ainda não leu - a leitura de Trabalhando efetivamente com código legado e refatoração - Melhorando o design do código existente .

[..] O problema que me parece é que, quando refatorar, esses testes serão interrompidos à medida que estou mudando onde certa lógica é feita e os testes serão escritos com a estrutura anterior em mente (dependências simuladas etc.) [ ..]

Eu não necessariamente vejo isso como um problema: escreva os testes, altere a estrutura do seu código e ajuste a estrutura do teste também . Isso fornecerá feedback direto se sua nova estrutura é realmente melhor que a antiga, porque, se for, os testes ajustados serão mais fáceis de escrever (e, portanto, a alteração dos testes deve ser relativamente direta, diminuindo o risco de uma nova introdução). erro passar nos testes).

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

Daniel Jour
fonte
11
Este. Os testes parecerão terríveis , mas cobrirão o comportamento existente. Em seguida, à medida que o código é refatorado, o mesmo ocorre com os testes, na etapa de bloqueio. Repita até ter algo de que se orgulha. ++
RubberDuck 16/05
11
Eu apóio as duas recomendações de livros - sempre as tenho em mãos quando preciso lidar com código sem teste.
Toby Speight 16/05
5

Não escreva testes de unidade rigorosos, onde você zomba de todas as dependências. Algumas pessoas dirão que esses não são testes de unidade reais. Ignore-os. Esses testes são úteis, e é isso que importa.

Vejamos o 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 isso:

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

Em vez de zombar do 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 MyDocumentServicepara DocumentDaosem que os testes sejam interrompidos. Os testes mostrarão que a funcionalidade é a mesma (desde que você a tenha testado).

Winston Ewert
fonte
Se você está testando o DocumentService e não zomba do DAO, não é um teste de unidade. É algo entre teste unitário e de integração. Não é?
Laiv
7
@ Laiv, existe realmente uma variedade significativa de como as pessoas usam o termo teste de unidade. Alguns o usam para significar apenas testes estritamente isolados. Outros incluem qualquer teste que seja executado rapidamente. Alguns incluem tudo o que é executado em uma estrutura de teste. Mas, em última análise, não importa como você deseja definir o termo teste de unidade. A questão é quais testes são úteis, portanto, não devemos nos distrair com a forma como definimos exatamente o teste de unidade.
Winston Ewert
Excelente ponto que mostra que a utilidade é a mais importante. Testes de unidade extravagantes para os algoritmos mais triviais apenas para que os testes de unidade causem mais danos do que benefícios, se não apenas um enorme desperdício de tempo e recursos valiosos. Isso pode ser aplicado a praticamente tudo e é algo que eu gostaria de saber no início da minha carreira.
21416 Lee
3

Como você diz, se você mudar o comportamento, será uma transformação e não um refator. 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) precisam manter o mesmo após a sua reformulação para que seu código seja considerado funcionando. Essa é a lista de casos de teste que você precisa implementar.

Para responder à sua pergunta sobre alterações nas implementações que exigem alterações nos casos de teste, sugiro que você dê uma olhada no TDD de Detroit (clássico) vs Londres (mockista). 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 seus externos não podem mudar, e seguir o seu caminho, os requisitos deverão permanecer razoavelmente estáveis ​​até que você chegue a um nível que realmente precise mudar.

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

Encaitar
fonte
3

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

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

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

1. A interface

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

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

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

Por enquanto, tudo bem. Não foram efetuadas grandes alterações, respeitamos o contrato atual e o comportamento permanece intocado.

public class MyDocumentService implements DocumentService {

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

2. Teste de unidade do código legado

Aqui temos o trabalho duro. Para configurar uma suíte de testes. Deveríamos definir o maior número possível de casos: casos bem-sucedidos e também casos de erro. Estes últimos são para o bem da qualidade do resultado.

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

Não vou entrar em detalhes, então me perdoe Se meu código parecer muito simples ou 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 leva mais tempo do que qualquer outro nesta abordagem. E é o mais importante porque definirá o ponto de referência para futuras comparações.

Nota: Devido a nenhuma grande alteração, o comportamento permanece intocado. Sugiro fazer uma tag aqui no SCM. Tag ou ramo não importa. Basta fazer uma versão.

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

3. Refatoração

O refator será implementado em um novo componente. Não faremos nenhuma alteração no código existente. A primeira etapa é tão fácil quanto copiar e colar MyDocumentService e renomeá-la para CustomDocumentService (por exemplo).

Nova classe continua implementando o DocumentService . Em seguida, refatorize getAllDocuments () . (Vamos começar por um. Refatores de pinos)

Pode exigir algumas alterações na interface / métodos do DAO. Nesse caso, não altere o código existente. Implemente seu próprio método na interface DAO. Anote o código antigo como Descontinuado 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 depois 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 de forma independente, mas também podemos comparar. Essa última validação é opcional e depende do resultado. Pode ser que não seja comparável.

Pode não fazer muito sentido 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 de unidade ou como usar bibliotecas simuladas. Não me atrevo nem a dizer como você deve refatorar. O que eu queria fazer é sugerir uma estratégia global. Como seguir adiante 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.

Comecei meus exemplos por um Serviço e seguia com o DAO e assim por diante. Aprofundando nos níveis de dependência. Mais ou menos, poderia ser descrito como estratégia de baixo para cima . No entanto, para pequenas mudanças / refatores ( como o exposto no exemplo do tour ), um processo ascendente facilitaria a tarefa. Porque o escopo das mudanças é pequeno.

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

Remova também testes obsoletos e o trabalho está concluído. Se você testou a solução antiga com seus testes, é possível verificar e comparar a qualquer momento.

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

Laiv
fonte
3

tl; dr Não escreva testes de unidade. Escreva testes em um nível mais apropriado.


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

você não muda o que o seu software faz, muda como ele faz

existe um espectro muito amplo. Por um lado, há uma alteração independente de um método específico, talvez usando um algoritmo mais eficiente. Por outro lado, está migrando para outro idioma.

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

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

  • Testes unitários - 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 intocado.

Pensar:

Que comportamento essencial visível ao público o aplicativo terá antes e depois da refatoração? Como posso testar se ainda funciona da mesma maneira?

Paul Draper
fonte
2

Não perca tempo escrevendo testes que se conectam em pontos onde você pode antecipar que a interface mudará de maneira não trivial. Isso geralmente é um sinal de que você está tentando testar classes de natureza 'colaborativa' por natureza - cujo valor não está no que elas fazem, mas em como elas interagem com várias classes intimamente relacionadas para produzir um comportamento valioso. . É esse comportamento que você deseja testar, o que significa que deseja testar em um nível superior. Testar abaixo desse nível geralmente requer muita zombaria feia, e os testes resultantes podem ser mais um empecilho para o desenvolvimento do que um auxílio à defesa do comportamento.

Não fique muito preocupado se você está fazendo um refator, um novo design ou o que quer que seja. Você pode fazer alterações que, no nível inferior, constituam uma reformulação de vários componentes, mas que, em um nível de integração mais alto, isso equivale a um refator. O ponto é ter clareza sobre qual comportamento é de valor para você e defender esse comportamento à medida que avança.

Pode ser útil considerar ao escrever seus testes - eu poderia descrever facilmente para um controle de qualidade, proprietário de um produto ou usuário, o que esse teste está realmente testando? Se parecer que descrever o teste for 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 agregue seu código com testes em todos os níveis.

topo Restabelecer Monica
fonte
Sempre interessado em razões para votos negativos!
topo Restabelece Monica
1

Sua primeira tarefa é tentar criar a "assinatura do método ideal" para seus testes. Esforce-se para torná-lo uma função pura . Isso deve ser independente do código que está realmente sendo testado; é uma pequena camada adaptadora. Escreva seu código nesta camada do adaptador. Agora, quando você refatorar seu código, você precisará alterar apenas 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 ruim. Posso refatorá-lo sem alterar os testes simplesmente atualizando minha camada do adaptador:

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

Este exemplo parece uma coisa bastante óbvia a ser feita de acordo com o princípio Não se repita, mas pode não ser tão óbvio em outros casos. A vantagem vai além do DRY - a vantagem real é a dissociação dos testes do código sob teste.

Obviamente, essa 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 pudesse mudar independentemente do código de teste. Além disso, se você estiver escrevendo um pequeno número de testes, uma camada adaptadora relativamente grande provavelmente seria um esforço desperdiçado.

default.kramer
fonte