¿Tiene sentido escribir pruebas para código heredado cuando no hay tiempo para una refactorización completa?

72

Por lo general, trato de seguir el consejo del libro Cómo trabajar con eficacia con el bacalao heredado e . Rompo las dependencias, muevo partes del código a los métodos @VisibleForTesting public static y a nuevas clases para hacer que el código (o al menos parte de él) sea verificable. Y escribo pruebas para asegurarme de no romper nada cuando modifico o añado nuevas funciones.

Un colega dice que no debería hacer esto. Su razonamiento:

  • El código original podría no funcionar correctamente en primer lugar. Y escribir pruebas para ello hace que las correcciones y modificaciones futuras sean más difíciles ya que los desarrolladores también tienen que entender y modificar las pruebas.
  • Si es un código GUI con algo de lógica (~ 12 líneas, 2-3 si bloque / else, por ejemplo), una prueba no vale la pena porque el código es demasiado trivial para comenzar.
  • También pueden existir patrones malos similares en otras partes del código base (que no he visto todavía, soy bastante nuevo); Será más fácil limpiarlos todos en una gran refactorización. Extraer la lógica podría socavar esta posibilidad futura.

¿Debo evitar extraer partes comprobables y escribir pruebas si no tenemos tiempo para una refactorización completa? ¿Hay alguna desventaja en esto que deba considerar?

    
pregunta is4 06.02.2014 - 08:15

10 respuestas

100

Aquí está mi impresión personal no científica: las tres razones parecen ser ilusiones cognitivas generalizadas pero falsas.

  1. Claro, el código existente podría estar equivocado. También podría ser correcto. Como la aplicación en su conjunto parece tener valor para usted (de lo contrario, simplemente lo descartaría), en ausencia de información más específica, debe asumir que es predominantemente correcta. "Escribir exámenes hace que las cosas sean más difíciles porque hay más código involucrado en general" es una actitud simplista y muy incorrecta.
  2. Por supuesto, invierta sus esfuerzos de refactorización, prueba y mejora en los lugares donde agregan el mayor valor con el menor esfuerzo. Las subrutinas de GUI de formateo de valor a menudo no son la primera prioridad. Pero no probar algo porque "es simple" también es una actitud muy incorrecta. Prácticamente todos los errores graves se cometen porque las personas pensaron que entendían algo mejor de lo que realmente entendían.
  3. "Lo haremos todo de una sola vez en el futuro" es una buena idea. Por lo general, el gran golpe se mantiene firme en el futuro, mientras que en el presente no sucede nada. Yo, estoy firmemente convencido de la convicción de que "la carrera gana lenta y constantemente".
respondido por el Kilian Foth 06.02.2014 - 08:28
50

Algunos pensamientos:

Cuando estás refactorizando el código heredado, no importa si algunas de las pruebas que escribes contradicen las especificaciones ideales. Lo que importa es que prueban el comportamiento actual del programa. La refactorización consiste en tomar pequeños pasos iso-funcionales para hacer que el código sea más limpio; no desea participar en la corrección de errores mientras está refactorizando. Además, si ves un error evidente, no se perderá. Siempre puede escribir una prueba de regresión para esta y deshabilitarla temporalmente, o insertar una tarea de corrección de errores en su backlog para más adelante. Una cosa a la vez.

Estoy de acuerdo en que el código GUI puro es difícil de probar y quizás no sea un buen ajuste para la refactorización del estilo " Trabajar con eficacia ... ". Sin embargo, esto no significa que no deba extraer el comportamiento que no tiene nada que hacer en la capa GUI y probar el código extraído. Y "12 líneas, 2-3 si / else bloque" no es trivial. Se debe probar todo el código con al menos un poco de lógica condicional.

En mi experiencia, las grandes refactorizaciones no son fáciles y rara vez funcionan. Si no te fijas metas pequeñas y precisas, existe un gran riesgo de que te embarques en una repetición de cabellos que nunca terminará y que nunca caerá sobre tus pies al final. Cuanto más grande sea el cambio, más riesgo corres el riesgo de romper algo y más problemas tendrás para descubrir dónde fracasaste.

