¿Cómo escribir pruebas unitarias antes de refactorizar?

54

He leído algunas respuestas a preguntas a lo largo de una línea similar como "¿Cómo mantienes funcionando tus pruebas de unidad cuando refactorizas?". En mi caso el escenario es ligeramente diferente en eso. Me dieron un proyecto para revisar y alinear con algunos de los estándares que tenemos, ¡actualmente no hay ninguna prueba para el proyecto!

He identificado varias cosas que creo que podrían haberse hecho mejor, como NO mezclar código de tipo DAO en una capa de servicio.

Antes de refactorizar, parecía una buena idea escribir pruebas para el código existente. El problema que me parece es que cuando hago refactorización, esas pruebas se interrumpirán cuando cambie. donde se realiza cierta lógica y las pruebas se escribirán con la estructura anterior en mente (dependencias simuladas, etc.)

En mi caso, ¿cuál sería la mejor manera de proceder? Estoy tentado a escribir las pruebas en torno al código refactorizado, pero estoy consciente de que existe el riesgo de que pueda refactorizar las cosas incorrectamente que podrían cambiar el comportamiento deseado.

Si se trata de un refactor o un rediseño, me complace que haya comprendido esos términos, actualmente estoy trabajando en la siguiente definición para refactorizar "Con la refactorización, por definición, no cambia lo que El software lo hace, tú cambias cómo lo hace ". Entonces, no estoy cambiando lo que hace el software, cambiaría cómo y dónde lo hace.

Igualmente, puedo ver el argumento de que si estoy cambiando la firma de métodos que podrían considerarse un rediseño.

Aquí hay un breve ejemplo

MyDocumentService.java (actual)

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 (refactorizado / rediseñado como sea)

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();
   }
}
    
pregunta PDStat 16.05.2016 - 15:27

9 respuestas

56

Está buscando pruebas que verifiquen regresiones . es decir, romper algún comportamiento existente. Comenzaría identificando en qué nivel ese comportamiento seguirá siendo el mismo, y que la interfaz que conduce ese comportamiento seguirá siendo la misma, y comenzaré a poner pruebas en ese punto.

Ahora tiene algunas pruebas que afirmarán que haga lo que haga a continuación en este nivel, su comportamiento seguirá siendo el mismo.

Tienes toda la razón al preguntarte cómo las pruebas y el código pueden permanecer sincronizados. Si su interfaz para un componente permanece igual, puede escribir una prueba sobre esto y afirmar las mismas condiciones para ambas implementaciones (a medida que crea la nueva implementación). Si no es así, debe aceptar que una prueba para un componente redundante es una prueba redundante.

    
respondido por el Brian Agnew 16.05.2016 - 15:53
40

La práctica recomendada es comenzar con la escritura de "pruebas de pin-down" que prueben el comportamiento actual del código, posiblemente incluyendo errores, pero sin requerir que descienda a la locura de discernir si un comportamiento determinado que viola los requisitos es algo un error, una solución para algo que no conoce, o representa un cambio no documentado en los requisitos.

Es más sensato que estas pruebas de nivel inferior estén en un nivel alto, es decir, en lugar de pruebas unitarias, para que sigan funcionando cuando empieces a refactorizar.

Pero algunas refactorizaciones podrían ser necesarias para hacer que el código sea verificable; solo tenga cuidado de atenerse a las refactorizaciones "seguras". Por ejemplo, en casi todos los casos, los métodos privados pueden hacerse públicos sin romper nada.

    
respondido por el Michael Borgwardt 16.05.2016 - 16:07
13

Le sugiero, si aún no lo ha hecho, lea tanto Cómo trabajar con eficacia con el código heredado como < a href="http://martinfowler.com/books/refactoring.html"> Refactoring: mejora del diseño del código existente .

  

[..] El problema que me parece es que cuando hago refactorización, esas pruebas se interrumpirán a medida que cambie la lógica y las pruebas se escribirán teniendo en cuenta la estructura anterior (dependencias simuladas, etc. .) [..]

No necesariamente veo esto como un problema: escriba las pruebas, cambie la estructura de su código y luego ajuste la estructura de la prueba también . Esto le dará una respuesta directa sobre si su nueva estructura es realmente mejor que la anterior, porque si lo es, las pruebas ajustadas serán más fáciles para escribir (y, por lo tanto, cambiar las pruebas debería ser relativamente sencillo, disminuyendo). el riesgo de que un error recién introducido pase las pruebas).

También, como ya han escrito otros: No escriba también pruebas detalladas (al menos no al principio). Trate de mantenerse en un alto nivel de abstracción (por lo tanto, sus pruebas probablemente se caracterizarán mejor como regresión o incluso pruebas de integración).

    
respondido por el Daniel Jour 16.05.2016 - 17:43
5

No escriba pruebas unitarias estrictas en las que se burla de todas las dependencias. Algunas personas te dirán que estas no son pruebas unitarias reales. Ingnóralos. Estas pruebas son útiles, y eso es lo que importa.

Veamos su ejemplo:

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;
   }
}

