¿Cómo evitar las pruebas de unidades frágiles?

24

Hemos escrito cerca de 3.000 pruebas: los datos han sido codificados, muy poca reutilización del código. Esta metodología ha comenzado a mordernos en el culo. A medida que cambia el sistema, nos encontramos pasando más tiempo arreglando pruebas rotas. Contamos con pruebas unitarias, integrales y funcionales.

Lo que estoy buscando es una forma definitiva de escribir pruebas manejables y mantenibles.

Marcos

Chuck Conway
fuente
Esto es mucho más adecuado para Programmers.StackExchange, OMI ...
iAbstract
BDD
Robbie Dee

Respuestas:

21

No pienses en ellos como "pruebas de unidades rotas", porque no lo son.

Son especificaciones que su programa ya no admite.

No piense en ello como "arreglar las pruebas", sino como "definir nuevos requisitos".

Las pruebas deben especificar su aplicación primero, no al revés.

No puede decir que tiene una implementación funcional hasta que sepa que funciona. No puedes decir que funciona hasta que lo pruebes.

Algunas otras notas que pueden guiarte:

  1. Las pruebas y las clases bajo prueba deben ser cortas y simples . Cada prueba solo debe verificar una pieza cohesiva de funcionalidad. Es decir, no le importan las cosas que otras pruebas ya verifican.
  2. Las pruebas y sus objetos deben estar acoplados de manera flexible, de manera que si cambia un objeto, solo está cambiando su gráfico de dependencia hacia abajo, y otros objetos que usan ese objeto no se ven afectados por él.
  3. Es posible que esté creando y probando cosas incorrectas . ¿Sus objetos están construidos para una fácil interfaz o fácil implementación? Si es el último caso, te encontrarás cambiando mucho código que usa la interfaz de la implementación anterior.
  4. En el mejor de los casos, adhiérase estrictamente al principio de responsabilidad única. En el peor de los casos, adhiérase al principio de segregación de interfaz. Ver Principios SÓLIDOS .
Ñame Marcovic
fuente
55
+1 paraDon't think of it as "fixing the tests", but as "defining new requirements".
StuperUser
2
+1 Las pruebas deben especificar su aplicación primero, no al revés
treecoder
11

Lo que usted describe en realidad puede no ser tan malo, sino un indicador de problemas más profundos que descubren sus pruebas

A medida que cambia el sistema, nos encontramos pasando más tiempo arreglando pruebas rotas. Contamos con pruebas unitarias, integrales y funcionales.

Si pudiera cambiar su código, y sus pruebas no se romperían, eso sería sospechoso para mí. La diferencia entre un cambio legítimo y un error es solo el hecho de que se solicita, y lo que se solicita es (se supone TDD) definido por sus pruebas.

Los datos han sido codificados.

Los datos codificados en las pruebas son, en mi opinión, algo bueno. Las pruebas funcionan como falsificaciones, no como pruebas. Si hay demasiado cálculo, sus pruebas pueden ser tautologías. Por ejemplo:

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

Cuanto mayor sea la abstracción, más se acercará al algoritmo y, por eso, más cerca de comparar la implementación aguda con sí mismo.

muy poca reutilización de código

La mejor reutilización de código en las pruebas es en mi opinión 'Verificaciones', como en jUnits assertThat, porque mantienen las pruebas simples. Además de eso, si las pruebas pueden ser refactorizadas para compartir código, el código real probado probablemente también lo sea , reduciendo así las pruebas a las que prueban la base refactorizada.

keppla
fuente
Me gustaría saber dónde no está de acuerdo el votante.
keppla
keppla: no soy el votante, pero en general, dependiendo de dónde me encuentre en el modelo, estoy a favor de probar la interacción de objetos sobre los datos de prueba en el nivel de la unidad. La prueba de datos funciona mejor a nivel de integración.
Ritch Melton
@keppla Tengo una clase que enruta un pedido a un canal diferente si sus artículos totales contienen ciertos artículos restringidos. Creo un pedido falso y lo relleno con 4 elementos, dos de los cuales son los restringidos. En la medida en que se agregan los elementos restringidos, esta prueba es única. Pero los pasos para crear un pedido falso y agregar dos elementos regulares son la misma configuración que otra prueba utiliza que prueba el flujo de trabajo de elementos no restringidos. En este caso, junto con los artículos si el pedido necesita tener la configuración de datos del cliente y la configuración de direcciones, etc., este no es un buen caso de reutilización de ayudantes de configuración. ¿Por qué solo afirmar la reutilización?
Asif Shiraz
6

