¿Por qué escribir pruebas para el código que refactorizaré?

15

Estoy refactorizando una gran clase de código heredado. La refactorización (supongo) aboga por esto:

  1. escribir pruebas para la clase heredada
  2. refactorizar al diablo de la clase

Problema: una vez que refactorice la clase, mis pruebas en el paso 1 deberán cambiarse. Por ejemplo, lo que una vez estuvo en un método heredado, ahora puede ser una clase separada. Lo que era un método puede ser ahora varios métodos. Todo el paisaje de la clase heredada puede ser borrado en algo nuevo, por lo que las pruebas que escribo en el paso 1 serán casi nulas y sin efecto. En esencia, agregaré el Paso 3. reescriba mis pruebas profusamente

¿Cuál es el propósito de escribir pruebas antes de refactorizar? Suena más como un ejercicio académico de crear más trabajo para mí. Estoy escribiendo pruebas para el método ahora y estoy aprendiendo más sobre cómo probar cosas y cómo funciona el método heredado. Uno puede aprender esto simplemente leyendo el código heredado, pero escribir pruebas es casi como frotarme la nariz y también documentar este conocimiento temporal en pruebas separadas. De esta manera, casi no tengo más remedio que aprender qué está haciendo el código. Dije temporal aquí, porque refactorizaré el código y toda mi documentación y pruebas serán nulas e inválidas durante una parte significativa, excepto que mi conocimiento se mantendrá y me permitirá estar más fresco en la refactorización.

¿Es esa la verdadera razón para escribir pruebas antes de refactorizar, para ayudarme a comprender mejor el código? Tiene que haber otra razón!

¡Por favor explique!

Nota:

Existe esta publicación: ¿Tiene sentido escribir pruebas para el código heredado cuando no hay tiempo para una refactorización completa? pero dice "escribir pruebas antes de refactorizar", pero no dice "por qué" o qué hacer si "escribir pruebas" parece "trabajo ocupado que se destruirá pronto"

Dennis
fuente
1
Tu premisa es incorrecta. No cambiarás tus pruebas. Escribirás nuevas pruebas. El paso 3 será "eliminar cualquier prueba que ahora esté inactiva".
pdr
1
El paso 3 puede leer "Escribir nuevas pruebas. Eliminar pruebas inactivas". Creo que todavía equivale a destruir el trabajo original
Dennis
3
No, desea escribir las nuevas pruebas durante el paso 2. Y sí, el paso 1 se destruye. ¿Pero fue una pérdida de tiempo? No, porque le da mucha seguridad de que no está rompiendo nada durante el paso 2. Sus nuevas pruebas no lo hacen.
pdr
3
@Dennis: si bien comparto muchas de las mismas inquietudes que tiene con respecto a las situaciones, podríamos considerar la mayor parte del esfuerzo de refactorización como "destruir el trabajo original", pero si nunca lo destruimos, nunca nos alejaríamos del código de espagueti con 10k líneas en una archivo. Probablemente lo mismo debería ir para las pruebas unitarias, van de la mano con el código que están probando. A medida que el código evoluciona y las cosas se mueven y / o eliminan, también deberían evolucionar las pruebas unitarias con él.
DXM
"Comprender el código" no es una pequeña ventaja. ¿Cómo esperas refactorizar un programa que no entiendes? Es inevitable, y qué mejor manera de demostrar la verdadera comprensión de un programa que escribir una prueba exhaustiva. También se debe decir que cuanto más abstracta sea la prueba, menos probable será que tenga que rascarla más tarde, así que, en todo caso, al principio, quédese con las pruebas de alto nivel.
Neil

Respuestas:

46

Refactorizar es limpiar un fragmento de código (por ejemplo, mejorar el estilo, el diseño o los algoritmos), sin cambiar el comportamiento (visible desde el exterior). Escribes pruebas para no asegurarte de que el código antes y después de la refactorización sea el mismo, sino que escribes pruebas como un indicador de que tu aplicación antes y después de la refactorización se comporta igual: el nuevo código es compatible y no se introdujeron nuevos errores.

Su principal preocupación debería ser escribir pruebas unitarias para la interfaz pública de su software. Esta interfaz no debería cambiar, por lo que las pruebas (que son una verificación automática para esta interfaz) tampoco deberían cambiar.