Su prueba probablemente se vea algo como esto:

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

En lugar de burlarse de DocumentDao, simula sus dependencias:

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

Ahora, puede mover la lógica de MyDocumentService a DocumentDao sin romper las pruebas. Las pruebas mostrarán que la funcionalidad es la misma (en la medida en que la haya probado).

    
respondido por el Winston Ewert 16.05.2016 - 17:26
3

Como usted dice, si cambia el comportamiento, entonces es una transformación y no un refactor. A qué nivel cambias el comportamiento es lo que marca la diferencia.

Si no hay pruebas formales en el nivel más alto, intente encontrar un conjunto de requisitos que los clientes (código de llamada o personas) que deben seguir siendo los mismos después de su rediseño para que su código se considere funcional. Esa es la lista de casos de prueba que necesita implementar.

Para responder a su pregunta sobre el cambio de implementaciones que requieren un cambio en los casos de prueba, le sugeriría que analice Detroit (clásica) contra Londres (mockist) TDD. Martin Fowler habla sobre esto en su gran artículo Las simulaciones no son talones pero muchas personas tienen opiniones. Si comienza en el nivel más alto, donde sus elementos externos no pueden cambiar, y trabaja hacia abajo, los requisitos deberían permanecer bastante estables hasta que llegue a un nivel que realmente necesite cambiar.

Sin ninguna prueba, esto va a ser difícil, y es posible que desee considerar ejecutar clientes a través de rutas de doble código (y registrar las diferencias) hasta que pueda estar seguro de que su nuevo código hace exactamente lo que debe hacer.

    
respondido por el Encaitar 16.05.2016 - 16:05
3

Aquí mi enfoque. Tiene un costo en términos de tiempo porque es una prueba de refactoría en 4 fases.

Lo que voy a exponer puede encajar mejor en componentes con más complejidad que la expuesta en el ejemplo de la pregunta.

De todos modos, la estrategia es válida para que cualquier componente candidato se normalice mediante una interfaz (DAO, Servicios, Controladores, ...).

1. La interfaz

Permite reunir todos los métodos públicos de MyDocumentService y los ponemos todos juntos en una interfaz. Por ejemplo. Si ya existe, use ese en lugar de establecer uno nuevo .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Luego obligamos a MyDocumentService a implementar esta nueva interfaz.

Hasta ahora todo bien. No se realizaron cambios importantes, respetamos el contrato actual y behaivos permanece intacto.

public class MyDocumentService implements DocumentService {

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

2. Prueba unitaria del código heredado

Aquí tenemos el trabajo duro. Para configurar una suite de prueba. Debemos establecer tantos casos como sea posible: casos exitosos y también casos de error. Estos últimos son para el bien de la calidad del resultado.

Ahora, en lugar de probar MyDocumentService , vamos a utilizar la interfaz como el contrato a probar.

No voy a entrar en detalles, así que perdóname si mi código parece demasiado simple o demasiado 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
    } 
 }

Esta etapa lleva más tiempo que cualquier otra en este enfoque. Y es lo más importante porque establecerá el punto de referencia para futuras comparaciones.

Nota: debido a que no se realizaron cambios importantes y el comportamiento se mantiene intacto. Sugiero hacer una etiqueta aquí en el SCM. Etiqueta o rama no importa. Solo haz una versión.

Lo queremos para reversiones, comparaciones de versiones y puede ser para ejecuciones paralelas del código antiguo y el nuevo.

3. Refactorización

Refactor se implementará en un nuevo componente. No haremos ningún cambio en el código existente. El primer paso es tan fácil como copiar y pegar MyDocumentService y cambiarle el nombre a CustomDocumentService (por ejemplo).

La nueva clase sigue implementando DocumentService . Luego ve y refactoriza getAllDocuments () . (Comencemos por uno. Pin-refactors)

Puede requerir algunos cambios en la interfaz / métodos de DAO. Si es así, no cambie el código existente. Implementa tu propio método en la interfaz DAO. Anote el código antiguo como En desuso y más adelante sabrá qué debe eliminarse.

Es importante no romper / cambiar la implementación existente. Queremos ejecutar ambos servicios en paralelo y luego comparar los 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. Actualización de DocumentServiceTestSuite

Ok, ahora la parte más fácil. Para agregar las pruebas del nuevo 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);
    } 
 }

Ahora hemos validado de forma independiente oldResult y newResult , pero también podemos compararnos entre nosotros. Esta última validación es opcional y depende del resultado. Puede que no sea comparable.

Puede que no haga demasiada cantidad para comparar dos colecciones de esta manera, pero sería válido para cualquier otro tipo de objeto (pojos, entidades de modelo de datos, DTO, envolturas, tipos nativos ...)

Notes

No me atrevería a decir cómo realizar pruebas unitarias o cómo utilizar las bibliotecas simuladas. No me atrevo ni a decir cómo tienes que hacer el refactor. Lo que quería hacer es sugerir una estrategia global. Cómo llevarlo adelante depende de ti. Usted sabe exactamente cómo es el código, su complejidad y si vale la pena probar esa estrategia. Hechos como el tiempo y los recursos son importantes aquí. También es importante lo que esperas de estas pruebas en el futuro.

