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


72

Por lo general, trato de seguir los consejos del libro Working Effectively with Legacy Cod e . Rompo dependencias, muevo partes del código a @VisibleForTesting public staticmétodos y a nuevas clases para hacer que el código (o al menos una parte de él) sea comprobable. Y escribo pruebas para asegurarme de no romper nada cuando estoy modificando o agregando 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 futuras correcciones y modificaciones sean más difíciles, ya que los desarrolladores también deben comprender y modificar las pruebas.
  • Si es código GUI con alguna lógica (~ 12 líneas, 2-3 bloque if / else, por ejemplo), una prueba no merece la pena ya que el código es demasiado trivial para empezar.
  • También podrían existir patrones incorrectos similares en otras partes de la base de código (que aún no he visto, 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 refactorizar por completo? ¿Hay alguna desventaja en esto que debería considerar?


29
Parece que su colega solo presenta excusas porque no trabaja de esa manera. Las personas a veces se comportan así porque son demasiado tenaces para cambiar su forma de hacer las cosas.
Doc Brown

3
lo que debería clasificarse como un error puede ser utilizado por otras partes del código convirtiéndolo en una característica
ratchet freak

1
El único buen argumento en contra de lo que puedo pensar es que su refactorización en sí misma podría introducir nuevos errores si ha leído / copiado algo incorrectamente. Por esa razón, soy libre de refactorizar y corregir el contenido de mi corazón en la versión actualmente en desarrollo, pero cualquier corrección en versiones anteriores enfrenta un obstáculo mucho mayor y puede que no se apruebe si son "solo" limpieza cosmética / estructural desde se considera que el riesgo excede la ganancia potencial. Conozca su cultura local, no solo la idea de un orquero, y tenga razones EXTREMADAMENTE fuertes antes de hacer cualquier otra cosa.
keshlam

66
El primer punto es algo gracioso: "No lo pruebes, podría tener errores". Bueno, ¿sí? Entonces es bueno saber que, o queremos arreglar eso o no queremos que nadie cambie el comportamiento real a lo que dicen algunas especificaciones de diseño. De cualquier manera, probar (y ejecutar las pruebas en un sistema automatizado) es beneficioso.
Christopher Creutzig

3
Con demasiada frecuencia, la "gran refactorización" que está por suceder y que curará todos los males es un mito, inventado por aquellos que simplemente quieren empujar cosas que consideran aburridas (pruebas de escritura) en un futuro lejano. ¡Y si alguna vez se vuelve real, lamentarán seriamente haber dejado que se vuelva tan grande!
Julia Hayward

Respuestas:


100

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

  1. Claro, el código existente podría estar equivocado. También podría ser correcto. Dado que la aplicación en su conjunto parece tener valor para usted (de lo contrario, simplemente la descartaría), en ausencia de información más específica, debe suponer que es predominantemente correcta. "Escribir pruebas hace las cosas más difíciles porque hay más código involucrado en general" es una actitud simplista y muy equivocada.
  2. De todos modos, gaste 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 formato de valor a menudo no son la primera prioridad. Pero no probar algo porque "es simple" también es una actitud muy equivocada. Prácticamente todos los errores graves se cometen porque las personas pensaron que entendían algo mejor de lo que realmente entendieron.
  3. "Lo haremos todo de una sola vez en el futuro" es un buen pensamiento. Por lo general, el gran golpe se mantiene firmemente en el futuro, mientras que en el presente no sucede nada. Yo, estoy firmemente convencido de la convicción de "lento y constante gana la carrera".

23
+1 para "Prácticamente todos los errores graves se cometen porque la gente pensó que entendía algo mejor de lo que realmente entendió".
rem

Punto 1: con BDD , las pruebas se documentan por sí mismas ...
Robbie Dee

2
Como @ guillaume31 señala, parte del valor de escribir pruebas es demostrar cómo funciona realmente el código, que puede o no estar de acuerdo con las especificaciones. Pero podría ser la especificación "incorrecta": las necesidades comerciales pueden haber cambiado y el código refleja los nuevos requisitos, pero la especificación no lo hace. Simplemente asumir que el código es "incorrecto" es demasiado simplista (ver punto 1). Y nuevamente, las pruebas le dirán qué hace realmente el código, no lo que alguien piensa / dice que hace (vea el punto 2).
David

incluso si haces un solo golpe, debes entender el código. Las pruebas lo ayudarán a detectar comportamientos inesperados, incluso si no refactoriza sino que reescribe (y si refactoriza, ayudan a garantizar que su refactorización no rompa el comportamiento heredado, o solo donde desea que se rompa). Siéntase libre de incorporar o no, como lo desee.
Frank Hopkins

50

Algunas reflexiones:

Cuando refactoriza el código heredado, no importa si algunas de las pruebas que escribe contradicen las especificaciones ideales. Lo que importa es que prueben 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 detecta un error evidente, no se perderá. Siempre puede escribir una prueba de regresión y deshabilitarla temporalmente, o insertar una tarea de corrección de errores en su cartera de pedidos 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 una buena opción para la refactorización de estilo " Trabajando efectivamente ... ". Sin embargo, esto no significa que no deba extraer un comportamiento que no tiene nada que ver en la capa GUI y probar el código extraído. Y "12 líneas, 2-3 si / sino 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 propones metas pequeñas y precisas, existe un alto riesgo de que te embarques en un reproceso interminable y agotador donde nunca terminarás de pie. Cuanto mayor sea el cambio, más te arriesgas a romper algo y más problemas tendrás para descubrir dónde fallaste.

Mejorar progresivamente las cosas con pequeñas refactorizaciones ad hoc no está "socavando las posibilidades futuras", las está habilitando, solidificando el terreno pantanoso donde se encuentra su aplicación. Definitivamente deberías hacerlo.


55
+1 para "las pruebas que escribe prueba el comportamiento actual del programa "
David

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 facilitar la refactorización posterior, ya que te ayudará a descubrir cuándo has roto algo accidentalmente. Debes probar las partes más importantes primero.


Tristemente cierto. Tenemos un par de errores obvios que se manifiestan en casos extremos que no podemos solucionar porque nuestro cliente prefiere la coherencia sobre la corrección. (Se deben a que el código de recopilación de datos permite cosas que el código de informe no tiene en cuenta, como dejar un campo en una serie de campos en blanco)
Izkata

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, ampliar, depurar) el código, debe entenderlo. Debe 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 deberían ayudarla a comprender 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 malas pruebas). Y las pruebas ayudan a mostrar qué cambió en el comportamiento del antiguo código. 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: además del hecho de que el "gran golpe" rara vez ocurre, también hay otra cosa: en realidad no es más fácil. Para ser más fácil, tendrían que aplicarse varias condiciones:

  • El antipatrón a ser refactorizado necesita ser encontrado fácilmente. ¿Se nombran todos tus singletons XYZSingleton? ¿Se llama siempre a su instancia getter getInstance()? ¿Y cómo encuentras tus jerarquías demasiado profundas? ¿Cómo buscas tus objetos divinos? Estos requieren un análisis de métricas de código y luego inspeccionar manualmente las métricas. O simplemente te tropiezas con ellos mientras trabajas, como lo hiciste.
  • La refactorización debe ser mecánica. En la mayoría de los casos, la parte difícil de la refactorización es comprender el código existente lo suficientemente bien como para saber cómo cambiarlo. Singletons nuevamente: si el singleton se ha ido, ¿cómo se obtiene la información requerida para sus usuarios? A menudo significa entender el callgraph local para que sepa de 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 comprender el 60% de la base de código) y eliminarlos? ¿O tomando el código que ya entiendes (porque estás trabajando en ello ahora) y extrayendo los singletons que se usan por ahí? Si la refactorización no es tan mecánica que requiere poco o ningún conocimiento del código circundante, no tiene sentido agruparlo.
  • La refactorización necesita ser automatizada. Esto está algo basado en la opinión, pero aquí va. Un poco de refactorización es divertido y satisfactorio. Muchas refactorizaciones son tediosas y aburridas. Dejar el fragmento de código en el que acaba de trabajar en un mejor estado le brinda una sensación agradable y cálida, antes de pasar a cosas más interesantes. Intentar refactorizar una base de código completa te dejará frustrado y enojado con los programadores idiotas que lo escribieron. Si desea realizar una gran refactorización en picado, entonces debe automatizarse 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, fácil de encontrar) y automatizar su cambio (es decir, mecánico).
  • La mejora gradual hace un mejor caso de negocios. La gran refactorización de swoop es increíblemente disruptiva. Si refactoriza un fragmento de código, invariablemente entra en conflicto de fusión con otras personas que trabajan en él, porque simplemente divide el método que estaban cambiando en cinco partes. Cuando refactoriza un fragmento de código de tamaño razonable, tiene conflictos con algunas personas (1-2 al dividir la megafunción de 600 líneas, 2-4 al dividir el objeto dios, 5 al extraer el singleton de un módulo ), pero habría tenido esos conflictos de todos modos debido a sus ediciones principales. Cuando realiza una refactorización de toda la base de código, entra en conflicto con todos. Sin mencionar que vincula a algunos 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 tan visible cuando no sucede nada excepto la limpieza.