Sin embargo, las pruebas también son útiles para localizar errores, por lo que también puede tener sentido escribir pruebas para partes privadas de su software. Se espera que estas pruebas cambien a lo largo de la refactorización. Si desea cambiar un detalle de implementación (como el nombre de una función privada), primero actualice las pruebas para reflejar sus expectativas cambiadas, luego asegúrese de que la prueba falle (sus expectativas no se cumplen), luego cambie el código real y verifique que todas las pruebas pasen nuevamente. En ningún momento deberían comenzar a fallar las pruebas para la interfaz pública.

Esto es más difícil cuando se realizan cambios a mayor escala, por ejemplo, rediseñando múltiples partes codependientes. Pero habrá algún tipo de límite, y en ese límite podrás escribir pruebas.

amon
fuente
66
+1. Lee mi mente, escribió mi respuesta. Punto importante: ¡es posible que deba escribir pruebas unitarias para mostrar que los mismos errores siguen ahí después de la refactorización!
david.pfx
Pregunta: ¿por qué en su ejemplo de cambio de nombre de función, primero cambia la prueba para asegurarse de que falla? Quiero decir que, por supuesto, fallará cuando lo cambie, ¡rompió la conexión que usan los enlazadores para unir el código! ¿Tal vez esperas que pueda haber otra función privada existente por el nombre que acabas de elegir y debes verificar que no es el caso en caso de que te lo hayas perdido? Veo que esto le dará cierta seguridad al borde del TOC, pero en este caso se siente como una exageración. ¿Hay alguna razón posible por la que la prueba en su ejemplo no falle?
Dennis
^ cont: como técnica general, veo que es bueno hacer la verificación de la cordura paso a paso de su código para detectar las cosas que salen mal lo antes posible. Es posible que no se enferme si no se lava las manos todo el tiempo, pero simplemente lavarse las manos como un hábito lo mantendrá más saludable en general, ya sea que entre en contacto con cosas contaminadas o no. Aquí puede lavarse las manos de manera superflua a veces, o probar el código de manera superflua a veces, pero ayuda a que usted y su código se mantengan saludables. ¿Ese era tu punto?
Dennis
@Dennis en realidad, estaba describiendo inconscientemente un experimento científicamente correcto: no podemos decir qué parámetro realmente afectó el resultado al cambiar más un parámetro. Recuerde que las pruebas son código, y cada código contiene errores. ¿Irás al infierno del programador por no ejecutar las pruebas antes de tocar el código? Seguramente no: aunque ejecutar las pruebas sería ideal, es su criterio profesional si es necesario. Tenga en cuenta además que una prueba ha fallado si no se compila, y que mi respuesta también es aplicable a lenguajes dinámicos, no solo a lenguajes estáticos con un vinculador.
amon
2
Después de corregir varios errores durante la refactorización, me doy cuenta de que no habría realizado los movimientos del código tan fácilmente sin tener pruebas. Las pruebas me alertan de "diferencias" de comportamiento / funcionales que presento al cambiar mi código.
Dennis
7

Ah, manteniendo sistemas heredados.

Idealmente, sus pruebas tratan la clase solo a través de su interfaz con el resto de la base de código, otros sistemas y / o la interfaz de usuario. Interfaces No puede refactorizar la interfaz sin afectar esos componentes ascendentes o descendentes. Si todo es un desastre estrechamente acoplado, entonces también podría considerar el esfuerzo de reescribir en lugar de refactorizar, pero es en gran parte semántico.

Editar: Digamos que parte de su código mide algo y tiene una función que simplemente devuelve un valor. La única interfaz es llamar a la función / método / whatnot y recibir el valor devuelto. Este es un acoplamiento suelto y una prueba fácil de usar Si su programa principal tiene un subcomponente que administra un búfer, y todas las llamadas dependen del búfer en sí, algunas variables de control y devuelve los mensajes de error a través de otra sección de código, entonces podría decir que está estrechamente acoplado y es prueba difícil de unidad. Todavía puede hacerlo con cantidades suficientes de objetos simulados y demás, pero se vuelve desordenado. Especialmente en c. Cualquier cantidad de refactorización del funcionamiento del búfer romperá el subcomponente.
Fin de edición

Si está probando su clase a través de interfaces que permanecen estables, sus pruebas deben ser válidas antes y después de la refactorización. Esto le permite hacer cambios con la confianza de que no lo rompió. Al menos, más confianza.

