unit testing - tipos - Pruebas unitarias: ¿el beneficio de las pruebas unitarias con cambios en los contratos?
tipos de pruebas unitarias (9)
Recientemente tuve una discusión interesante con un colega sobre pruebas unitarias. Estuvimos discutiendo cuando el mantenimiento de pruebas unitarias se volvió menos productivo, cuando cambian sus contratos.
Quizás alguien pueda explicarme cómo abordar este problema. Déjame elaborar:
Entonces digamos que hay una clase que hace algunos cálculos ingeniosos. El contrato dice que debe calcular un número, o devuelve -1 cuando falla por alguna razón.
Tengo pruebas de contrato que lo prueban. Y en todas mis otras pruebas resisto esta cosita de calculadora ingeniosa.
Así que ahora cambio el contrato, siempre que no pueda calcular arrojará una CannotCalculateException.
Mis pruebas de contrato fallarán, y las corregiré en consecuencia. Pero, todos mis objetos simulados / trozados seguirán usando las viejas reglas de contrato. ¡Estas pruebas tendrán éxito, mientras que no deberían!
La pregunta que surge es que con esta fe en las pruebas unitarias, qué tanta confianza se puede depositar en dichos cambios ... Las pruebas unitarias tienen éxito, pero se producirán errores al probar la aplicación. Las pruebas que usan esta calculadora necesitarán ser reparadas, lo que le puede costar tiempo e incluso puede ser refutado / burlado muchas veces ...
¿Cómo piensas sobre este caso? Nunca pensé en eso por completo. En mi opinión, estos cambios en las pruebas unitarias serían aceptables. Si no uso pruebas unitarias, también vería que surgen errores en la fase de prueba (por probadores). Sin embargo, no estoy lo suficientemente seguro como para señalar lo que costará más tiempo (o menos).
¿Alguna idea?
Alguien hizo la misma pregunta en el Grupo de Google sobre el libro "Crecimiento de software orientado a objetos: guiado por pruebas". El hilo es una prueba de la unidad simulacro / suposiciones suposiciones se pudre .
Aquí está la respuesta de JB Rainsberger (él es el autor de " JUnit Recipes " de Manning).
El primer problema que plantea es el llamado problema de "prueba frágil". Usted hace un cambio en su aplicación, y cientos de pruebas se rompen debido a ese cambio. Cuando esto sucede, tienes un problema de diseño . Sus pruebas han sido diseñadas para ser frágiles. No han sido suficientemente desacoplados del código de producción. La solución es (como lo es en todos los problemas de software como este) encontrar una abstracción que desacople las pruebas del código de producción de tal manera que la volatilidad del código de producción se oculte de las pruebas.
Algunas cosas simples que causan este tipo de fragilidad son:
- Prueba de cadenas que se muestran. Tales cadenas son volátiles porque su gramática u ortografía pueden cambiar según el capricho de un analista.
- Prueba de valores discretos (por ejemplo, 3) que deberían codificarse detrás de una abstracción (por ejemplo, FULL_TIME).
- Llamar a la misma API de muchas pruebas. Debería envolver la llamada API en una función de prueba para que cuando la API cambie pueda hacer el cambio en un lugar.
El diseño de prueba es un tema importante que los principiantes de TDD a menudo descuidan. Esto a menudo resulta en pruebas frágiles, que luego llevan a los principiantes a rechazar TDD como "improductivo".
El segundo problema que planteaste fueron falsos positivos. Has usado tantos simulacros que ninguna de tus pruebas realmente prueba el sistema integrado. Si bien probar unidades independientes es algo bueno, también es importante probar integraciones parciales y completas del sistema. TDD no se trata solo de pruebas unitarias.
Las pruebas se deben organizar de la siguiente manera:
- Las pruebas unitarias proporcionan una cobertura de código cercana al 100%. Ellos prueban unidades independientes. Están escritos por programadores que usan el lenguaje de programación del sistema.
- Las pruebas de componentes cubren ~ 50% del sistema. Están escritos por analistas de negocios y QA. Están escritos en un lenguaje como FitNesse, Selenium, Cucumber, etc. Probaron componentes completos, no unidades individuales. Prueban principalmente casos de ruta felices y algunos casos de rutas infelices altamente visibles.
- Las pruebas de integración cubren ~ 20% del sistema. Probar pequeños conjuntos de componentes en lugar de todo el sistema. También escrito en FitNesse / Selenium / Cucumber etc. Escrito por arquitectos.
- Las pruebas del sistema cubren ~ 10% del sistema. Prueban todo el sistema integrado juntos. Nuevamente están escritos en FitNesse / Selenium / Cucumber etc. Escrito por arquitectos.
- Pruebas manuales exploratorias. (Ver James Bach) Estas pruebas son manuales pero no con guión. Emplean el ingenio humano y la creatividad.
Las pruebas unitarias seguramente no pueden detectar todos los errores, incluso en el caso ideal de una cobertura de código / funcionalidad del 100%. Creo que no es de esperar.
Si el contrato probado cambia, I (el desarrollador) debería usar mis cerebros para actualizar todo el código (¡incluido el código de prueba!) En consecuencia. Si no actualizo algunos simulacros que, por lo tanto, todavía producen el comportamiento anterior, es culpa mía, no de las pruebas unitarias.
Es similar al caso cuando corrijo un error y produzco una prueba de unidad, pero no puedo analizar (y probar) todos los casos similares, algunos de los cuales más tarde también resultan defectuosos.
Entonces sí, las pruebas unitarias necesitan mantenimiento tan bien como el código de producción mismo. Sin mantenimiento, se pudren y se pudren.
Lo veo de esta manera, cuando su contrato cambie, debe tratarlo como un nuevo contrato. Por lo tanto, debe crear un nuevo conjunto de pruebas UNIT para este "nuevo" contrato. El hecho de que tiene un conjunto existente de casos de prueba es además del punto.
Opino la opinión del tío Bob de que el problema está en el diseño. Además, volvería un paso atrás y verificaría el diseño de tus contratos .
En breve
en lugar de decir "return -1 for x == 0" o "throw CannotCalculateException for x == y", niftyCalcuatorThingy(x,y)
con la precondición x!=y && x!=0
en situaciones apropiadas (ver a continuación) . Por lo tanto, sus talones pueden comportarse de manera arbitraria para estos casos, las pruebas unitarias deben reflejar eso y usted tiene una modularidad máxima, es decir, la libertad de cambiar arbitrariamente el comportamiento de su sistema bajo prueba para todos los casos no especificados, sin la necesidad de cambiar contratos o pruebas.
Subespecificación donde sea apropiado
Puede diferenciar su afirmación "-1 cuando falla por algún motivo" de acuerdo con los siguientes criterios: ¿Es el escenario?
- un comportamiento excepcional que la implementación puede verificar?
- dentro del dominio / responsabilidad del método?
- una excepción que la persona que llama (o alguien anterior en la pila de llamadas) puede recuperarse / manejar de alguna otra manera?
Si, y solo si 1) a 3) se mantienen, especifique el escenario en el contrato (por ejemplo, que EmptyStackException
se lanza al llamar a pop () en una pila vacía).
Sin 1), la implementación no puede garantizar un comportamiento específico en el caso excepcional. Por ejemplo, Object.equals () no especifica ningún comportamiento cuando la condición de reflexividad, simetría, transitividad y consistencia no se cumple.
Sin 2), SingleResponsibilityPrinciple no se cumple, la modularidad se rompe y los usuarios / lectores del código se confunden. Por ejemplo, Graph transform(Graph original)
no debería especificar que MissingResourceException
podría lanzarse porque, en el fondo, se realiza una clonación mediante serialización.
Sin 3), la persona que llama no puede hacer uso del comportamiento especificado (cierto valor de retorno / excepción). Por ejemplo, si la JVM lanza un UnknownError.
Pros y contras
Si especificas casos en los que 1), 2) o 3) no se cumplen, obtienes algunas dificultades:
- un objetivo principal de un contrato (diseño por) es la modularidad. Esto es más factible si realmente se separan las responsabilidades: cuando no se cumple la precondición (la responsabilidad de la persona que llama), no especificar el comportamiento de la implementación conduce a una modularidad máxima, como muestra su ejemplo.
- no tiene libertad para cambiar en el futuro, ni siquiera para una funcionalidad más general del método que arroja excepciones en menos casos
- comportamientos excepcionales pueden llegar a ser bastante complejos, por lo que los contratos que los cubren se vuelven complejos, propensos a errores y difíciles de entender. Por ejemplo: ¿están cubiertas todas las situaciones? ¿Qué comportamiento es correcto si se cumplen múltiples precondiciones excepcionales?
La desventaja de la subespecificación es que la robustez (prueba), es decir, la capacidad de la implementación de reaccionar de manera apropiada a condiciones anormales, es más difícil.
Como compromiso, me gusta usar el siguiente esquema de contrato cuando sea posible:
<(Semi-) formal PRE- y POST-condición, incluido el comportamiento excepcional donde 1) a 3) mantener>
Si PRE no se cumple, la implementación actual arroja el RTE A, B o C.
Tengo experiencias similares con pruebas unitarias: cuando cambias el contrato de una clase, a menudo necesitas cambiar muchas otras pruebas (lo que en realidad pasará en muchos casos, lo que lo hace aún más difícil). Es por eso que siempre uso pruebas de nivel más alto también:
- Pruebas de aceptación: prueba un par de clases o más. Estas pruebas suelen estar alineadas con las tiendas de los usuarios que deben implementarse, por lo que prueba que la historia del usuario "funciona". Estos no necesitan conectarse a una base de datos u otros sistemas externos, pero pueden.
- Pruebas de integración, principalmente para verificar la conectividad del sistema externo, etc.
- Pruebas completas de extremo a extremo: prueba todo el sistema
Tenga en cuenta que incluso si tiene una cobertura de prueba del 100% de la unidad, ¡ni siquiera está garantizado que su aplicación comience! Es por eso que necesitas pruebas de nivel superior. Hay tantas capas diferentes de pruebas porque cuanto más bajo se prueba algo, más barato suele ser (en términos de desarrollo, mantenimiento de la infraestructura de prueba y tiempo de ejecución).
Como nota al margen: debido al problema que mencionaste al usar pruebas unitarias, te enseña a mantener tus componentes lo más desacoplados posibles y sus contratos lo más pequeños posible, ¡lo que definitivamente es una buena práctica!
Un principio en el que confío es eliminar la duplicación. En general, no tengo muchas falsificaciones o burlas diferentes para implementar este contrato (utilizo más falsificaciones que simulacros en parte por este motivo). Cuando cambio el contrato, es natural inspeccionar cada implementación de ese contrato, código de producción o prueba. Me molesta cuando descubro que estoy haciendo este tipo de cambio, mis abstracciones deberían haber sido mejor pensadas, tal vez, etc., pero si los códigos de prueba son demasiado onerosos para cambiar la escala del contrato, entonces tengo que preguntarme si estos también se deben algunas refactorizaciones.
Una de las reglas para el código de pruebas unitarias (y el resto del código utilizado para las pruebas) es tratarlo de la misma manera que el código de producción, ni más ni menos, de todos modos.
Mi comprensión de esto es que (además de mantenerlo relevante, refactorizado, funcionando, etc., como el código de producción) también debe considerarse de la misma manera que la inversión / el costo.
Probablemente su estrategia de prueba debe incluir algo para abordar el problema que ha descrito en la publicación inicial, algo similar a lo que especifica qué código de prueba (incluyendo stubs / mocks) debe ser revisado (ejecutado, inspeccionado, modificado, reparado, etc.) cuando un diseñador cambia una función / método en el código de producción. Por lo tanto, el costo de cualquier cambio en el código de producción debe incluir el costo de hacerlo; de lo contrario, el código de prueba se convertirá en "ciudadano de tercera clase" y la confianza de los diseñadores en el conjunto de pruebas unitarias disminuirá ... Obviamente, el retorno de la inversión se encuentra en el momento del descubrimiento y solución de errores.
Es mejor tener que corregir la prueba unitaria que falla debido a cambios intencionados en el código que no tener pruebas para detectar los errores que eventualmente introducen estos cambios.
Cuando su base de código tiene una buena cobertura de prueba de unidad, puede encontrarse con muchas fallas de prueba de unidad que no se deben a errores en el código, sino a cambios intencionados en los contratos o la refactorización de código.
Sin embargo, esa cobertura de prueba unitaria también le dará confianza para refactorizar el código e implementar cualquier cambio en el contrato. Algunas pruebas fallarán y deberán corregirse, pero otras pruebas eventualmente fallarán debido a errores que usted introdujo con estos cambios.