12

Hay una cultura en algunas compañías donde son reticentes a permitir a los desarrolladores en cualquier momento mejorar el código que no entrega directamente valor adicional, por ejemplo, nueva funcionalidad.

Probablemente estoy predicando a los convertidos aquí, pero eso es claramente una economía falsa. El código limpio y conciso beneficia a los desarrolladores posteriores. Es solo que la recuperación no es evidente de inmediato.

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

Dicho esto, el software sufre de entropía y acumula deuda técnica. Los desarrolladores anteriores con poco tiempo (o tal vez solo perezosos o inexpertos) pueden haber implementado soluciones de errores subóptimas sobre soluciones bien diseñadas. Si bien puede parecer deseable refactorizarlos, corre el riesgo de introducir nuevos errores en el código de trabajo (para los usuarios de todos modos).

Algunos cambios son de menor riesgo que otros. Por ejemplo, donde trabajo tiende a haber una gran cantidad de código duplicado que se puede cultivar de forma segura en una subrutina con un impacto mínimo.

En última instancia, debe tomar una decisión de juicio sobre cuán lejos lleva la refactorización, pero hay un valor innegable en agregar pruebas automatizadas si aún no existen.


2
Estoy totalmente de acuerdo en principio, pero en muchas empresas se trata de tiempo y dinero. Si la parte de "ordenar" solo toma unos minutos, entonces está bien, pero una vez que la estimación del orden comienza a aumentar (para alguna definición de grande), usted, la persona que codifica debe delegar esa decisión a su jefe o gerente de proyecto. No es su lugar decidir el valor de ese tiempo dedicado. Trabajar en la corrección de errores X, o la nueva función Y podría tener un valor mucho mayor para el proyecto / empresa / cliente.
ozz

