Con frecuencia trabajo con programas muy numéricos / matemáticos, donde el resultado exacto de una función es difícil de predecir de antemano.
Al tratar de aplicar TDD con este tipo de código, a menudo encuentro que escribir el código bajo prueba es significativamente más fácil que escribir pruebas unitarias para ese código, porque la única forma que sé para encontrar el resultado esperado es aplicar el algoritmo mismo (ya sea en mi cabeza, en papel o por la computadora). Esto se siente mal, porque estoy usando efectivamente el código bajo prueba para verificar mis pruebas unitarias, en lugar de al revés.
¿Existen técnicas conocidas para escribir pruebas unitarias y aplicar TDD cuando el resultado del código bajo prueba es difícil de predecir?
Un ejemplo (real) de código con resultados difíciles de predecir:
Una función weightedTasksOnTime
que, dada la cantidad de trabajo realizado por día workPerDay
en el rango (0, 24], la hora actual initialTime
> 0 y una lista de tareas taskArray
; cada una con un tiempo para completar la propiedad time
> 0, fecha de vencimiento due
y valor de importancia importance
; devuelve un valor normalizado en el rango [0, 1] que representa la importancia de las tareas que se pueden completar antes de su due
fecha si cada tarea se completa en el orden dado por taskArray
, comenzando en initialTime
.
El algoritmo para implementar esta función es relativamente sencillo: iterar sobre las tareas en taskArray
. Para cada tarea, agregue time
a initialTime
. Si la nueva hora < due
, agregue importance
a un acumulador. El tiempo se ajusta mediante trabajo inverso por día. Antes de devolver el acumulador, divídalo por la suma de las tareas importantes para normalizar.
function weightedTasksOnTime(workPerDay, initialTime, taskArray) {
let simulatedTime = initialTime
let accumulator = 0;
for (task in taskArray) {
simulatedTime += task.time * (24 / workPerDay)
if (simulatedTime < task.due) {
accumulator += task.importance
}
}
return accumulator / totalImportance(taskArray)
}
Creo que el problema anterior se puede simplificar, manteniendo su núcleo, eliminando workPerDay
y el requisito de normalización, para dar:
function weightedTasksOnTime(initialTime, taskArray) {
let simulatedTime = initialTime
let accumulator = 0;
for (task in taskArray) {
simulatedTime += task.time
if (simulatedTime < task.due) {
accumulator += task.importance
}
}
return accumulator
}
Esta pregunta aborda situaciones en las que el código bajo prueba no es una reimplementación de un algoritmo existente. Si el código es una reimplementación, intrínsecamente tiene resultados fáciles de predecir, porque las implementaciones confiables existentes del algoritmo actúan como un oráculo de prueba natural.
fuente
Respuestas:
Hay dos cosas que puede probar en un código difícil de probar. Primero, los casos degenerados. Qué sucede si no tiene elementos en su matriz de tareas, o solo uno, o dos, pero uno ha pasado la fecha de vencimiento, etc. Cualquier cosa que sea más simple que su problema real, pero aún así razonable para calcular manualmente.
El segundo son los controles de cordura. Estas son las comprobaciones que realiza cuando no sabe si una respuesta es correcta , pero definitivamente sabrá si está equivocada . Estas son cosas como el tiempo debe avanzar, los valores deben estar en un rango razonable, los porcentajes deben sumar 100, etc.
Sí, esto no es tan bueno como una prueba completa, pero te sorprendería la frecuencia con la que te equivocas en los controles de cordura y los casos degenerados, lo que revela un problema en tu algoritmo completo.
fuente
Solía escribir pruebas para software científico con resultados difíciles de predecir. Hicimos mucho uso de las relaciones metamórficas. Esencialmente, hay cosas que sabe acerca de cómo debe comportarse su software, incluso si no conoce resultados numéricos exactos.
Un posible ejemplo para su caso: si disminuye la cantidad de trabajo que puede hacer cada día, la cantidad total de trabajo que puede hacer será, en el mejor de los casos, igual, pero probablemente disminuirá. Por lo tanto, ejecute la función para varios valores de
workPerDay
y asegúrese de que la relación se mantenga.fuente
Las otras respuestas tienen buenas ideas para desarrollar pruebas para el caso de borde o error. Para los demás, el uso del algoritmo en sí no es ideal (obviamente) pero sigue siendo útil.
Detectará si el algoritmo (o los datos de los que depende) ha cambiado
Si el cambio es un accidente, podría revertir una confirmación. Si el cambio fue deliberado, debe volver a visitar la prueba de la unidad.
fuente
De la misma manera que escribe pruebas unitarias para cualquier otro tipo de código:
A menos que su código implique algún elemento aleatorio o no sea determinista (es decir, no producirá la misma salida dada la misma entrada), es comprobable por unidad.
Evite los efectos secundarios o las funciones afectadas por fuerzas externas. Las funciones puras son más fáciles de probar.
fuente
Unless your code involves some random element
El truco aquí es hacer que su generador de números aleatorios sea una dependencia inyectada, para que pueda reemplazarlo por un generador de números que le dé el resultado exacto que desea. Esto le permite volver a probar con precisión, contando también los números generados como parámetros de entrada.not deterministic (i.e. it won't produce the same output given the same input)
Dado que una prueba unitaria debe comenzar desde una situación controlada , solo puede ser no determinista si tiene un elemento aleatorio, que luego puede inyectar. No puedo pensar en otras posibilidades aquí.if(x == x)
, es una comparación sin sentido. Necesita sus dos resultados ( real : proviene del código; esperado : proviene de su conocimiento externo) para ser independientes el uno del otro.Actualización debido a comentarios publicados
La respuesta original se eliminó por razones de brevedad: puede encontrarla en el historial de edición.
Primero, un TL; DR para evitar una respuesta larga:
El problema principal aquí es que no está haciendo una separación entre el cliente y el desarrollador (y el analista, aunque ese rol también puede ser representado por un desarrollador).
Debe distinguir entre probar el código y probar los requisitos comerciales.
Por ejemplo, el cliente quiere que funcione así [esto] . Sin embargo, el desarrollador malinterpreta y escribe un código que hace [eso] .
Por lo tanto, el desarrollador escribirá pruebas unitarias que prueben si [eso] funciona como se esperaba. Si desarrolló la aplicación correctamente, sus pruebas unitarias pasarán aunque la aplicación no haga [esto] , lo que el cliente esperaba.
Si desea probar las expectativas del cliente (los requisitos comerciales), eso debe hacerse en un paso separado (y posterior).
Un flujo de trabajo de desarrollo simple para mostrarle cuándo deben ejecutarse estas pruebas:
Quizás se pregunte cuál es el punto de hacer dos pruebas separadas cuando el cliente y el desarrollador son uno y lo mismo. Como no hay una "transferencia" del desarrollador al cliente, las pruebas se ejecutan una tras otra, pero aún son pasos separados.
Si desea probar si su algoritmo es correcto, eso no es parte del trabajo del desarrollador . Esa es la preocupación del cliente, y el cliente lo probará utilizando la aplicación.
Como emprendedor y académico, es posible que te falte una distinción importante aquí, que destaca las diferentes responsabilidades.
fuente
Prueba de propiedad
A veces, las funciones matemáticas se cumplen mejor con "Pruebas de propiedad" que con las pruebas unitarias tradicionales basadas en ejemplos. Por ejemplo, imagine que está escribiendo pruebas unitarias para algo como una función de "multiplicación" de enteros. Si bien la función en sí misma puede parecer muy simple, si es la única forma de multiplicarse, ¿cómo se prueba a fondo sin la lógica en la función misma? Podría usar tablas gigantes con entradas / salidas esperadas, pero esto es limitado y propenso a errores.
En estos casos, puede probar las propiedades conocidas de la función, en lugar de buscar resultados esperados específicos. Para la multiplicación, puede saber que multiplicar un número negativo y un número positivo debería dar como resultado un número negativo, y que multiplicar dos números negativos debería dar como resultado un número positivo, etc. Usando valores aleatorios y luego verificando que estas propiedades se conservan para todos Los valores de prueba son una buena manera de probar tales funciones. Generalmente necesita probar más de una propiedad, pero a menudo puede identificar un conjunto finito de propiedades que juntas validan el comportamiento correcto de una función sin conocer necesariamente el resultado esperado para cada caso.
Una de las mejores presentaciones de Property Testing que he visto es esta en F #. Esperemos que la sintaxis no sea un obstáculo para comprender la explicación de la técnica.
fuente
Es tentador escribir el código y luego ver si el resultado "se ve bien", pero, como intuye correctamente, no es una buena idea.
Cuando el algoritmo es difícil, puede hacer varias cosas para facilitar el cálculo manual del resultado.
Usa Excel. Configure una hoja de cálculo que haga algunos o todos los cálculos por usted. Mantenlo lo suficientemente simple para que puedas ver los pasos.
Divida su método en métodos comprobables más pequeños, cada uno con sus propias pruebas. Cuando esté seguro de que las piezas más pequeñas funcionan, úselas para trabajar manualmente en el siguiente paso.
Use propiedades agregadas para verificar la cordura. Por ejemplo, supongamos que tiene una calculadora de probabilidad; Es posible que no sepa cuáles deberían ser los resultados individuales, pero sabe que todos tienen que sumar 100%.
Fuerza bruta. Escriba un programa que genere todos los resultados posibles y verifique que ninguno sea mejor de lo que genera su algoritmo.
fuente
TL; DR
Diríjase a la sección de "pruebas comparativas" para obtener consejos que no figuran en otras respuestas.
Principios
Comience probando los casos que deben ser rechazados por el algoritmo (cero o negativo
workPerDay
, por ejemplo) y los casos que son triviales (por ejemplo,tasks
matriz vacía ).Después de eso, primero desea probar los casos más simples. Para la
tasks
entrada, necesitamos probar diferentes longitudes; debería ser suficiente para probar los elementos 0, 1 y 2 (2 pertenece a la categoría "muchos" para esta prueba).Si puede encontrar entradas que puedan calcularse mentalmente, es un buen comienzo. Una técnica que a veces uso es comenzar desde un resultado deseado y retroceder (en la especificación) a las entradas que deberían producir ese resultado.
Prueba comparativa
A veces, la relación de la salida con la entrada no es obvia, pero tiene una relación predecible entre diferentes salidas cuando se cambia una entrada. Si he entendido el ejemplo correctamente, entonces agregar una tarea (sin cambiar otras entradas) nunca aumentará la proporción del trabajo realizado a tiempo, por lo que podemos crear una prueba que llame a la función dos veces, una con y otra sin la tarea adicional - y afirma la desigualdad entre los dos resultados.
Fallbacks
A veces he tenido que recurrir a un comentario largo que muestra un resultado calculado a mano en pasos correspondientes a la especificación (dicho comentario suele ser más largo que el caso de prueba). El peor de los casos es cuando tiene que mantener la compatibilidad con una implementación anterior en un idioma diferente o para un entorno diferente. A veces solo tiene que etiquetar los datos de prueba con algo así
/* derived from v2.6 implementation on ARM system */
. Eso no es muy satisfactorio, pero puede ser aceptable como prueba de fidelidad al portar, o como una muleta a corto plazo.Recordatorios
El atributo más importante de una prueba es su legibilidad: si las entradas y salidas son opacas para el lector, entonces la prueba tiene un valor muy bajo, pero si se ayuda al lector a comprender las relaciones entre ellos, entonces la prueba tiene dos propósitos.
No olvide utilizar un "aproximadamente igual" apropiado para obtener resultados inexactos (por ejemplo, coma flotante).
Evite las pruebas en exceso: solo agregue una prueba si cubre algo (como un valor límite) que otras pruebas no alcanzan.
fuente
No hay nada muy especial en este tipo de función difícil de probar. Lo mismo se aplica para el código que usa interfaces externas (por ejemplo, una API REST de una aplicación de terceros que no está bajo su control y que ciertamente no puede ser probada por su conjunto de pruebas; o usando una biblioteca de terceros donde no está seguro de la formato de byte exacto de los valores de retorno).
Es un enfoque bastante válido simplemente ejecutar su algoritmo para una entrada sensata, ver qué hace, asegurarse de que el resultado sea correcto y encapsular la entrada y el resultado como un caso de prueba. Puede hacer esto por algunos casos y así obtener varias muestras. Intente hacer que los parámetros de entrada sean lo más diferentes posible. En el caso de una llamada API externa, haría algunas llamadas contra el sistema real, las rastrearía con alguna herramienta y luego las imitaría en las pruebas unitarias para ver cómo reacciona su programa, que es lo mismo que elegir algunas ejecuta su código de planificación de tareas, verificándolos a mano y luego codificando el resultado en sus pruebas.
Luego, obviamente, traiga casos extremos como (en su ejemplo) una lista vacía de tareas; ese tipo de cosas.
Su conjunto de pruebas quizás no sea tan bueno como para un método en el que puede predecir fácilmente los resultados; pero sigue siendo 100% mejor que ninguna suite de pruebas (o simplemente una prueba de humo).
Sin embargo, si su problema es que le resulta difícil decidir si un resultado es correcto, entonces ese es un problema completamente diferente. Por ejemplo, supongamos que tiene un método que detecta si un número arbitrariamente grande es primo. Difícilmente puede arrojarle un número aleatorio y luego simplemente "mirar" si el resultado es correcto (suponiendo que no pueda decidir la primacía en su cabeza o en una hoja de papel). En este caso, de hecho, es poco lo que puede hacer: necesitaría obtener resultados conocidos (es decir, algunos números primos grandes) o implementar la funcionalidad con un algoritmo diferente (tal vez incluso un equipo diferente: la NASA parece ser aficionada a eso) y espero que si cualquiera de las implementaciones tiene errores, al menos el error no conduce a los mismos resultados incorrectos.
Si este es un caso normal para usted, entonces debe tener una buena conversación con sus ingenieros de requisitos. Si no pueden formular sus requisitos de una manera que sea fácil (o posible) verificar por usted, entonces, ¿cuándo sabe si ha terminado?
fuente
Otras respuestas son buenas, así que intentaré acertar en algunos puntos que se han perdido colectivamente hasta ahora.
He escrito (y probado exhaustivamente) software para hacer procesamiento de imágenes usando Synthetic Aperture Radar (SAR). Es de naturaleza científica / numérica (hay mucha geometría, física y matemáticas involucradas).
Un par de consejos (para pruebas científicas / numéricas generales):
1) Usa inversas. ¿Cuál es la
fft
de[1,2,3,4,5]
? Ni idea. ¿Qué esifft(fft([1,2,3,4,5]))
? Debería estar[1,2,3,4,5]
(o cerca de él, pueden aparecer errores de coma flotante). Lo mismo ocurre con el caso 2D.2) Use afirmaciones conocidas. Si escribe una función determinante, puede ser difícil decir cuál es el determinante de una matriz aleatoria de 100x100. Pero sí sabe que el determinante de la matriz de identidad es 1, incluso si es 100x100. También sabe que la función debe devolver 0 en una matriz no invertible (como un 100x100 lleno de todos los 0).
3) Use afirmaciones aproximadas en lugar de afirmaciones exactas . Escribí un código para dicho procesamiento SAR que registraría dos imágenes al generar puntos de enlace que crean un mapeo entre las imágenes y luego hacer una deformación entre ellas para que coincidan. Podría registrarse en un nivel de subpíxel. A priori, es difícil decir algo sobre cómo podría ser el registro de dos imágenes. ¿Cómo puedes probarlo? Cosas como:
Como solo puede registrarse en partes superpuestas, la imagen registrada debe ser más pequeña o igual a su imagen más pequeña, y también:
dado que una imagen registrada para sí misma debe estar CERCANA a sí misma, pero es posible que experimente un poco más de errores de coma flotante debido al algoritmo en cuestión, por lo que solo debe verificar que cada píxel esté dentro de +/- 5% del rango que pueden tomar los píxeles. (0-255 es escala de grises, común en el procesamiento de imágenes). El resultado debe ser al menos del mismo tamaño que la entrada.
Incluso puede simplemente hacer una prueba de humo (es decir, llamarlo y asegurarse de que no se cuelgue). En general, esta técnica es mejor para pruebas más grandes donde el resultado final no puede calcularse (fácilmente) a priori para ejecutar la prueba.
4) Use O ALMACENE una semilla de número aleatorio para su RNG.
Carreras no tienen que ser reproducibles. Sin embargo, es falso que la única forma de obtener una ejecución reproducible es proporcionar una semilla específica a un generador de números aleatorios. A veces las pruebas de aleatoriedad son valiosas. He visto / escuchado sobre errores en el código científico que surgen en casos degenerados que se generaron aleatoriamente (en algoritmos complicados puede ser difícil ver cuál es el caso degenerado incluso) En lugar de llamar siempre a su función con la misma semilla, genere una semilla aleatoria, luego use esa semilla y registre el valor de la semilla. De esa manera, cada ejecución tiene una semilla aleatoria diferente, pero si se bloquea, puede volver a ejecutar el resultado utilizando la semilla que ha registrado para depurar. De hecho, he usado esto en la práctica y aplastó un error, así que pensé en mencionarlo. Es cierto que esto solo sucedió una vez, y estoy seguro de que no siempre vale la pena hacerlo, así que usa esta técnica con prudencia. Sin embargo, el azar con la misma semilla siempre es seguro. Desventaja (en lugar de usar la misma semilla todo el tiempo): debe registrar sus ejecuciones de prueba. Al revés: corrección y eliminación de errores.
Su caso particular
1) Probar que un vacío
taskArray
devuelve 0 (afirmación conocida).2) Generar entrada aleatoria de tal manera que
task.time > 0
,task.due > 0
, ytask.importance > 0
para todos lostask
s, y afirmar el resultado es mayor que0
(assert áspero, de entrada al azar) . No necesita volverse loco y generar semillas al azar, su algoritmo simplemente no es lo suficientemente complejo como para justificarlo. Hay alrededor de 0 posibilidades de que valga la pena: simplemente mantenga la prueba simple.3) Probar si
task.importance == 0
para todos lostask
s, luego el resultado es0
(afirmación conocida)4) Otras respuestas tocaron esto, pero podría ser importante para su caso particular : si está haciendo que una API sea consumida por usuarios fuera de su equipo, debe probar los casos degenerados. Por ejemplo, si
workPerDay == 0
, asegúrese de lanzar un error encantador que le indique al usuario que la entrada no es válida. Si no está haciendo una API, y es solo para usted y su equipo, probablemente puede omitir este paso y simplemente negarse a llamarlo con el caso degenerado.HTH.
fuente
Incorpore pruebas de afirmación en su conjunto de pruebas unitarias para pruebas basadas en propiedades de su algoritmo. Además de escribir pruebas unitarias que verifican la salida específica, escriba pruebas diseñadas para fallar al desencadenar fallas de aserción en el código principal.
Muchos algoritmos confían en sus pruebas de corrección para mantener ciertas propiedades a lo largo de las etapas del algoritmo. Si puede verificar con sensatez estas propiedades observando el resultado de una función, la prueba unitaria por sí sola es suficiente para probar sus propiedades. De lo contrario, las pruebas basadas en aserciones le permiten probar que una implementación mantiene una propiedad cada vez que el algoritmo la asume.
Las pruebas basadas en afirmaciones expondrán fallas de algoritmos, errores de codificación y fallas de implementación debido a problemas como la inestabilidad numérica. Muchos lenguajes tienen mecanismos que afirman las aserciones en tiempo de compilación o antes de que se interprete el código, de modo que cuando se ejecutan en modo de producción las aserciones no incurran en una penalización de rendimiento. Si su código pasa las pruebas unitarias pero falla en un caso de la vida real, puede volver a activar las afirmaciones como una herramienta de depuración.
fuente
Algunas de las otras respuestas aquí son muy buenas:
... Añadiría algunas otras tácticas:
La descomposición le permite asegurarse de que los componentes de su algoritmo hagan lo que espera que hagan. Y una descomposición "buena" también le permite asegurarse de que estén bien pegados. Una gran descomposición generaliza y simplifica el algoritmo en la medida en que puede predecir los resultados (de los algoritmos genéricos simplificados) a mano lo suficientemente bien como para escribir pruebas exhaustivas.
Si no puede descomponerse hasta ese punto, pruebe el algoritmo fuera del código por cualquier medio que sea suficiente para satisfacerlo a usted y a sus pares, partes interesadas y clientes. Y luego, descomponga lo suficiente como para demostrar que su implementación coincide con el diseño.
fuente
Esto puede parecer una respuesta idealista, pero ayuda a identificar diferentes tipos de pruebas.
Si las respuestas estrictas son importantes para la implementación, entonces los ejemplos y las respuestas esperadas realmente deberían proporcionarse en los requisitos que describen el algoritmo. Estos requisitos deben revisarse en grupo y si no obtiene los mismos resultados, debe identificarse el motivo.
Incluso si desempeña el papel de analista e implementador, en realidad debe crear requisitos y hacer que se revisen mucho antes de escribir las pruebas unitarias, por lo que en este caso sabrá los resultados esperados y podrá escribir sus pruebas en consecuencia.
Por otro lado, si esta es una parte que está implementando y que no forma parte de la lógica de negocios o admite una respuesta de lógica de negocios, entonces debería estar bien ejecutar la prueba para ver cuáles son los resultados y luego modificar la prueba para esperar esos resultados Los resultados finales ya se verifican según sus requisitos, por lo que si son correctos, todo el código que alimenta esos resultados finales debe ser numéricamente correcto y en ese punto las pruebas de su unidad son más para detectar casos de falla de borde y futuros cambios de refactorización que para demostrar que un determinado El algoritmo produce resultados correctos.
fuente
Creo que es perfectamente aceptable en ocasiones seguir el proceso:
Este es un enfoque razonable en cualquier situación en la que verificar la corrección de una respuesta a mano es más fácil que calcular la respuesta a mano desde los primeros principios.
Conozco personas que escriben software para renderizar páginas impresas y tienen pruebas que verifican que se hayan configurado exactamente los píxeles correctos en la página impresa. La única forma sensata de hacerlo es escribir el código para representar la página, comprobar a simple vista que se ve bien y luego capturar el resultado como una prueba de regresión para futuras versiones.
El hecho de que lea en un libro que una metodología en particular alienta a escribir los casos de prueba primero, no significa que siempre tenga que hacerlo de esa manera. Las reglas están para romperse.
fuente
Las respuestas de otras respuestas ya tienen técnicas para el aspecto de una prueba cuando el resultado específico no se puede determinar fuera de la función probada.
Lo que hago además, que no he visto en las otras respuestas, es generar automáticamente pruebas de alguna manera:
Por ejemplo, si la función toma tres parámetros, cada uno con el rango de entrada permitido [-1,1], pruebe todas las combinaciones de cada parámetro, {-2, -1.01, -1, -0.99, -0.5, -0.01, 0,0.01 , 0,5,0,99,1,1.01,2, algo más aleatorio en (-1,1)}
En resumen: a veces la mala calidad puede ser subsidiada por la cantidad
fuente