¿Cómo mantiene funcionando sus pruebas unitarias cuando refactoriza?

29

En otra pregunta, se reveló que uno de los dolores con TDD es mantener el paquete de pruebas sincronizado con la base de código durante y después de la refactorización.

Ahora, soy un gran admirador de la refactorización. No voy a renunciar a hacer TDD. Pero también he experimentado los problemas de las pruebas escritas de tal manera que una refactorización menor conduce a muchas fallas en las pruebas.

¿Cómo evitas romper las pruebas al refactorizar?

  • ¿Escribes las pruebas 'mejor'? Si es así, ¿qué debe buscar?
  • ¿Evitas ciertos tipos de refactorización?
  • ¿Existen herramientas de refactorización de pruebas?

Editar: escribí una nueva pregunta que preguntaba qué quería hacer (pero mantuve esta como una variante interesante).

Alex Feinman
fuente
77
Pensé que, con TDD, su primer paso en la refactorización es escribir una prueba que falla y luego refactorizar el código para que funcione.
Matt Ellen
¿No puede su IDE descubrir cómo refactorizar las pruebas también?
@ Thorbjørn Ravn Andersen, sí, y escribí una nueva pregunta que preguntaba qué quería hacer (pero mantuve esta como una variante interesante; vea la respuesta de azheglov, que es esencialmente lo que usted dice)
Alex Feinman
¿Consideró agregar esa información a esta pregunta?

Respuestas:

35

Lo que intentas hacer no es refactorizar realmente. Con la refactorización, por definición, no cambia lo que hace su software, cambia cómo lo hace.

Comience con todas las pruebas ecológicas (todas pasan), luego realice modificaciones "debajo del capó" (por ejemplo, mueva un método de una clase derivada a la base, extraiga un método o encapsule un Compuesto con un Constructor , etc.). Tus pruebas aún deberían pasar.

Lo que estás describiendo no parece ser una refactorización, sino un rediseño, que también aumenta la funcionalidad de tu software bajo prueba. TDD y refactorización (como intenté definirlo aquí) no están en conflicto. Todavía puede refactorizar (verde-verde) y aplicar TDD (rojo-verde) para desarrollar la funcionalidad "delta".

azheglov
fuente
77
El mismo código X copió 15 lugares. Personalizado en cada lugar. Lo convierte en una biblioteca común y parametriza la X o utiliza un patrón de estrategia para permitir estas diferencias. Garantizo que las pruebas unitarias para X fallarán. Los clientes de X fallarán porque la interfaz pública cambia ligeramente. Rediseño o refactorización? Lo llamo refactorizador, pero de cualquier manera rompe todo tipo de cosas. La conclusión es que no puede refactorizar a menos que sepa exactamente cómo encaja todo. Entonces arreglar las pruebas es tedioso pero en última instancia trivial.
Kevin
3
Si las pruebas necesitan un ajuste constante, probablemente sea una pista de tener pruebas demasiado detalladas. Por ejemplo, suponga que una pieza de código necesita desencadenar eventos A, B y C bajo ciertas circunstancias, sin ningún orden en particular. El código anterior lo hace en orden ABC y las pruebas esperan los eventos en ese orden. Si el código refactorizado escupe eventos en el orden ACB, todavía funciona de acuerdo con las especificaciones, pero la prueba fallará.
otto
3
@Kevin: Creo que lo que describe es un rediseño, porque la interfaz pública cambia. La definición de Fowler de refactorización ("alterar la estructura interna [del código] sin cambiar su comportamiento externo") es bastante clara al respecto.
azheglov
3
@azheglov: tal vez, pero en mi experiencia, si la implementación es mala, también lo es la interfaz
Kevin
2
Una pregunta perfectamente válida y clara termina en una discusión sobre el "significado de la palabra". A quién le importa cómo lo llames, tengamos esa discusión en otro lugar. Mientras tanto, esta respuesta omite por completo cualquier respuesta real, pero sigue teniendo la mayor cantidad de votos positivos. Entiendo por qué la gente se refiere a TDD como una religión.
Dirk Boer
21

Uno de los beneficios de tener pruebas unitarias es que puede refactorizar con confianza.

