TDD y cobertura de prueba completa donde se necesitan casos de prueba exponenciales

18

Estoy trabajando en un comparador de listas para ayudar a ordenar una lista desordenada de resultados de búsqueda según los requisitos muy específicos de nuestro cliente. Los requisitos requieren un algoritmo de relevancia clasificado con las siguientes reglas en orden de importancia:

  1. Coincidencia exacta en el nombre
  2. Todas las palabras de consulta de búsqueda en nombre o sinónimo del resultado
  3. Algunas palabras de consulta de búsqueda en nombre o sinónimo del resultado (% descendente)
  4. Todas las palabras de la consulta de búsqueda en la descripción.
  5. Algunas palabras de la consulta de búsqueda en la descripción (% descendente)
  6. Fecha de última modificación descendiente

La elección de diseño natural para este comparador parecía ser una clasificación puntuada basada en poderes de 2. La suma de reglas menos importantes nunca puede ser más que una coincidencia positiva en una regla de mayor importancia. Esto se logra mediante el siguiente puntaje:

  1. 32
  2. dieciséis
  3. 8 (puntaje secundario de desempate basado en% descendente)
  4. 4 4
  5. 2 (Puntuación secundaria de desempate basada en% descendente)
  6. 1

En el espíritu TDD, decidí comenzar primero con mis pruebas unitarias. Tener un caso de prueba para cada escenario único sería, como mínimo, 63 casos de prueba únicos sin considerar casos de prueba adicionales para la lógica secundaria de desempate en las reglas 3 y 5. Esto parece excesivo.

Sin embargo, las pruebas reales serán menos. Según las reglas reales, ciertas reglas aseguran que las reglas inferiores siempre serán verdaderas (por ejemplo, cuando "Todas las palabras de consulta de búsqueda aparecen en la descripción", entonces la regla "Algunas palabras de consulta de búsqueda aparecen en la descripción" siempre será verdadera). ¿Aún vale la pena el nivel de esfuerzo al escribir cada uno de estos casos de prueba? ¿Es este el nivel de prueba que generalmente se requiere cuando se habla de una cobertura de prueba del 100% en TDD? Si no, ¿cuál sería una estrategia de prueba alternativa aceptable?

árbol de arce
fuente
1
Este escenario y otros similares es la razón por la que desarrollé un "TMatrixTestCase" y un enumerador para el que puede escribir el código de prueba una vez y alimentarlo con dos o más matrices que contienen las entradas y el resultado esperado.
Marjan Venema

Respuestas:

17

Su pregunta implica que TDD tiene algo que ver con "escribir todos los casos de prueba primero". En mi humilde opinión eso no está "en el espíritu de TDD", en realidad está en contra . Recuerde que TDD significa " desarrollo impulsado por pruebas ", por lo que solo necesita aquellos casos de prueba que realmente "impulsen" su implementación, no más. Y siempre que su implementación no esté diseñada de manera que el número de bloques de código crezca exponencialmente con cada nuevo requisito, tampoco necesitará un número exponencial de casos de prueba. En su ejemplo, el ciclo TDD probablemente se verá así:

  • comience con el primer requisito de su lista: las palabras con "coincidencia exacta en el nombre" deben obtener una puntuación más alta que todo lo demás
  • ahora escribe un primer caso de prueba para esto (por ejemplo: una palabra que coincide con una consulta dada) e implementa la cantidad mínima de código de trabajo que hace que esa prueba pase
  • agregue un segundo caso de prueba para el primer requisito (por ejemplo: una palabra que no coincide con la consulta), y antes de agregar un nuevo caso de prueba , cambie su código existente hasta que pase la segunda prueba
  • dependiendo de los detalles de su implementación, siéntase libre de agregar más casos de prueba, por ejemplo, una consulta vacía, una palabra vacía, etc. (recuerde: TDD es un enfoque de recuadro blanco , puede hacer uso del hecho de que conoce su implementación cuando diseñe sus casos de prueba).