Mejorar las cosas progresivamente con pequeñas refactorizaciones ad hoc no es "socavar las posibilidades futuras", sino que las habilita, solidificando el terreno pantanoso donde se encuentra su aplicación. Definitivamente deberías hacerlo.

    
respondido por el guillaume31 06.02.2014 - 12:38
17

También re: "El código original podría no funcionar correctamente", eso no significa que simplemente cambie el comportamiento del código sin preocuparse por el impacto. Otro código puede depender de lo que parece ser un comportamiento roto o efectos secundarios de la implementación actual. La cobertura de prueba de la aplicación existente debería hacer que sea más fácil refactorizar más tarde, ya que le ayudará a descubrir cuándo ha roto algo accidentalmente. Primero debes probar las partes más importantes.

    
respondido por el Rory Hunter 06.02.2014 - 12:01
14

La respuesta de Kilian cubre los aspectos más importantes, pero quiero ampliar los puntos 1 y 3.

Si un desarrollador quiere cambiar (refactorizar, extender, depurar) el código, tiene que entenderlo. Ella tiene que asegurarse de que sus cambios afecten exactamente el comportamiento que desea (nada en el caso de refactorización), y nada más.

Si hay pruebas, entonces ella también tiene que entender las pruebas, claro. Al mismo tiempo, las pruebas deben ayudarla a entender el código principal, y las pruebas son mucho más fáciles de entender que el código funcional de todos modos (a menos que sean pruebas malas). Y las pruebas ayudan a mostrar lo que cambió en el comportamiento del código anterior. Incluso si el código original es incorrecto, y la prueba prueba ese comportamiento incorrecto, sigue siendo una ventaja.

Sin embargo, esto requiere que las pruebas estén documentadas como pruebas de comportamiento preexistente, no como una especificación.

Algunas reflexiones sobre el punto 3 también: además del hecho de que el "gran cambio" rara vez sucede realmente, también hay otra cosa: en realidad no es más fácil. Para ser más fáciles, varias condiciones tendrían que aplicarse:

  • El antipatrón a ser refaccionado debe ser encontrado fácilmente. ¿Todos sus singletons se llaman XYZSingleton ? ¿Su captador de instancias siempre se llama getInstance() ? ¿Y cómo encuentras tus jerarquías demasiado profundas? ¿Cómo buscas tus objetos de dios? Estos requieren un análisis de métricas de código y luego inspeccionar manualmente las métricas. O simplemente tropieza con ellos mientras trabaja, como lo hizo.
  • La refactorización debe ser mecánica. En la mayoría de los casos, la parte difícil de la refactorización es entender el código existente lo suficientemente bien como para saber cómo cambiarlo. Singletons otra vez: si el Singleton se ha ido, ¿cómo se obtiene la información requerida para sus usuarios? A menudo significa comprender el gráfico de llamadas local para que sepa dónde obtener la información. Ahora, ¿qué es más fácil: buscar los diez singletons en su aplicación, comprender los usos de cada uno (lo que lleva a la necesidad de entender el 60% de la base de código) y eliminarlos? ¿O tomar el código que ya entiendes (porque estás trabajando en ello ahora mismo) y copiar los singletons que se están utilizando allí? Si la refactorización no es tan mecánica que requiere poco o ningún conocimiento del código circundante, no hay uso en agruparlo.
  • La refactorización necesita ser automatizada. Esto es algo basado en la opinión, pero aquí va. Un poco de refactorización es divertido y satisfactorio. Mucha refactorización es tediosa y aburrida. Dejar el código en el que acabas de trabajar en un mejor estado te da una sensación agradable y cálida, antes de pasar a cosas más interesantes. Tratar de refactorizar una base de código completa lo dejará frustrado y enojado con los programadores idiotas que lo escribieron. Si desea realizar una gran refactorización swoop, debe ser automatizada en gran medida para minimizar la frustración. Esto es, en cierto modo, una combinación de los dos primeros puntos: solo puede automatizar la refactorización si puede automatizar la búsqueda del código incorrecto (es decir, se encuentra fácilmente) y automatizar el cambio (es decir, mecánico).
  • La mejora gradual hace un mejor caso de negocio. La gran refactorización swoop es increíblemente disruptiva. Si refactorizas un fragmento de código, invariablemente te metes en conflictos de fusión con otras personas que trabajan en él, porque simplemente divides el método que estaban cambiando en cinco partes. Cuando refactorizas un código de tamaño razonable, obtienes conflictos con algunas personas (1-2 al dividir la megafunción de 600 líneas, 2-4 al separar el objeto divino, 5 al arrancar el singleton de un módulo ), pero habrías tenido esos conflictos de todos modos debido a tus ediciones principales. Cuando haces una refactorización de todo el código, estás en conflicto con todos . Sin mencionar que enlaza a unos pocos desarrolladores durante días. La mejora gradual hace que cada modificación del código tarde un poco más. Esto lo hace más predecible, y no hay un período de tiempo visible en el que no suceda nada, excepto la limpieza.