Si la refactorización no cambia la interfaz pública, entonces deja las pruebas unitarias como están y se asegura que después de refactorizar todas pasen.

Si la refactorización cambia la interfaz pública, entonces las pruebas deben reescribirse primero. Refactorizar hasta que pasen las nuevas pruebas.

Nunca evitaría ninguna refactorización porque rompe las pruebas. Escribir pruebas unitarias puede ser un dolor en el trasero, pero vale la pena a largo plazo.

Tim Murphy
fuente
7

Al contrario de las otras respuestas, es importante tener en cuenta que algunas formas de prueba pueden volverse frágiles cuando se refactoriza el sistema bajo prueba (SUT), si la prueba es una caja blanca.

Si estoy usando un marco burlón que verifica el orden de los métodos invocados en los simulacros (cuando el orden es irrelevante porque las llamadas están libres de efectos secundarios); entonces, si mi código está más limpio con esas llamadas a métodos en un orden diferente y refactorizo, entonces mi prueba se interrumpirá. En general, los simulacros pueden introducir fragilidad en las pruebas.

Si estoy verificando el estado interno de mi SUT al exponer a sus miembros privados o protegidos (podríamos usar "amigo" en Visual Basic, o escalar el nivel de acceso "interno" y usar "internalsvisibleto" en c #; en muchos idiomas OO, incluidos c # se podría usar una " subclase específica de prueba "), de repente, el estado interno de la clase será importante: puede que esté refactorizando la clase como un cuadro negro, pero las pruebas de cuadro blanco fallarán. Suponga que un solo campo se reutiliza para significar cosas diferentes (¡no es una buena práctica!) Cuando el SUT cambia de estado; si lo dividimos en dos campos, es posible que necesitemos reescribir pruebas rotas.

Las subclases específicas de prueba también se pueden usar para probar métodos protegidos, lo que puede significar que un refactorizador desde el punto de vista del código de producción es un cambio importante desde el punto de vista del código de prueba. Mover algunas líneas dentro o fuera de un método protegido puede no tener efectos secundarios de producción, pero interrumpa una prueba.

Si uso " ganchos de prueba " o cualquier otro código de compilación condicional o específico de prueba, puede ser difícil garantizar que las pruebas no se rompan debido a las dependencias frágiles de la lógica interna.

Por lo tanto, para evitar que las pruebas se acoplen a los detalles internos íntimos del SUT, puede ayudar a:

  • Use trozos en lugar de simulacros, cuando sea posible. Para obtener más información, consulte el blog de Fabio Periera sobre pruebas tautológicas y mi blog sobre pruebas tautológicas .
  • Si usa simulacros, evite verificar el orden de los métodos llamados, a menos que sea importante.
  • Intente evitar verificar el estado interno de su SUT: use su API externa si es posible.
  • Trate de evitar la lógica específica de la prueba en el código de producción.
  • Trate de evitar el uso de subclases específicas de la prueba.

Todos los puntos anteriores son ejemplos de acoplamiento de caja blanca utilizados en las pruebas. Por lo tanto, para evitar por completo la refactorización de las pruebas de ruptura, utilice las pruebas de caja negra del SUT.

Descargo de responsabilidad: con el propósito de discutir la refactorización aquí, estoy usando la palabra un poco más ampliamente para incluir cambios en la implementación interna sin ningún efecto externo visible. Algunos puristas pueden estar en desacuerdo y referirse exclusivamente al libro Refactoring de Martin Fowler y Kent Beck, que describe las operaciones de refactorización atómica.

En la práctica, tendemos a dar pasos que no se rompen un poco más grandes que las operaciones atómicas descritas allí, y en particular los cambios que hacen que el código de producción se comporte de manera idéntica desde el exterior pueden no dejar pasar las pruebas. Pero creo que es justo incluir "algoritmo sustituto de otro algoritmo que tenga un comportamiento idéntico" como refactorizador, y creo que Fowler está de acuerdo. El propio Martin Fowler dice que la refactorización puede romper las pruebas:

Cuando escribe una prueba simulada, está probando las llamadas salientes del SUT para asegurarse de que hable correctamente con sus proveedores. Una prueba clásica solo se preocupa por el estado final, no por cómo se derivó ese estado. Las pruebas simuladas están, por lo tanto, más acopladas a la implementación de un método. Cambiar la naturaleza de las llamadas a los colaboradores generalmente hace que se rompa una prueba simulada.

[...]

El acoplamiento a la implementación también interfiere con la refactorización, ya que es mucho más probable que los cambios de implementación rompan las pruebas que con las pruebas clásicas.

Fowler: los simulacros no son trozos

perfeccionista
fuente
Fowler literalmente escribió el libro sobre Refactorización; y el libro más autorizado sobre Pruebas unitarias (xUnit Test Patterns de Gerard Meszaros) está en la serie "firma" de Fowler, por lo que cuando dice que la refactorización puede romper una prueba, probablemente tenga razón.
perfeccionista
5

Si sus pruebas se rompen cuando está refactorizando, entonces no está, por definición, refactorizando, lo que significa "cambiar la estructura de su programa sin cambiar el comportamiento de su programa".

A veces, usted necesita cambiar el comportamiento de sus pruebas. Tal vez necesite fusionar dos métodos (por ejemplo, bind () y listen () en una clase de socket TCP de escucha), por lo que tiene otras partes de su código que intentan y no pueden usar la API ahora alterada. ¡Pero eso no es refactorizar!

Frank Shearar
fuente
¿Qué pasa si él simplemente cambia el nombre de un método probado por las pruebas? Las pruebas fallarán a menos que también las renombre en las pruebas. Aquí no está cambiando el comportamiento del programa.
Oscar Mederos
2
En cuyo caso sus pruebas también están siendo refactorizadas. Sin embargo, debe tener cuidado: primero cambia el nombre del método y luego ejecuta la prueba. Debería fallar por los motivos correctos (no se puede compilar (C #), se obtiene una excepción MessageNotUndersvered (Smalltalk), parece que no sucede nada (patrón de anulación de Objective-C)). Luego cambia su prueba, sabiendo que no ha introducido ningún error accidentalmente. "Si sus pruebas se rompen" significa "si sus pruebas se rompen después de que haya terminado la refactorización", en otras palabras. ¡Intenta mantener los trozos pequeños!
Frank Shearar 01 de
1
Las pruebas unitarias están inherentemente acopladas a la estructura del código. Por ejemplo, Fowler tiene muchos en refactoring.com/catalog que afectarían las pruebas unitarias (por ejemplo, método de ocultar, método en línea, reemplazar código de error con excepción, etc.).
Kristian H
falso. La fusión de dos métodos juntos es obviamente una refactorización que tiene nombres oficiales (por ejemplo, la refactorización de métodos en línea se ajusta a la definición) y romperá las pruebas de un método que está en línea; algunos de los casos de prueba ahora deberían reescribirse / probarse por otros medios. No tengo que cambiar el comportamiento de un programa para romper las pruebas unitarias, todo lo que necesito hacer es reestructurar las partes internas que tienen pruebas unitarias junto con ellas. Mientras el comportamiento de un programa no cambie, esto todavía se ajusta a la definición de refactorización.
KolA
Escribí lo anterior asumiendo pruebas bien escritas: si está probando su implementación, si la estructura de la prueba refleja las partes internas del código bajo prueba, claro. En cuyo caso, pruebe el contrato de la unidad, no la implementación.
Frank Shearar
4

Creo que el problema con esta pregunta es que diferentes personas están tomando la palabra 'refactorizar' de manera diferente. Creo que es mejor definir cuidadosamente algunas cosas que probablemente quieras decir:

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

Como ya señaló otra persona, si mantiene la API igual y todas sus pruebas de regresión operan en la API pública, no debería tener problemas. La refactorización no debería causar ningún problema. Cualquier prueba fallida CUALQUIERA significa que su código anterior tenía un error y su prueba es mala, o su nuevo código tiene un error.

Pero eso es bastante obvio. Entonces, PROBABLEMENTE quieres decir con refactorización, que estás cambiando la API.

¡Déjenme responder cómo abordar eso!

  • Primero cree una NUEVA API, que haga lo que desea que sea su NUEVO comportamiento de API. Si sucede que esta nueva API tiene el mismo nombre que una API ANTERIOR, entonces agrego el nombre _NUEVO al nuevo nombre de la API.

    int DoSomethingInterestingAPI ();

se convierte en:

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

OK, en esta etapa, todas sus pruebas de regresión se aprobarán utilizando el nombre DoSomethingInterestingAPI ().

SIGUIENTE, revise su código y cambie todas las llamadas a DoSomethingInterestingAPI () a la variante apropiada de DoSomethingInterestingAPI_NEW (). Esto incluye actualizar / reescribir las partes de las pruebas de regresión que se deban cambiar para usar la nueva API.

SIGUIENTE, marque DoSomethingInterestingAPI_OLD () como [[en desuso ()]]. Mantenga la API obsoleta todo el tiempo que desee (hasta que haya actualizado de forma segura todo el código que pueda depender de ella).

Con este enfoque, cualquier falla en sus pruebas de regresión simplemente son errores en esa prueba de regresión o identifican errores en su código, exactamente como lo desearía. Este proceso por etapas de revisión de una API mediante la creación explícita de versiones _NEW y _OLD de la API le permite tener partes del código nuevo y antiguo coexistiendo por un tiempo.

Lewis Pringle
fuente
Me gusta esta respuesta porque hace obvio que las Pruebas unitarias al SUT son lo mismo que los clientes externos a una API publicada. Lo que prescribe es muy similar al protocolo SemVer para administrar la biblioteca / componente publicada para evitar el 'infierno de dependencia'. Sin embargo, esto tiene un costo de tiempo y flexibilidad, extrapolar este enfoque a la interfaz pública de cada micro unidad también significa extrapolar los costos. Un enfoque más flexible es desacoplar las pruebas de la implementación tanto como sea posible, es decir, pruebas de integración o un DSL separado para describir las entradas y salidas de prueba
KolA
1

Supongo que sus pruebas unitarias son de una granularidad que yo llamaría "estúpido" :) es decir, prueban las minucias absolutas de cada clase y función. Aléjese de las herramientas generadoras de código y escriba pruebas que se apliquen a una superficie más grande, luego puede refactorizar las partes internas tanto como desee, sabiendo que las interfaces para sus aplicaciones no han cambiado y sus pruebas aún funcionan.

Si desea tener pruebas unitarias que prueben todos y cada uno de los métodos, espere tener que refactorizarlos al mismo tiempo.

gbjbaanb
fuente
1
La respuesta más útil que en realidad aborda la pregunta: no construya su cobertura de prueba sobre una base inestable de curiosidades internas, o espere que se desmorone constantemente, pero la mayoría rechaza porque la TDD prescribe hacer exactamente lo contrario. Esto es lo que obtienes por señalar la verdad incómoda sobre un enfoque exagerado.
KolA
1

mantener el conjunto de pruebas sincronizado con la base de código durante y después de la refactorización

Lo que lo hace difícil es el acoplamiento . Cualquier prueba viene con cierto grado de acoplamiento con los detalles de implementación, pero las pruebas unitarias (independientemente de si es TDD o no) son especialmente malas porque interfieren con las partes internas: más pruebas unitarias equivalen a más código acoplado a las unidades, es decir, firmas de métodos / cualquier otra interfaz pública de unidades, al menos.

Las "unidades", por definición, son detalles de implementación de bajo nivel, la interfaz de las unidades puede y debe cambiar / dividirse / fusionarse y, de lo contrario, mutar a medida que el sistema evoluciona. La abundancia de pruebas unitarias puede obstaculizar esta evolución más de lo que ayuda.

¿Cómo evitar romper las pruebas al refactorizar? Evitar el acoplamiento. En la práctica, significa evitar tantas pruebas unitarias como sea posible y preferir pruebas de mayor nivel / integración más agnósticas de los detalles de implementación. Sin embargo, recuerde que no hay una viñeta plateada, las pruebas aún tienen que acoplarse a algo en algún nivel, pero idealmente debería ser una interfaz que se versione explícitamente usando el Versionado Semántico, es decir, generalmente en el nivel de aplicación / aplicación publicada (no desea hacer SemVer para cada unidad en su solución).

Kola
fuente
0

Sus pruebas están demasiado unidas a la implementación y no al requisito.

considere escribir sus pruebas con comentarios como este:

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

de esta manera no puede refactorizar el significado de las pruebas.

mcintyre321
fuente