Luego, comience con el segundo requisito:

  • "Todas las palabras de consulta de búsqueda en el nombre o un sinónimo del resultado" deben obtener una puntuación más baja que "Coincidencia exacta en el nombre", pero una puntuación más alta que todo lo demás.
  • ahora cree casos de prueba para este nuevo requisito, tal como se indicó anteriormente, uno tras otro, e implemente la siguiente parte de su código después de cada nueva prueba. No olvides refactorizar en el medio, tu código y tus casos de prueba.

Aquí viene el truco : cuando agrega casos de prueba para el requisito / número de categoría "n", solo tendrá que agregar pruebas para asegurarse de que el puntaje de la categoría "n-1" sea más alto que el puntaje de la categoría "n" . No tendrá que agregar ningún caso de prueba para cualquier otra combinación de las categorías 1, ..., n-1, ya que las pruebas que ha escrito anteriormente se asegurarán de que los puntajes de esas categorías sigan en el orden correcto.

Por lo tanto, esto le dará una serie de casos de prueba que crecen aproximadamente lineales con el número de requisitos, no exponencialmente.

Doc Brown
fuente
1
Realmente me gusta esta respuesta. Ofrece una estrategia de prueba unitaria clara y concisa para abordar este problema teniendo en cuenta la TDD. Lo descompones bastante bien.
maple_shaft
@maple_shaft: gracias, y realmente me gusta tu pregunta. Me gustaría agregar que supongo que incluso con su enfoque de diseñar todos los casos de prueba primero, la técnica clásica de construir clases de equivalencia para las pruebas podría ser suficiente para reducir el crecimiento exponencial (pero no lo resolví hasta ahora).
Doc Brown
13

Considere escribir una clase que pase por una lista predefinida de condiciones y multiplique un puntaje actual por 2 por cada verificación exitosa.

Esto se puede probar muy fácilmente, utilizando solo un par de pruebas simuladas.

Luego puede escribir una clase para cada condición y solo hay 2 pruebas para cada caso.

Realmente no entiendo su caso de uso, pero espero que este ejemplo ayude.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Notarás que tus pruebas de condiciones 2 ^ se reducen rápidamente a 4+ (condiciones 2 *). 20 es mucho menos dominante que 64. Y si agrega otra más tarde, no tiene que cambiar NINGUNA de las clases existentes (principio abierto-cerrado), por lo que no tiene que escribir 64 nuevas pruebas, solo tiene que para agregar otra clase con 2 pruebas nuevas e inyectar eso en su clase ScoreBuilder.

pdr
fuente
Enfoque interesante Todo el tiempo mi mente nunca consideró un enfoque OOP ya que estaba atrapado en la mente de un solo componente de comparación. Realmente no estaba buscando consejos sobre algoritmos, pero esto es muy útil independientemente.
maple_shaft
44
@maple_shaft: No, pero estabas buscando consejos de TDD y este tipo de algoritmos son perfectos para eliminar la pregunta de si vale la pena el esfuerzo, reduciendo enormemente el esfuerzo. Reducir la complejidad es clave para TDD.
pdr
+1, gran respuesta. Aunque creo que incluso sin una solución tan sofisticada, el número de casos de prueba no tiene que crecer exponencialmente (vea mi respuesta a continuación).
Doc Brown
No acepté su respuesta porque sentí que otra respuesta abordaba mejor la pregunta real, pero me gustó tanto su enfoque de diseño que lo estoy implementando como usted sugirió. Esto reduce la complejidad y la hace más extensible a largo plazo.
maple_shaft
4

¿Aún vale la pena el nivel de esfuerzo al escribir cada uno de estos casos de prueba?

Tendrá que definir "vale la pena". El problema con este tipo de escenario es que las pruebas tendrán un rendimiento decreciente de la utilidad. Ciertamente, la primera prueba que escriba valdrá la pena. Puede encontrar errores obvios en la prioridad e incluso cosas como errores de análisis al intentar separar las palabras.