También te permite hacer cambios incrementales. Si este es un gran proyecto, no creo que quiera desarmarlo todo, construir un nuevo sistema y luego comenzar a desarrollar pruebas. Puede cambiar una parte de ella, probarla y asegurarse de que el cambio no derribe el resto del sistema. O si lo hace, al menos puedes ver el desorden enredado gigante en desarrollo en lugar de sorprenderte cuando lo sueltes.

Si bien puede dividir un método en tres, seguirán haciendo lo mismo que el método anterior, por lo que puede tomar la prueba del método anterior y dividirlo en tres. El esfuerzo de escribir la primera prueba no se desperdicia.

Además, tratar el conocimiento del sistema heredado como "conocimiento temporal" no va a funcionar bien. Saber cómo lo hizo anteriormente es vital cuando se trata de sistemas heredados. Muy útil para la antigua pregunta de "¿por qué demonios hace eso?"

Philip
fuente
Creo que entiendo, pero me perdiste en las interfaces. es decir, las pruebas que estoy escribiendo ahora verifican si ciertas variables se han poblado correctamente, después de llamar al método bajo prueba. Si esas variables se cambian o refactorizan, también lo serán mis pruebas. La clase heredada existente con la que estoy trabajando no tiene interfaces / getters / setters por seh, lo que haría cambios variables o menos trabajo intensivo. Pero, una vez más, no estoy seguro de lo que quiere decir con interfaces cuando se trata de código heredado. Tal vez puedo crear algunos? Pero eso será refactorizar.
Dennis
1
Sí, si tienes una clase de dios que hace todo, entonces realmente no hay ninguna interfaz. Pero si llama a otra clase, la clase superior espera que se comporte de cierta manera, y las pruebas unitarias pueden verificar que así sea. Aún así, no pretendo que no va a tener que actualizar la prueba de su unidad mientras refactoriza.
Philip
4

Mi propia respuesta / realización:

Después de corregir varios errores durante la refactorización, me estoy dando cuenta de que no habría realizado los movimientos del código tan fácilmente sin tener pruebas. Las pruebas me alertan de "diferencias" de comportamiento / funcionales que presento al cambiar mi código.

No tiene que estar hiperconsciente cuando tiene buenas pruebas en su lugar. Puede editar su código en un comportamiento más relajado. Las pruebas hacen la verificación y los controles de cordura por usted.

Además, mis pruebas se han mantenido más o menos igual que refactorizado y no fueron destruidas. De hecho, noté algunas oportunidades adicionales para agregar afirmaciones a mis pruebas a medida que profundizaba en el código.

ACTUALIZAR

Bueno, ahora estoy cambiando mucho mis pruebas: / Debido a que reescribí la función original (eliminé la función y creé una nueva clase de limpiador, moviendo la pelusa que solía estar dentro de la función fuera de la nueva clase), así que ahora El código bajo prueba que ejecuté antes toma diferentes parámetros bajo un nombre de clase diferente y produce diferentes resultados (el código original con la pelusa tenía más resultados para probar). Entonces, mis pruebas deben reflejar estos cambios y básicamente estoy reescribiendo mis pruebas en algo nuevo.

Supongo que hay otras soluciones que puedo hacer para evitar reescribir las pruebas. es decir, mantenga el nombre de la función anterior con un código nuevo y la pelusa dentro de él ... pero no sé si es la mejor idea y todavía no tengo tanta experiencia para decidir qué hacer.

Dennis
fuente
Parece que rediseñó la aplicación junto con la refactorización.
JeffO
¿Cuándo se refactoriza y cuándo se rediseña? es decir, cuando se refactoriza es difícil no dividir las clases difíciles de manejar más grandes en clases más pequeñas, y también moverlas. Entonces sí, no estoy exactamente seguro de la distinción, pero tal vez estoy haciendo ambas cosas.
Dennis
3

Use sus pruebas para manejar su código mientras lo hace. En el código heredado, esto significa escribir pruebas para el código que va a cambiar. De esa manera no son un artefacto separado. Las pruebas deben ser sobre lo que el código necesita para lograr y no sobre las agallas internas de cómo lo hace.

En general, desea agregar pruebas en el código que no tiene ninguno) para el código que va a refactorizar para asegurarse de que el comportamiento de los códigos continúe funcionando según lo previsto. Por lo tanto, ejecutar continuamente el conjunto de pruebas mientras se refactoriza es una fantástica red de seguridad. La idea de cambiar el código sin un conjunto de pruebas para confirmar que los cambios no están afectando algo inesperado es aterrador.