He comenzado mis ejemplos por un Servicio y seguiría con DAO y así sucesivamente. Profundizando en los niveles de dependencia. Más o menos podría describirse como estrategia de abajo hacia arriba . Sin embargo, para cambios / refactores menores ( como el expuesto en el ejemplo de recorrido ), un de abajo hacia arriba haría la tarea más fácil. Debido a que el alcance de los cambios es pequeño.

Finalmente, depende de usted eliminar el código en desuso y redirigir las dependencias antiguas a la nueva.

Eliminar también las pruebas en desuso y el trabajo está hecho. Si versionó la solución anterior con sus pruebas, puede verificar y comparar entre sí en cualquier momento.

Como consecuencia de tanto trabajo, tiene un código heredado probado, validado y versionado. Y nuevo código, probado, validado y listo para ser versionado.

    
respondido por el Laiv 16.05.2016 - 22:22
3

tl; dr No escriba pruebas de unidad. Escribir pruebas a un nivel más apropiado.

Dada tu definición de trabajo de refactorización:

  

no cambia lo que hace su software, cambia cómo lo hace

hay muy amplio espectro. En un extremo hay un cambio autocontenido a un método particular, tal vez usando un algoritmo más eficiente. En el otro extremo está portando a otro idioma.

Independientemente del nivel de refactorización / rediseño que se esté realizando, es importante tener pruebas que operen a ese nivel o superior.

Las pruebas automatizadas a menudo se clasifican por nivel como:

  • Pruebas unitarias - Componentes individuales (clases, métodos)

  • Pruebas de integración : interacciones entre componentes

  • Pruebas del sistema : la aplicación completa

Escriba el nivel de prueba que puede soportar la refactorización esencialmente sin tocar.

Piensa:

  

¿Qué comportamiento esencial, públicamente visible tendrá la aplicación antes y después de la refactorización? ¿Cómo puedo probar que la cosa todavía funciona de la misma manera?

    
respondido por el Paul Draper 18.05.2016 - 00:05
2

No pierda tiempo escribiendo pruebas que se enganchan en puntos en los que puede anticipar que la interfaz cambiará de manera no trivial. A menudo, esto es una señal de que está intentando realizar pruebas unitarias de clases que son de naturaleza 'colaborativa', cuyo valor no radica en lo que hacen ellos mismos, sino en cómo interactúan con una serie de clases estrechamente relacionadas para producir un comportamiento valioso. . Es el comportamiento que lo que desea probar, lo que significa que desea realizar pruebas en un nivel superior. Las pruebas por debajo de este nivel a menudo requieren muchas burlas feas, y las pruebas resultantes pueden ser más un obstáculo para el desarrollo que una ayuda para defender el comportamiento.

No se preocupe demasiado si está haciendo un refactor, un rediseño o lo que sea. Puede realizar cambios que en el nivel inferior constituyen un rediseño de una serie de componentes, pero en un nivel de integración más alto, simplemente se trata de un refactor. El punto es tener claro qué comportamiento es valioso para usted y defenderlo a medida que avanza.

Puede ser útil tenerlo en cuenta al escribir sus pruebas. ¿Podría describirle fácilmente a un QA, a un propietario de un producto oa un usuario, qué prueba está probando esta prueba? Si parece que describir la prueba sería demasiado esotérico y técnico, tal vez esté realizando una prueba en el nivel incorrecto. Realice pruebas en los puntos / niveles que tengan "sentido" y no pegue su código con pruebas en todos los niveles.

    
respondido por el topo morto 16.05.2016 - 22:50
1

Su primera tarea es tratar de encontrar la "firma del método ideal" para sus pruebas. Esfuércese por que sea una función pura . Esto debería ser independiente del código que está realmente bajo prueba; Es una pequeña capa adaptadora. Escriba su código en esta capa de adaptador. Ahora, cuando refactoriza su código, solo necesita cambiar la capa del adaptador. Aquí hay un ejemplo simple:

[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;
}

Las pruebas son buenas, pero el código bajo prueba tiene una API incorrecta. Puedo refactorizarlo sin cambiar las pruebas simplemente actualizando mi capa de adaptador:

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

Este ejemplo parece bastante obvio según el principio de No repetirte, pero puede que no sea tan obvio en otros casos. La ventaja va más allá de DRY, la verdadera ventaja es el desacoplamiento de las pruebas del código bajo prueba.

Por supuesto, esta técnica puede no ser aconsejable en todas las situaciones. Por ejemplo, no habría ninguna razón para escribir adaptadores para POCOs / POJOs porque realmente no tienen una API que podría cambiar independientemente del código de prueba. Además, si está escribiendo un pequeño número de pruebas, una capa de adaptador relativamente grande probablemente se desperdicie esfuerzo.

    
respondido por el default.kramer 16.05.2016 - 19:36

Lea otras preguntas en las etiquetas