¿Cómo escribir pruebas unitarias antes de refactorizar?


55

He leído algunas respuestas a preguntas en una línea similar, como "¿Cómo mantiene funcionando las pruebas de su unidad cuando refactoriza?". En mi caso, el escenario es ligeramente diferente, ya que me han dado un proyecto para revisar y adaptarlo a algunos estándares que tenemos, ¡actualmente no hay pruebas para el proyecto!

He identificado varias cosas que creo que podrían haberse hecho mejor, como NO mezclar el 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 realizo una refactorización, esas pruebas se romperán a medida que cambie donde se realiza cierta lógica y las pruebas se escribirán teniendo en cuenta la estructura anterior (dependencias simuladas, etc.)

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

Ya sea que se trate de una refactorización o un rediseño, estoy feliz de que comprenda que esos términos se deben corregir, actualmente estoy trabajando en la siguiente definición para refactorizar "Con la refactorización, por definición, no cambia lo que hace su software, cambias cómo lo hace ". Así que no estoy cambiando lo que hace el software, estaría cambiando cómo / dónde lo hace.

Igualmente puedo ver el argumento de que si estoy cambiando la firma de los 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 lo que 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();
   }
}

14
¿Realmente está refactorizando su plan o rediseño ? Porque la respuesta puede ser diferente en los dos casos.
herby

44
Estoy trabajando en la definición "Con la refactorización, por definición, no cambias lo que hace tu software, cambias cómo lo hace". Entonces, creo que en este caso es una refactorización, no dude en corregir mi comprensión del término
PDStat

21
No, escriba pruebas de integración. La "refactorización" que está planeando está por encima del nivel de las pruebas unitarias. Solo pruebe las nuevas clases (o las antiguas que sabe que las mantendrá).
Deja de dañar a Monica

2
Con respecto a la definición de refactorización, ¿su software define claramente lo que hace? En otras palabras, ¿ya está "factorizado" en módulos con API independientes? Si no, entonces no puede refactorizarlo, excepto quizás en el nivel más alto (orientado al usuario). A nivel de módulo, inevitablemente lo rediseñará. En ese caso, no pierda su tiempo escribiendo pruebas de unidades antes de tener unidades.
Kevin Krumwiede

44
Es muy probable que tenga que hacer un poco de refactorización sin la red de seguridad de las pruebas solo para poder colocarlo en un arnés de prueba. El mejor consejo que puedo darle es que si su IDE o herramienta de refactorización no lo hará por usted, no lo haga a mano. Siga aplicando refactorizaciones automáticas hasta que pueda poner el CUT en un arnés. Definitivamente vas a querer recoger una copia de "Trabajando eficazmente con código heredado" de Michael Feather.
RubberDuck

Respuestas:


56

Estás buscando pruebas que comprueben las regresiones . es decir, romper algunos comportamientos existentes. Comenzaría por identificar a qué nivel ese comportamiento seguirá siendo el mismo, y que la interfaz que conduce ese comportamiento seguirá siendo el mismo, y comenzaría a realizar pruebas en ese punto.

Ahora tiene algunas pruebas que afirman que cualquier cosa que haga por debajo de este nivel, su comportamiento sigue siendo el mismo.

Tiene toda la razón al preguntar cómo las pruebas y el código pueden permanecer sincronizados. Si su interfaz con un componente sigue siendo la misma, puede escribir una prueba alrededor de 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.


1
Es decir, es probable que esté haciendo pruebas de integración o sistema en lugar de pruebas unitarias. Probablemente todavía utilizará una herramienta de "prueba de unidad", pero obtendrá más de una unidad de código con cada prueba.
Más

Si. Ese es mucho el caso. Su prueba de regresión podría estar haciendo algo de muy alto nivel, por ejemplo, solicitudes REST a un servidor y posiblemente una prueba de base de datos posterior (es decir, definitivamente no es una prueba unitaria )
Brian Agnew

40

La práctica recomendada es comenzar por escribir "pruebas precisas" 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 documentos de requisitos es un error, solución para algo que no conoce, o que representa un cambio indocumentado en los requisitos.