La segunda prueba valdrá la pena porque cubre una ruta diferente a través del código, probablemente verificando otra relación de prioridad.

La 63a prueba probablemente no valdrá la pena porque es algo en lo que tienes una confianza del 99,99% y está cubierto por la lógica de tu código u otra prueba.

¿Es este el nivel de prueba que generalmente se requiere cuando se habla de una cobertura de prueba del 100% en TDD?

Tengo entendido que la cobertura del 100% significa que se ejercen todas las rutas de código. Esto no significa que haga todas las combinaciones de sus reglas, pero todas las diferentes rutas que podría seguir su código (como señala, algunas combinaciones no pueden existir en el código). Pero como está haciendo TDD, todavía no hay un "código" para verificar las rutas. La letra del proceso diría make 63+.

Personalmente, encuentro que el 100% de cobertura es un sueño imposible. Más allá de eso, no es pragmático. Las pruebas unitarias existen para servirle, no al revés. A medida que realiza más pruebas, obtiene rendimientos decrecientes en el beneficio (la probabilidad de que la prueba evite un error + la confianza de que el código es correcto). Dependiendo de lo que haga su código, define en qué escala deslizante deja de hacer pruebas. Si su código está ejecutando un reactor nuclear, entonces quizás todas las más de 63 pruebas valen la pena. Si su código está organizando su archivo de música, entonces probablemente podría salirse con la suya con mucho menos.

Telastyn
fuente
"cobertura" generalmente se refiere a la cobertura de código (cada línea de código se ejecuta) o cobertura de sucursal (cada sucursal se ejecuta al menos una vez en cualquier dirección posible). Para ambos tipos de cobertura no hay necesidad de 64 casos de prueba diferentes. Al menos, no con una implementación seria que no contenga partes de código individuales para cada uno de los 64 casos. Entonces, el 100% de cobertura es totalmente posible.
Doc Brown
@DocBrown, claro, en este caso, otras cosas son más difíciles / imposibles de probar; considere las rutas de excepción sin memoria. ¿No se requerirían todos los 64 en TDD 'por letra' para hacer cumplir el comportamiento que se prueba ignorando la implementación?
Telastyn
bueno, mi comentario estaba relacionado con la pregunta, y su respuesta da la impresión de que puede ser difícil obtener una cobertura del 100% en el caso del OP . Dudo que. Y estoy de acuerdo con usted en que se pueden construir casos en los que la cobertura del 100% es más difícil de lograr, pero eso no se preguntó.
Doc Brown
4

Yo diría que este es un caso perfecto para TDD.

Tiene un conjunto conocido de criterios para probar, con un desglose lógico de esos casos. Asumiendo que los va a probar unitariamente ahora o más tarde, parece tener sentido tomar el resultado conocido y construirlo a su alrededor, asegurando que, de hecho, esté cubriendo cada una de las reglas de forma independiente.

Además, puede averiguar a medida que avanza si agregar una nueva regla de búsqueda infringe una regla existente. Si hace todo esto al final de la codificación, presumiblemente corre un mayor riesgo de tener que cambiar uno para arreglar uno, que rompe otro, que rompe otro ... Y, al implementar las reglas, aprende si su diseño es válido o necesita ajustes.

Wonko el cuerdo
fuente
1

No soy fanático de interpretar estrictamente el 100% de la cobertura de prueba como escribir especificaciones contra cada método o probar cada permutación del código. Hacer esto fanáticamente tiende a conducir a un diseño de sus clases basado en pruebas que no encapsula adecuadamente la lógica de negocios y produce pruebas / especificaciones que generalmente no tienen sentido en términos de describir la lógica de negocios admitida. En cambio, me concentro en estructurar las pruebas de forma muy parecida a las reglas comerciales en sí mismas y me esfuerzo por ejercitar cada rama condicional del código con pruebas con la expectativa explícita de que esas pruebas sean fácilmente entendidas por el probador como lo serían generalmente los casos de uso y realmente describen reglas comerciales que fueron implementadas.