También he tenido este problema. Mi enfoque mejorado ha sido el siguiente:

  1. No escriba pruebas unitarias a menos que sean la única buena manera de probar algo.

    Estoy completamente preparado para admitir que las pruebas unitarias tienen el menor costo de diagnóstico y tiempo de reparación. Esto los convierte en una herramienta valiosa. El problema es, con el obvio su-millaje-puede-variar, que las pruebas unitarias a menudo son demasiado pequeñas para merecer el costo de mantener la masa del código. Escribí un ejemplo en la parte inferior, eche un vistazo.

  2. Utilice aserciones siempre que sean equivalentes a la prueba unitaria para ese componente. Las aserciones tienen la buena propiedad de que siempre se verifican en cualquier compilación de depuración. Entonces, en lugar de probar las restricciones de clase "Empleado" en una unidad de pruebas separada, está probando efectivamente la clase Empleado a través de cada caso de prueba en el sistema. Las afirmaciones también tienen la buena propiedad de que no aumentan la masa del código tanto como las pruebas unitarias (que eventualmente requieren andamios / burlas / lo que sea).

    Antes de que alguien me mate: las compilaciones de producción no deberían fallar en las afirmaciones. En su lugar, deberían iniciar sesión en el nivel "Error".

    Como advertencia a alguien que aún no lo ha pensado, no afirme nada sobre la entrada del usuario o la red. Es un gran error ™.

    En mis últimas bases de código, he estado eliminando juiciosamente las pruebas unitarias siempre que veo una oportunidad obvia de afirmaciones. Esto ha reducido significativamente el costo de mantenimiento en general y me ha hecho una persona mucho más feliz.

  3. Prefiera las pruebas de sistema / integración, implementándolas para todos sus flujos primarios y experiencias de usuario. Los casos de esquina probablemente no necesiten estar aquí. Una prueba del sistema verifica el comportamiento al final del usuario ejecutando todos los componentes. Debido a eso, una prueba del sistema es necesariamente más lenta, así que escriba las que importen (ni más ni menos) y detectará los problemas más importantes. Las pruebas del sistema tienen gastos generales de mantenimiento muy bajos.

    Es clave recordar que, dado que está utilizando afirmaciones, cada prueba del sistema ejecutará un par de cientos de "pruebas unitarias" al mismo tiempo. También está bastante seguro de que los más importantes se ejecutan varias veces.

  4. Escriba API fuertes que puedan probarse funcionalmente. Las pruebas funcionales son incómodas y (seamos sinceros) algo sin sentido si su API hace que sea demasiado difícil verificar los componentes que funcionan por sí mismos. Un buen diseño de API a) simplifica los pasos de prueba yb) genera afirmaciones claras y valiosas.

    La prueba funcional es lo más difícil de hacer, especialmente cuando tienes componentes que se comunican de uno a muchos o (aún peor, oh dios) de muchos a muchos a través de las barreras del proceso. Cuantas más entradas y salidas se unan a un solo componente, más difícil será la prueba funcional, ya que debe aislar una de ellas para probar realmente su funcionalidad.


Sobre el tema de "no escribir pruebas unitarias", presentaré un ejemplo:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

El autor de esta prueba ha agregado siete líneas que no contribuyen en absoluto a la verificación del producto final. El usuario nunca debería ver que esto suceda, ya sea porque a) nadie debería pasar NULL allí (entonces escriba una afirmación, entonces) ob) el caso NULL debería causar un comportamiento diferente. Si el caso es (b), escriba una prueba que realmente verifique ese comportamiento.

Mi filosofía se ha convertido en que no debemos probar los artefactos de implementación. Solo debemos probar cualquier cosa que pueda considerarse una salida real. De lo contrario, no hay forma de evitar escribir el doble de la masa básica de código entre las pruebas unitarias (que fuerzan una implementación particular) y la implementación misma.

Es importante tener en cuenta, aquí, que hay buenos candidatos para las pruebas unitarias. De hecho, incluso hay varias situaciones en las que una prueba unitaria es el único medio adecuado para verificar algo y en el que es de gran valor escribir y mantener esas pruebas. De la parte superior de mi cabeza, esta lista incluye algoritmos no triviales, contenedores de datos expuestos en una API y código altamente optimizado que parece "complicado" (también conocido como "el próximo tipo probablemente lo arruinará").

Mi consejo específico para usted, entonces: Comience a eliminar juiciosamente las pruebas unitarias a medida que se rompen, preguntándose: "¿Es esto una salida o estoy desperdiciando código?" Probablemente logre reducir la cantidad de cosas que le hacen perder el tiempo.