respondido por el Sebastian Redl 06.02.2014 - 11:47
12

Hay una cultura en algunas empresas en las que son reticentes a permitir que los desarrolladores en cualquier momento realicen un código que no ofrezca un valor adicional, por ejemplo. nueva funcionalidad.

Probablemente estoy predicando a los conversos aquí, pero eso es claramente una economía falsa. El código limpio y conciso beneficia a los desarrolladores posteriores. Es solo que el reembolso no es inmediatamente evidente.

Me suscribo personalmente a Principio de Boy Scout pero otros (como has visto) no lo hacen.

Dicho esto, el software sufre de entropía y genera deuda técnica. Los desarrolladores anteriores con poco tiempo (o quizás solo perezosos o inexpertos) pueden haber implementado soluciones de buggy por debajo de las óptimas sobre otras bien diseñadas. Si bien puede parecer conveniente refactorizar esto, se arriesga a introducir nuevos errores en el código de trabajo (para los usuarios).

Algunos cambios son de menor riesgo que otros. Por ejemplo, donde trabajo, suele haber una gran cantidad de códigos duplicados que se pueden agrupar de forma segura en una subrutina con un impacto mínimo.

En última instancia, debe emitir un juicio acerca de lo lejos que lleva la refactorización, pero es innegable que agregar pruebas automatizadas si no existen ya.

    
respondido por el Robbie Dee 06.02.2014 - 10:17
4

En mi experiencia, una prueba de caracterización de algún tipo funciona bien. Le brinda una cobertura de prueba amplia pero no muy específica de manera relativamente rápida, pero puede ser difícil de implementar para las aplicaciones GUI.

Luego, escribiría pruebas unitarias para las piezas que desea cambiar y lo haré cada vez que quiera hacer un cambio, lo que aumentará la cobertura de su prueba unitaria a lo largo del tiempo.

Este enfoque le da una buena idea si los cambios están afectando otras partes del sistema y le permite ponerse en una posición para hacer los cambios necesarios antes.

    
respondido por el jamesj 07.02.2014 - 11:17
3

Re: "El código original podría no funcionar correctamente":

Las pruebas no están escritas en piedra. Se pueden cambiar. Y si probó una característica que estaba equivocada, debería ser fácil reescribir la prueba más correctamente. Después de todo, solo el resultado esperado de la función probada debería haber cambiado.

    
respondido por el rem 06.02.2014 - 10:15
3

Bueno, sí. Respondiendo como ingeniero de pruebas de software. En primer lugar, debes probar todo lo que haces de todos modos. Porque si no lo haces, no sabes si funciona o no. Esto puede parecer obvio para nosotros, pero tengo colegas que lo ven de manera diferente. Incluso si su proyecto es pequeño y es posible que nunca se entregue, debe mirar al usuario a la cara y decir que sabe que funciona porque lo probó.

El código no trivial siempre contiene errores (citando a un individuo de la universidad; y si no hay errores, es trivial) y nuestro trabajo es encontrarlos antes de que lo haga el cliente. El código heredado tiene errores heredados. Si el código original no funciona como debería, quieres saberlo, créeme. Los errores están bien si los conoces, no tengas miedo de encontrarlos, para eso están las notas de lanzamiento.

Si recuerdo correctamente, el libro de Refactorización dice que hay que probar constantemente de todos modos, así que es parte del proceso.

    
respondido por el RedSonja 06.02.2014 - 16:37
3