Con esta idea en mente, probaría exhaustivamente los 6 factores de clasificación que enumeró de forma aislada entre sí, seguidos de 2 o 3 pruebas de estilo de integración que aseguran que está acumulando sus resultados a los valores de clasificación generales esperados. Por ejemplo, en el caso n. ° 1, Exact Match on Name, tendría al menos dos pruebas unitarias para probar cuándo es exacto y cuándo no, y que los dos escenarios devuelven la puntuación esperada. Si distingue entre mayúsculas y minúsculas, también un caso para probar "Coincidencia exacta" frente a "coincidencia exacta" y posiblemente otras variaciones de entrada, como puntuación, espacios adicionales, etc., también devuelve las puntuaciones esperadas.

Una vez que he trabajado con todos los factores individuales que contribuyen a los puntajes de clasificación, esencialmente asumo que estos funcionan correctamente en el nivel de integración y me concentro en asegurar que sus factores combinados contribuyan correctamente al puntaje de clasificación final esperado.

Suponiendo que los casos # 2 / # 3 y # 4 / # 5 están generalizados a los mismos métodos subyacentes, pero pasando diferentes campos, solo tiene que escribir un conjunto de pruebas unitarias para los métodos subyacentes y escribir pruebas unitarias adicionales simples para probar el específico campos (título, nombre, descripción, etc.) y puntuación en la factorización designada, por lo que esto reduce aún más la redundancia de su esfuerzo de prueba general.

Con este enfoque, el enfoque descrito anteriormente probablemente arrojaría 3 o 4 pruebas unitarias en el caso n. ° 1, tal vez 10 especificaciones en algunos / todos los w / sinónimos explicados, más 4 especificaciones en la calificación correcta de los casos n. ° 2 - n. ° 5 y 2 a 3 especificaciones en la clasificación ordenada de la fecha final, luego 3 a 4 pruebas de nivel de integración que miden los 6 casos combinados de manera probable (olvídate de los casos de borde oscuro por ahora a menos que veas claramente un problema en tu código que debe ejercerse para garantizar esa condición se maneja) o asegúrese de que no sea ​​violado / roto por revisiones posteriores. Eso produce alrededor de 25 o más especificaciones para ejercer el 100% del código escrito (aunque no llamó directamente al 100% de los métodos escritos).

Michael Lang
fuente
1

Nunca he sido fanático del 100% de cobertura de prueba. En mi experiencia, si algo es lo suficientemente simple como para probar con solo uno o dos casos de prueba, entonces es lo suficientemente simple como para raramente fallar. Cuando falla, generalmente se debe a cambios arquitectónicos que requerirían cambios de prueba de todos modos.

Dicho esto, para requisitos como el tuyo, siempre realizo pruebas exhaustivas, incluso en proyectos personales en los que nadie me obliga, porque esos son los casos en que las pruebas unitarias te ahorran tiempo y molestias. Cuantas más pruebas unitarias se requieran para probar algo, más tiempo se ahorrarán las pruebas unitarias.

Esto se debe a que solo puedes mantener tantas cosas en tu cabeza a la vez. Si está tratando de escribir código que funcione para 63 combinaciones diferentes, a menudo es difícil arreglar una combinación sin romper otra. Terminas probando manualmente otras combinaciones una y otra vez. Las pruebas manuales son mucho más lentas, lo que hace que no desee volver a ejecutar todas las combinaciones posibles cada vez que realice un cambio. Eso te hace más propenso a perderte algo y a perder tiempo buscando caminos que no funcionan en todos los casos.

Además del tiempo ahorrado en comparación con las pruebas manuales, hay mucha menos tensión mental, lo que hace que sea más fácil concentrarse en el problema en cuestión sin preocuparse por la introducción accidental de regresiones. Eso te permite trabajar más rápido y por más tiempo sin agotamiento. En mi opinión, solo los beneficios de salud mental valen el costo de la prueba unitaria del código complejo, incluso si no le ahorró tiempo.

Karl Bielefeldt
fuente