Andres Jaan Tack
fuente
3
Prefiero pruebas de sistema / integración: esto es increíblemente malo. Su sistema llega al punto en el que usa estas pruebas (¡lento!) Para probar las cosas que se pueden atrapar rápidamente a nivel de unidad y les lleva horas ejecutarlas porque tiene muchas pruebas similares y lentas.
Ritch Melton
1
@RitchMelton Completamente separado de la discusión, parece que necesita un nuevo servidor CI. CI no debería comportarse así.
Andres Jaan Tack
1
Un programa que falla (que es lo que hacen las aserciones) no debería matar a su corredor de pruebas (CI). Por eso tienes un corredor de prueba; para que algo pueda detectar e informar tales fallas.
Andres Jaan Tack
1
Las afirmaciones de estilo 'Assert' solo de depuración con las que estoy familiarizado (no las afirmaciones de prueba) abren un cuadro de diálogo que cuelga el CI porque está esperando la interacción del desarrollador.
Ritch Melton
1
Ah, bueno, eso explicaría mucho sobre nuestro desacuerdo. :) Me refiero a las afirmaciones de estilo C. Acabo de darme cuenta de que esta es una pregunta .NET. cplusplus.com/reference/clibrary/cassert/assert
Andres Jaan Tack
5

Me parece que la prueba de tu unidad funciona a las mil maravillas. Es bueno que sea tan frágil a los cambios, ya que ese es el punto principal. Pequeños cambios en las pruebas de ruptura de código para que pueda eliminar la posibilidad de error en todo su programa.

Sin embargo, tenga en cuenta que realmente solo necesita probar las condiciones que harían que su método fallara o que diera resultados inesperados. Esto haría que su unidad de prueba sea más propensa a "romperse" si hay un problema genuino en lugar de cosas triviales.

Aunque me parece que estás rediseñando mucho el programa. En tales casos, haga lo que sea necesario y elimine las pruebas anteriores y reemplácelas por otras nuevas después. Reparar pruebas unitarias solo vale la pena si no está reparando debido a cambios radicales en su programa. De lo contrario, es posible que dedique demasiado tiempo a la reescritura de pruebas para que sea aplicable en su nueva sección del código del programa.

Neil
fuente
3

Estoy seguro de que otros tendrán mucho más aporte, pero en mi experiencia, estas son algunas cosas importantes que lo ayudarán:

  1. Use una fábrica de objetos de prueba para construir estructuras de datos de entrada, de modo que no necesite duplicar esa lógica. Quizás busque en una biblioteca auxiliar como AutoFixture para reducir el código necesario para la configuración de la prueba.
  2. Para cada clase de prueba, centralice la creación del SUT, por lo que será fácil cambiar cuando las cosas se refactoricen.
  3. Recuerde, ese código de prueba es tan importante como el código de producción. También debe ser refactorizado, si encuentra que se está repitiendo, si el código no se puede mantener, etc., etc.
driis
fuente
Cuanto más reutilice el código en las pruebas, más frágiles se vuelven, porque ahora cambiar una prueba puede romper otra. Eso puede ser un costo razonable, a cambio de la capacidad de mantenimiento, no estoy entrando en ese argumento aquí, pero argumentar que los puntos 1 y 2 hacen que las pruebas sean menos frágiles (que era la pregunta) es simplemente incorrecto.
pdr
@driis - Correcto, el código de prueba tiene expresiones diferentes a las del código en ejecución. Ocultar cosas refactorizando el código 'común' y usando cosas como contenedores IoC simplemente enmascara los problemas de diseño expuestos por sus pruebas.
Ritch Melton
Si bien el punto que hace @pdr es probablemente válido para las pruebas unitarias, diría que para las pruebas de integración / sistema, podría ser útil pensar en términos de "preparar la aplicación para la tarea X". Eso podría implicar navegar al lugar adecuado, establecer ciertas configuraciones de tiempo de ejecución, abrir un archivo de datos, etc. Si se inician múltiples pruebas de integración en el mismo lugar, refactorizar ese código para reutilizarlo en múltiples pruebas podría no ser algo malo si comprende los riesgos y las limitaciones de dicho enfoque.
un CVn
2

Maneje las pruebas como lo hace con el código fuente.

Control de versiones, lanzamiento de puntos de control, seguimiento de problemas, "propiedad de características", planificación y estimación de esfuerzos, etc.

mosquito
fuente
1

Definitivamente deberías echar un vistazo a los patrones de prueba XUnit de Gerard Meszaros . Tiene una gran sección con muchas recetas para reutilizar su código de prueba y evitar duplicaciones.

Si sus pruebas son frágiles, también podría ser que no recurra lo suficiente como para probar dobles. Especialmente, si recrea gráficos enteros de objetos al comienzo de cada prueba unitaria, las secciones de Arreglo en sus pruebas pueden sobredimensionarse y a menudo puede encontrarse en situaciones en las que tiene que reescribir las secciones de Arreglo en un número considerable de pruebas solo porque una de sus clases más utilizadas ha cambiado. Los simulacros y trozos pueden ayudarlo aquí al reducir la cantidad de objetos que tiene que rehidratar para tener un contexto de prueba relevante.

Eliminar los detalles sin importancia de sus configuraciones de prueba a través de simulacros y apéndices y aplicar patrones de prueba para reutilizar el código debería reducir su fragilidad significativamente.

guillaume31
fuente