2
También es posible que no se dé cuenta de problemas más importantes, como que el proyecto se descarte dentro de 6 meses, o simplemente que la empresa valora más su tiempo (por ejemplo, hace algo que considera más importante y alguien más puede hacer el trabajo de refactorización). El trabajo de refactorización también puede afectar las pruebas. ¿Una refactorización grande desencadenará una regresión de prueba completa? ¿La empresa tiene recursos que puede implementar para hacer esto?
ozz

Sí, como ha mencionado, existen innumerables razones por las cuales la cirugía de código mayor puede o no ser una buena idea: otras prioridades de desarrollo, la vida útil del software, recursos de prueba, experiencia del desarrollador, acoplamiento, ciclo de lanzamiento, familiaridad con el código base, documentación, importancia crítica de la misión, cultura de la empresa, etc., etc. Es una decisión judicial
Robbie Dee

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 con relativa rapidez, pero puede ser difícil de implementar para aplicaciones GUI.

Luego escribiría pruebas unitarias para las partes que desea cambiar y lo haría cada vez que quiera hacer un cambio, aumentando así la cobertura de la prueba unitaria con el tiempo.

Este enfoque le da una buena idea si los cambios están afectando a otras partes del sistema y le permite estar en condiciones de realizar los cambios necesarios antes.


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 mal, debería ser fácil reescribir la prueba más correctamente. Solo el resultado esperado de la función probada debería haber cambiado, después de todo.


