it-swarm-es.com

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

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 fanático 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 mantenía esta como una variante interesante).

33
Alex Feinman

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

Comience con todas las pruebas verdes (todas las aprobadas), luego realice modificaciones "bajo el capó" (por ejemplo, mueva un método de una clase derivada a la base, extraiga un método o encapsule un Compuesto con un Generador , 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 traté de definirlo aquí) no están en conflicto. Todavía puede refactorizar (verde-verde) y aplicar TDD (rojo-verde) para desarrollar la funcionalidad "delta".

38
azheglov

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 después de refactorizarlas.

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 de cabeza, pero vale la pena a largo plazo.

21
Tim Murphy

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), if la prueba es whitebox.

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 son un efecto secundario gratis); 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 exponiendo 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 OO idiomas, incluyendo c # a " prueba-subclase-específica " podría usarse), de repente, el estado interno de la clase será importante: puede estar refactorizando la clase como un recuadro negro , pero las pruebas de recuadro 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 asegurar 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 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 la prueba 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

10
perfectionist

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, para que otras partes de su código intenten y no puedan usar el ahora API alterada, ¡pero eso no está refactorizando!

5
Frank Shearar

Creo que el problema con esta pregunta es que diferentes personas están tomando la Palabra "refactorizando" 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 quiere 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.

4
Lewis Pringle

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 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 todavía tienen que acoplarse a algo en algún nivel, pero idealmente debería ser una interfaz que se versione explícitamente utilizando 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).

1
KolA

Supongo que sus pruebas unitarias son de una granularidad que 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.

1
gbjbaanb

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 se puede refactorizar el significado de las pruebas.

0
mcintyre321