Tiene más sentido que estas pruebas precisas sean de alto nivel, es decir, integración en lugar de pruebas unitarias, para que sigan funcionando cuando comience a refactorizar.

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


+1 para pruebas de integración. Dependiendo de la aplicación, puede comenzar a enviar solicitudes a la aplicación web. Lo que la aplicación devuelve no debería cambiar solo por la refactorización, aunque si devuelve HTML, esto es ciertamente menos comprobable.
jpmc26

Me gusta la frase 'pin-down' tests.
Brian Agnew

12

Sugiero, si aún no lo ha hecho, leer Trabajar eficazmente con código heredado y Refactorizar: Mejorar el diseño de código existente .

[..] El problema que me parece es que cuando realizo una refactorización, esas pruebas se romperán a medida que cambie donde se realiza cierta 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á retroalimentación directa si su nueva estructura es realmente mejor que la anterior, porque si lo es, las pruebas ajustadas serán más fáciles de escribir (y, por lo tanto, cambiar las pruebas debería ser relativamente sencillo, lo que reduce el riesgo de tener una nueva introducción error pasa las pruebas).

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


1
Esta. Las pruebas se verán horribles , pero cubrirán el comportamiento existente. Luego, a medida que el código se refactoriza, también lo hacen las pruebas, en el paso de bloqueo. Repita hasta que tenga algo de lo que esté orgulloso. ++
RubberDuck

1
Respaldo ambas recomendaciones de libros: siempre tengo ambas a mano cuando tengo que lidiar con un código sin prueba.
Toby Speight

5

No escriba pruebas unitarias estrictas donde se burle 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 tu 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 parece a 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, burlarse de sus dependencias:

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

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


Si está probando DocumentService y no se burla del DAO, no es una prueba unitaria en absoluto. Es algo entre prueba unitaria y de integración. ¿No lo es?
Laiv

77
@Laiv, en realidad hay una variedad significativa en cómo las personas usan el término prueba unitaria. Algunos lo usan para significar solo pruebas estrictamente aisladas. Otros incluyen cualquier prueba que se ejecute rápidamente. Algunos incluyen cualquier cosa que se ejecute en un marco de prueba. Pero en última instancia, no importa cómo desee definir el término prueba unitaria. La pregunta es qué pruebas son útiles, por lo que no debemos distraernos por cómo definimos exactamente la prueba unitaria.
Winston Ewert

Excelente punto que muestra que la utilidad es lo más importante. Las pruebas unitarias extravagantes para los algoritmos más triviales solo por el hecho de hacer que las pruebas unitarias hagan más daño que bien, si no solo una pérdida masiva de tiempo y recursos valiosos. Esto se puede aplicar a casi todo y es algo que desearía saber al principio de mi carrera.
Lee

3

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

Si no hay pruebas formales al más alto nivel, intente encontrar un conjunto de requisitos que los clientes (código de llamada o humanos) deben permanecer igual después de su rediseño para que su código se considere que funciona. Esa es la lista de casos de prueba que necesita implementar.

Para responder a su pregunta sobre cambios en las implementaciones que requieren cambios en los casos de prueba, le sugiero que eche un vistazo a TDD Detroit (clásico) vs Londres (simulacro). Martin Fowler habla de esto en su gran artículo Los simulacros no son trozos, pero muchas personas tienen opiniones. Si comienza en el nivel más alto, donde sus elementos externos no pueden cambiar, y avanza hacia abajo, entonces los requisitos deben mantenerse bastante estables hasta llegar a un nivel que realmente necesita cambiar.

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


3

Aquí mi acercamiento. Tiene un costo en términos de tiempo porque es una prueba de refactorización en 4 fases.

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

De todos modos, la estrategia es válida para que cualquier candidato a componente sea normalizado por una interfaz (DAO, Servicios, Controladores, ...).

1. La interfaz

Vamos a reunir todos los métodos públicos de MyDocumentService y ponerlos todos juntos en una interfaz. Por ejemplo. Si ya existe, use ese en lugar de configurar uno nuevo .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Luego forzamos a MyDocumentService a implementar esta nueva interfaz.