En cuanto a lo esencial de actualizar las pruebas antiguas, escribir nuevas pruebas, eliminar pruebas antiguas, etc. Simplemente lo veo como parte del costo del desarrollo de software profesional moderno.

Michael Durrant
fuente
Su primer párrafo parece estar abogando por ignorar el paso 1 y escribir pruebas a medida que avanza; su segundo párrafo parece contradecir eso.
pdr
Actualicé mi respuesta.
Michael Durrant
2

¿Cuál es el objetivo de refactorizar en su caso específico?

Presumimos con el propósito de soportar mi respuesta de que todos creemos (hasta cierto punto) en TDD (Test-Driven Development).

Si el propósito de su refactorización es limpiar el código existente sin cambiar el comportamiento existente, entonces escribir pruebas antes de refactorizar es cómo asegurarse de que no ha cambiado el comportamiento del código, si tiene éxito, las pruebas tendrán éxito tanto antes como después usted refactoriza.

  • Las pruebas lo ayudarán a asegurarse de que su nuevo trabajo realmente funcione.

  • Las pruebas probablemente también descubrirán casos en los que el trabajo original no funciona.

Pero, ¿cómo se hace una refactorización significativa sin afectar el comportamiento en algún grado?

Aquí hay una breve lista de algunas cosas que pueden suceder durante la refactorización:

  • renombrar variable
  • cambiar el nombre de la función
  • agregar función
  • función de borrar
  • dividir la función en dos o más funciones
  • combinar dos o más funciones en una sola función
  • clase dividida
  • combinar clases
  • renombrar clase

Voy a argumentar que cada una de esas actividades enumeradas cambia el comportamiento de alguna manera.

Y voy a argumentar que si su refactorización cambia el comportamiento, sus pruebas seguirán siendo cómo se asegura de que no haya roto nada.

Quizás el comportamiento no cambie a nivel macro, pero el objetivo de las pruebas unitarias no es garantizar el comportamiento macro. Eso es prueba de integración . El objetivo de las pruebas unitarias es garantizar que las piezas y piezas individuales con las que construye su producto no se rompan. Cadena, eslabón más débil, etc.

¿Qué tal este escenario:

  • Presume que tienes function bar()

  • function foo() hace una llamada a bar()

  • function flee() también hace un llamado a funcionar bar()

  • Solo por variedad, flam()hace un llamado afoo()

  • Todo funciona espléndidamente (aparentemente, al menos).

  • Refactorizas ...

  • bar() se renombra a barista()

  • flee() se cambia para llamar barista()

  • foo()se no cambiado a la llamadabarista()

Obviamente, sus pruebas para ambos foo()y flam()ahora fallan.

Tal vez no te diste cuenta de que foo()llamaste bar()en primer lugar. Ciertamente no te diste cuenta de que flam()dependía a bar()propósito foo().

Lo que sea. El punto es que sus pruebas descubrirán el comportamiento recientemente roto de ambos foo()y flam(), de manera incremental, durante su trabajo de refactorización.

Las pruebas terminan ayudándote a refactorizar bien.

A menos que no tengas ninguna prueba.

Ese es un poco un ejemplo artificial. Hay quienes argumentan que si cambiar los bar()descansos foo(), foo()para empezar , sería demasiado complejo y debería desglosarse. Pero los procedimientos pueden llamar a otros procedimientos por una razón y es imposible eliminar toda la complejidad, ¿verdad? Nuestro trabajo es gestionar la complejidad razonablemente bien.

Considere otro escenario.

Estás construyendo un edificio.

Construye un andamio para ayudar a garantizar que el edificio se construya correctamente.

El andamio lo ayuda a construir un pozo de ascensor, entre otras cosas. Después, derribas el andamio, pero el hueco del ascensor permanece. Has destruido el "trabajo original" al destruir el andamio.

La analogía es tenue, pero el punto es que no es inaudito construir herramientas que lo ayuden a construir productos. Incluso si las herramientas no son permanentes, son útiles (incluso necesarias). Los carpinteros hacen plantillas todo el tiempo, a veces solo para un trabajo. Luego desgarran las plantillas, a veces usan las piezas para construir otras plantillas para otros trabajos, a veces no. Pero eso no hace que las plantillas sean un esfuerzo inútil o desperdiciado.

Craig
fuente