Haga la cobertura de prueba automatizada.

Tenga cuidado con las ilusiones, tanto las suyas como las de sus clientes y jefes. Por mucho que me gustaría creer que mis cambios serán correctos la primera vez y que solo tendré que realizar una prueba, aprendí a tratar ese tipo de pensamiento de la misma manera que a los correos electrónicos estafadores de Nigeria. Bueno, en su mayoría; Nunca he ido a recibir un correo electrónico fraudulento, pero recientemente (cuando me gritaron) dejé de usar las mejores prácticas. Fue una experiencia dolorosa que arrastró (costosa) una y otra vez. ¡Nunca más!

Tengo una cita favorita del cómic web de Freefall: "¿Alguna vez ha trabajado en un campo complejo en el que el supervisor solo tiene una idea aproximada de los detalles técnicos? ... Entonces, conoce la forma más segura de hacer que su supervisor Fallar es seguir cada una de sus órdenes sin cuestionarlo ".

Probablemente sea apropiado limitar la cantidad de tiempo que invierte.

    
respondido por el Technophile 07.02.2014 - 07:46
1

Si está lidiando con grandes cantidades de código heredado que no está actualmente bajo prueba, obtener la cobertura de prueba ahora en lugar de esperar una gran reescritura hipotética en el futuro es la decisión correcta. Comenzar escribiendo pruebas unitarias no lo es.

Sin realizar pruebas automáticas, después de realizar cualquier cambio en el código, debe realizar algunas pruebas manuales de principio a fin de la aplicación para asegurarse de que funciona. Comience escribiendo pruebas de integración de alto nivel para reemplazar eso. Si su aplicación lee los archivos, los valida, procesa los datos de alguna manera y muestra los resultados que desea que las pruebas capturen todo eso.

Lo ideal es que tenga datos de un plan de prueba manual o que pueda obtener una muestra de datos de producción reales para usar. Si no, ya que la aplicación está en producción, en la mayoría de los casos está haciendo lo que debería, así que invente datos que lleguen a todos los puntos altos y suponga que la salida es correcta por el momento. No es peor que tomar una pequeña función, asumiendo que está haciendo lo que se llama o cualquier comentario sugiere que debería estar haciendo, y escribiendo pruebas asumiendo que está funcionando correctamente.

IntegrationTestCase1()
{
    var input = ReadDataFile("path\to\test\data\case1in.ext");
    bool validInput = ValidateData(input);
    Assert.IsTrue(validInput);

    var processedData = ProcessData(input);
    Assert.AreEqual(0, processedData.Errors.Count);

    bool writeError = WriteFile(processedData, "temp\file.ext");
    Assert.IsFalse(writeError);

    bool filesAreEqual = CompareFiles("temp\file.ext", "path\to\test\data\case1out.ext");
    Assert.IsTrue(filesAreEqual);
}

Una vez que haya escrito lo suficiente de estas pruebas de alto nivel para capturar el funcionamiento normal de las aplicaciones y los casos de error más comunes, la cantidad de tiempo que necesitará para golpear el teclado para intentar detectar errores del código haciendo algo. aparte de lo que pensabas que se suponía que iba a hacer, se reducirá significativamente, lo que hará que la refactorización futura (o incluso una gran reescritura) sea mucho más fácil.

Como puede ampliar la cobertura de pruebas unitarias, puede reducir o incluso retirar la mayoría de las pruebas de integración. Si su aplicación está leyendo o escribiendo archivos o accediendo a una base de datos, probar esas partes de forma aislada y burlarse de ellas o comenzar sus pruebas creando las estructuras de datos leídas desde el archivo / base de datos es un lugar obvio para comenzar. En realidad, crear esa infraestructura de prueba llevará mucho más tiempo que escribir un conjunto de pruebas rápidas y sucias; y cada vez que ejecute un conjunto de pruebas de integración de 2 minutos en lugar de pasar 30 minutos probando manualmente una fracción de lo que cubrieron las pruebas de integración, ya está obteniendo una gran ganancia.

    
respondido por el Dan Neely 07.06.2014 - 19:26

Lea otras preguntas en las etiquetas