Hasta aquí todo bien. No se hicieron 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 un conjunto de pruebas. Deberíamos establecer tantos casos como sea posible: casos exitosos y también casos de error. Estos últimos son por el bien de la calidad del resultado.

Ahora, en lugar de probar MyDocumentService , vamos a utilizar la interfaz como el contrato que se va 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 permanece intacto. Sugiero hacer una etiqueta aquí en el SCM. La etiqueta o rama no importa. Solo haz una versión.

Lo queremos para retrocesos, 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).

Nueva clase sigue implementando DocumentService . Luego ve y refactoriza getAllDocuments () . (Comencemos por uno. Pin-refactores)

Puede requerir algunos cambios en la interfaz / métodos de DAO. Si es así, no cambie el código existente. Implemente su propio método en la interfaz DAO. Anote el código antiguo como obsoleto y sabrá más adelante 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 tenemos oldResult y newResult, ambos validados independientemente, pero también podemos comparar entre nosotros. Esta última validación es opcional y depende del resultado. Puede ser que no sea comparable.

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

Notas

No me atrevería a decir cómo hacer pruebas unitarias o cómo usar libs simuladas. Tampoco me atrevo 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. Sabes exactamente cómo es el código, su complejidad y si vale la pena intentarlo. Hechos como el tiempo y los recursos son importantes aquí. También importa qué espera de estas pruebas en el futuro.

Comencé 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 una estrategia ascendente . Sin embargo, para cambios / refactores menores ( como el que se expone en el ejemplo del recorrido ), una tarea ascendente facilitaría la tarea. Porque el alcance de los cambios es pequeño.

Finalmente, depende de usted eliminar el código obsoleto y redirigir las dependencias antiguas al nuevo.

Elimine también las pruebas obsoletas 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, ha probado, validado y versionado el código heredado. Y nuevo código, probado, validado y listo para ser versionado.


3

tl; dr No escriba pruebas unitarias. Escribir exámenes a un nivel más apropiado.


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

no cambias lo que hace tu software, cambias cómo lo hace

Hay un espectro muy amplio. En un extremo, hay un cambio autónomo en 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 funcionen 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 intacta.

Pensar:

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


2

No pierda el tiempo escribiendo pruebas que se conectan en puntos donde puede anticipar que la interfaz va a cambiar de una manera no trivial. Esto es a menudo una señal de que está tratando de evaluar las clases unitarias que son de naturaleza 'colaborativa', cuyo valor no está 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 ese comportamiento el que desea probar, lo que significa que desea realizar la prueba a un nivel superior. Las pruebas por debajo de este nivel a menudo requieren mucha burla fea, y las pruebas resultantes pueden ser más una carga para el desarrollo que una ayuda para defender el comportamiento.

No se obsesione con 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 equivalen a un refactorizador. El punto es tener claro qué comportamiento es valioso para usted y defender ese comportamiento a medida que avanza.

Puede ser útil tener en cuenta a medida que escribe sus pruebas: ¿podría describir fácilmente a un QA, al propietario de un producto o a un usuario lo que esta prueba realmente está probando? Si parece que describir la prueba sería demasiado esotérico y técnico, tal vez esté probando en el nivel incorrecto. Pruebe en los puntos / niveles que 'tengan sentido', y no estropee su código con pruebas en todos los niveles.


Siempre interesado en razones para votos negativos!
topo Reinstale a Monica

1

Su primera tarea es tratar de encontrar la "firma de método ideal" para sus pruebas. Esfuércese para 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 refactorice 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 la capa de mi adaptador:

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

Este ejemplo parece algo bastante obvio según el principio de No repetirse, pero puede no ser tan obvio en otros casos. La ventaja va más allá de DRY: la ventaja real 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 razón para escribir adaptadores para POCO / POJO porque realmente no tienen una API que pueda cambiar independientemente del código de prueba. Además, si está escribiendo una pequeña cantidad de pruebas, una capa de adaptador relativamente grande probablemente sería un esfuerzo inútil.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.