1
En mi opinión, las pruebas individuales deben escribirse en piedra, al menos hasta que la característica que están probando esté muerta y desaparecida. Son los que verifican el comportamiento del sistema existente y ayudan a asegurar a los mantenedores que sus cambios no romperán el código heredado que ya puede depender de ese comportamiento. Cambie las pruebas para una función en vivo, y está eliminando esas garantías.
cHao

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 puede 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 chico de la universidad; y si no hay errores, es trivial) y nuestro trabajo es encontrarlos antes que 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 conoce, no tenga miedo de encontrarlos, para eso están las notas de la versión.

Si recuerdo correctamente, el libro de Refactorización dice que debe probar constantemente de todos modos, por lo que es parte del proceso.


3

Haga la cobertura de prueba automatizada.

Tenga cuidado con las ilusiones, tanto propias como de sus clientes y jefes. Por mucho que me gustaría creer que mis cambios serán correctos la primera vez y solo tendré que probarlo una vez, he aprendido a tratar ese tipo de pensamiento de la misma manera que trato los correos electrónicos de estafa nigerianos. Bueno, sobre todo; Nunca he ido por un correo electrónico fraudulento, pero recientemente (cuando me gritaron) me di por vencido al no usar las mejores prácticas. Fue una experiencia dolorosa que se prolongó (costosamente) 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 donde el supervisor solo tiene una idea aproximada de los detalles técnicos? ... Entonces sabe que la forma más segura de hacer que su supervisor falle es siga cada una de sus órdenes sin dudar ".

Probablemente sea apropiado limitar la cantidad de tiempo que invierte.


1

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

Sin pruebas automáticas, después de realizar cualquier cambio en el código, debe realizar algunas pruebas manuales de extremo a extremo 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 archivos, los valida, procesa los datos de alguna manera y muestra los resultados que desea pruebas que capturan todo eso.

Idealmente, tendrá datos de un plan de prueba manual o podrá obtener una muestra de datos de producción reales para usar. Si no es así, dado que la aplicación está en producción, en la mayoría de los casos está haciendo lo que debería ser, así que solo invente datos que lleguen a todos los puntos altos y suponga que la salida es correcta por ahora. No es peor que tomar una función pequeña, suponiendo que está haciendo lo que su nombre o cualquier comentario sugiere que debería estar haciendo, y escribir pruebas asumiendo que funciona 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 tenga suficientes de estas pruebas de alto nivel escritas 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 tratar de detectar errores del código haciendo algo diferente a lo que pensaste que se suponía que debía hacerlo, se reduciría significativamente, lo que facilitaría mucho la refactorización futura (o incluso una gran reescritura).

Como puede ampliar la cobertura de pruebas unitarias, puede reducir o incluso retirar la mayoría de las pruebas de integración. Si los archivos de lectura / escritura de su aplicación o el acceso a una base de datos, probar estas partes de forma aislada y burlarse de ellas o hacer que comiencen sus pruebas creando las estructuras de datos leídas del 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 ejecuta un conjunto de pruebas de integración de 2 minutos en lugar de pasar 30 minutos probando manualmente una fracción de lo que cubren las pruebas de integración, ya está obteniendo una gran victoria.

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.