Recientemente, cada vez más a menudo hay referencias a cierta herramienta mágica: pruebas basadas en propiedades (pruebas basadas en propiedades, si necesita buscar en la literatura inglesa). La mayoría de los artículos sobre este tema hablan sobre qué enfoque tan genial es, luego muestran en un ejemplo elemental cómo escribir una prueba usando un marco específico, en el mejor de los casos sugieren varias propiedades comunes, y ... eso es todo. Además, el lector asombrado y entusiasta intenta poner todo esto en práctica, y se basa en el hecho de que las propiedades no se inventan de alguna manera. Y desafortunadamente, a menudo se rinde ante esto. En este artículo intentaré priorizar un poco diferente. Aún así, comenzaré con un ejemplo más o menos concreto para explicar qué tipo de animal es. Pero un ejemplo, espero, no es del todo típico para artículos de este tipo. Luego intentaré analizar algunos de los problemas asociados con este enfoque y cómo se pueden resolver. Y en lo sucesivo, propiedades, propiedades y solo propiedades, con ejemplos en los que se pueden insertar. Interesante?
Prueba de almacenamiento de valor clave en tres pruebas cortas
Digamos, por alguna razón, que necesitamos implementar algún tipo de almacenamiento de valores clave. Puede ser un diccionario basado en una tabla hash, o basado en algún árbol, puede almacenarse por completo en la memoria o puede funcionar con un disco; no nos importa. Lo principal es que debe tener una interfaz que le permita:
- escribir valor por clave
- comprobar si existe una entrada con la clave deseada
- leer valor por clave
- obtener una lista de elementos grabados
- obtener una copia del repositorio
En el enfoque clásico basado en ejemplos, una prueba típica se vería así:
storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42
Más o menos:
storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73
Y en general, tales pruebas pueden y deberán escribirse un poco más que dofiga. Además, cuanto más complicada sea la implementación interna, más posibilidades hay de perder algo de todos modos. En resumen, un trabajo largo, tedioso y muchas veces ingrato. ¡Qué bueno sería empujarlo a alguien! Por ejemplo, haga que la computadora genere casos de prueba para nosotros. Primero, intente hacer algo como esto:
storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value
Esta es la primera prueba basada en propiedades. Se ve casi igual que el tradicional, aunque un pequeño bono ya es sorprendente: no hay valores tomados del techo, sino que utilizamos funciones que devuelven valores y claves arbitrarias. Hay otra ventaja mucho más seria: se puede realizar muchas, muchas veces y en diferentes datos de entrada para verificar el contrato de que si intenta agregar algún elemento al almacenamiento vacío, realmente se agregará allí. Bien, eso está muy bien, pero hasta ahora no es muy útil en comparación con el enfoque tradicional. Intentemos agregar otra prueba:
storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy)
Aquí, en lugar de tomar el almacenamiento vacío, generamos datos arbitrarios con algunos datos y verificamos que su copia sea idéntica a la original. Sí, el generador debe escribirse utilizando una API pública potencialmente defectuosa, pero, por regla general, esta no es una tarea tan difícil. Al mismo tiempo, si hay errores graves en la implementación, entonces hay muchas posibilidades de que las caídas comiencen durante el proceso de generación, por lo que esto también puede considerarse como una especie de prueba de humo adicional. Pero ahora podemos estar seguros de que todo lo que el generador pudo suministrar se puede copiar correctamente. Y gracias a la primera prueba, sabemos con certeza que el generador puede crear almacenamiento con al menos un elemento. ¡Hora de la próxima prueba! Al mismo tiempo, reutilizamos el generador:
storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup)
Tomamos un almacenamiento arbitrario y verificamos que podamos agregar otro elemento allí. Entonces el generador puede crear un repositorio con dos elementos. Y también puedes agregarle un elemento. Y así sucesivamente (recuerdo de inmediato una inducción matemática). Como resultado, las tres pruebas escritas y el generador permiten verificar de manera confiable que se puede agregar un número arbitrario de elementos diferentes al repositorio. ¡Solo tres pruebas cortas! Esa es básicamente la idea de las pruebas basadas en propiedades:
- encontramos propiedades
- comprobación de propiedades en un montón de datos diferentes
- beneficio!
Por cierto, este enfoque no contradice los principios de TDD: las pruebas se pueden escribir de la misma manera antes del código (al menos personalmente, generalmente hago esto). Otra cuestión es que hacer que dicha prueba se vuelva verde puede ser mucho más difícil que la tradicional, pero cuando finalmente se apruebe con éxito, nos aseguraremos de que el código realmente cumpla con una determinada parte del contrato.
Todo esto está muy bien, pero ...
Con todo el atractivo de un enfoque de prueba basado en la propiedad, hay muchos problemas. En esta parte intentaré distinguir los más comunes. Y aparte de los problemas con la complejidad real de encontrar propiedades útiles (a las que volveré en la próxima sección), en mi opinión, el mayor problema para los principiantes es a menudo una falsa creencia en una buena cobertura. De hecho, escribimos varias pruebas que generan cientos de casos de prueba: ¿qué podría salir mal? Si nos fijamos en el ejemplo de la parte anterior, en realidad hay muchas cosas. Para empezar, las pruebas escritas no garantizan que
storage.copy () realmente haga una copia "profunda", y no solo copie el puntero. Otro agujero: no hay una verificación normal de que la
clave en el almacenamiento devolverá
False si la clave que está buscando no está en la tienda. Y la lista continúa. Bueno, uno de mis ejemplos favoritos: digamos que escribimos una especie, y por alguna razón creemos que una prueba que verifica el orden de los elementos es suficiente:
input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:]))
Y tal implementación irá bien
def sort(input): return [1, 2, 3]
Espero que la moraleja aquí sea clara.
El siguiente problema, que en cierto sentido se puede llamar una consecuencia de los dos anteriores, es que el uso de pruebas basadas en propiedades a menudo es muy difícil de lograr una cobertura verdaderamente completa. Pero en mi opinión, esto se resuelve de manera muy simple: no es necesario escribir solo pruebas basadas en propiedades, nadie canceló las pruebas tradicionales. Además, las personas están tan dispuestas que les resulta mucho más fácil comprender las cosas con ejemplos concretos, lo que también habla a favor del uso de ambos enfoques. En general, desarrollé para mí mismo aproximadamente el siguiente algoritmo: escribir algunas pruebas tradicionales muy simples, idealmente para que puedan servir como un ejemplo de cómo se supone que se debe usar la API. Tan pronto como hubo la sensación de que las pruebas "para documentación" son suficientes, pero aún está lejos de ser una cobertura completa, comience a agregar pruebas basadas en las propiedades.
Ahora, a la cuestión de los marcos, qué esperar de ellos y por qué son necesarios, después de todo, nadie prohíbe con las manos realizar una prueba en un ciclo, causando una aleatoriedad dentro y disfrutando de la vida. De hecho, la alegría será hasta que caiga la primera prueba, y es bueno si es local, y no en algunos IC. Primero, dado que las pruebas basadas en propiedades son aleatorias, definitivamente necesitas una forma de reproducir de manera confiable un caso descartado, y cualquier marco de trabajo que te respete te permite hacer esto. Los enfoques más populares son enviar una cierta semilla a la consola, que puede aplicar manualmente en el corredor de prueba y reproducir de manera confiable el caso descartado (conveniente para la depuración), o crear una caché en el disco con sids "malos", que se comprobarán automáticamente primero cuando comience la prueba ( ayuda con la repetibilidad en CI). Otro aspecto importante es la minificación de datos (reducción en fuentes extranjeras). Dado que los datos se generan aleatoriamente, es decir, una posibilidad completamente falsa de obtener un caso de prueba de caída con un contenedor de 1000 elementos, lo que sigue siendo un "placer" de depuración. Por lo tanto, los buenos marcos después de encontrar un caso feylyaschy aplican una serie de heurísticas para tratar de encontrar un conjunto más compacto de datos de entrada, que sin embargo continuará bloqueando la prueba. Y, por último, a menudo la mitad de la funcionalidad de prueba es un generador de datos de entrada, por lo que la presencia de generadores integrados y primitivas que le permiten construir rápidamente otros más complejos a partir de generadores simples también ayuda mucho.
También hay críticas ocasionales de que hay demasiadas pruebas lógicas basadas en propiedades. Sin embargo, esto suele ir acompañado de ejemplos al estilo de
data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut)
De hecho, es bastante común (para principiantes) antipatrón, ¡no hagas esto! Es mucho mejor dividir dicha prueba en dos diferentes, y omitir datos de entrada inapropiados (en muchos marcos incluso hay herramientas especiales para esto) si la posibilidad de llegar a ellos es pequeña, o usar generadores más especializados que producirán inmediatamente solo datos adecuados. El resultado debería ser algo como
data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut)
y
data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut)
Propiedades útiles y sus hábitats.
De acuerdo, ¿qué es útil para las pruebas basadas en propiedades? Parece claro, los principales peligros se han resuelto ... aunque no, lo principal todavía no está claro: ¿de dónde provienen estas propiedades? Intentemos buscar.
Al menos no caigas
La opción más fácil es introducir datos arbitrarios en el sistema bajo prueba y verificar que no se bloquee. De hecho, esta es una dirección completamente diferente con el nombre difuso de moda, para el cual existen herramientas especializadas (por ejemplo, AFL, también conocido como American Fuzzy Lop), pero con un poco de estiramiento puede considerarse un caso especial de prueba basado en propiedades, y si no hay ideas en mente Si no está escalando, entonces puedes comenzar con él. Sin embargo, como regla general, tales pruebas explícitamente rara vez tienen sentido, ya que las caídas potenciales generalmente salen muy bien al verificar otras propiedades. Las principales razones por las que menciono esta "propiedad" es para dirigir al lector a fuzzers y en particular a AFL (hay muchos artículos en inglés sobre este tema), bueno, para completar la imagen.
Prueba oráculo
Una de las propiedades más aburridas, pero de hecho es algo muy poderoso que se puede usar con mucha más frecuencia de lo que parece. La idea es que a veces hay dos piezas de código que hacen lo mismo, pero de diferentes maneras. Y luego, especialmente, no puede comprender generar datos de entrada arbitrarios, insertarlos en ambas opciones y verificar que los resultados coincidan. El ejemplo de aplicación citado con mayor frecuencia es cuando se escribe una versión optimizada de una función para dejar una opción lenta pero simple y ejecutar pruebas contra ella.
input = arbitrary_list() assert quick_sort(input) == bubble_sort(input)
Sin embargo, la aplicabilidad de esta propiedad no se limita a esto. Por ejemplo, muy a menudo resulta que la funcionalidad implementada por el sistema que queremos probar es un superconjunto de algo ya implementado, a menudo incluso en la biblioteca de idiomas estándar. En particular, la mayor parte de la funcionalidad de un almacenamiento de valores clave (en la memoria o en el disco, basado en árboles, tablas hash o algunas estructuras de datos más exóticas como el árbol merkle patricia) se puede probar con un diccionario estándar estándar. Prueba de todo tipo de CRUD, también allí.
Otra aplicación interesante que utilicé personalmente: a veces, al implementar un modelo numérico de un sistema, algunos casos particulares se pueden calcular analíticamente y comparar con ellos los resultados de la simulación. En este caso, como regla general, si intenta introducir datos completamente arbitrarios en la entrada, incluso con la implementación correcta, las pruebas seguirán cayendo debido a la precisión limitada (y, en consecuencia, la aplicabilidad) de las soluciones numéricas, pero durante el proceso de reparación al imponer restricciones a los datos de entrada generados, estas mismas restricciones darse a conocer.
Requisitos e invariantes
La idea principal aquí es que a menudo los requisitos en sí mismos están formulados para que sean fáciles de usar como propiedades. En algunos artículos sobre tales temas, los invariantes se resaltan por separado, pero en mi opinión, la frontera aquí es demasiado inestable, ya que la mayoría de estos invariantes son consecuencias directas de los requisitos, por lo que probablemente tiraré todo junto.
Una pequeña lista de ejemplos de una variedad de áreas adecuadas para verificar propiedades:
- el campo de clase debe tener un valor asignado previamente (getter-setters)
- el repositorio debe poder leer un elemento previamente grabado
- agregar un elemento previamente inexistente al repositorio no afecta los elementos agregados anteriormente
- en muchos diccionarios no se pueden almacenar varios elementos diferentes con la misma clave
- la altura equilibrada del árbol no debería ser más K cdotlog(N) donde N - número de elementos grabados
- El resultado de clasificación es una lista de artículos ordenados
- El resultado de codificación base64 debe contener solo caracteres base64
- el algoritmo de construcción de ruta debe devolver una secuencia de movimientos permitidos que conducirán del punto A al punto B
- para todos los puntos de las isolinas construidas deben cumplirse f(x,y)=const
- el algoritmo de verificación de firma electrónica debería devolver True si la firma es real y False de lo contrario
- Como resultado de la ortonormalización, todos los vectores en la base deben tener unidades de longitud y cero productos escalares mutuos
- las operaciones de transferencia y rotación de vectores no deben cambiar su longitud
En principio, se podría decir que todo está completo, el artículo está completo, usar oráculos de prueba o buscar propiedades en los requisitos, pero hay algunos "casos especiales" más interesantes que me gustaría señalar por separado.
Inducción y pruebas de estado
A veces necesitas probar algo con un estado. En este caso, la forma más fácil:
- escriba una prueba que verifique la corrección del estado inicial (por ejemplo, que el contenedor que acaba de crear está vacío)
- escriba un generador que utilizando un conjunto de operaciones aleatorias llevará al sistema a un estado arbitrario
- escribir pruebas para todas las operaciones utilizando el resultado del generador como estado inicial
Muy similar a la inducción matemática:
- probar la declaración 1
- probar la declaración N + 1, suponiendo que la declaración N es verdadera
Otro método (a veces dar un poco más de información sobre dónde se rompió) es generar una secuencia aceptable de eventos, aplicarla al sistema bajo prueba y verificar las propiedades después de cada paso.
De ida y vuelta
Si de repente hubo una necesidad de probar un par de funciones para la conversión directa e inversa de algunos datos, entonces considere que tiene mucha suerte:
input = arbitrary_data() assert decode(encode(input)) == input
Genial para probar:
- serialización-deserialización
- cifrado descifrado
- codificación-decodificación
- transforma la matriz base en cuaternión y viceversa
- transformación de coordenadas directa e inversa
- transformada de Fourier directa e inversa
Un caso especial pero interesante es la inversión:
input = arbitrary_data() assert invert(invert(input)) == input
Un ejemplo sorprendente es la inversión o transposición de una matriz.
Idempotencia
Algunas operaciones no cambian el resultado del uso repetido. Ejemplos tipicos:
- clasificación
- cualquier normalización de vectores y bases
- volver a agregar un elemento existente a un conjunto o diccionario
- volver a grabar los mismos datos en alguna propiedad del objeto
- transmitir datos a forma canónica (los espacios en JSON conducen a un estilo unificado, por ejemplo)
La idempotencia también se puede usar para probar la serialización-deserialización si la
decodificación habitual
(codificación (entrada)) == método de
entrada no es adecuada debido a diferentes representaciones posibles para datos de entrada equivalentes (nuevamente, espacios adicionales en algunos JSON):
def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input)
Diferentes maneras, un resultado
Aquí la idea se reduce a explotar el hecho de que a veces hay varias formas de hacer lo mismo. Esto puede parecer un caso especial del oráculo de prueba, pero en realidad no es así. El ejemplo más simple es usar la conmutatividad de algunas operaciones:
a = arbitrary_value() b = arbitrary_value() assert a + b == b + a
Puede parecer trivial, pero esta es una excelente manera de probar:
- suma y multiplicación de números en una representación no estándar (bigint, racional, eso es todo)
- "Adición" de puntos en curvas elípticas en campos finitos (¡hola, criptografía!)
- unión de conjuntos (que en su interior puede tener estructuras de datos completamente no triviales)
Además, la adición de elementos al diccionario tiene la misma propiedad:
A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B
La opción es más complicada: pensé durante mucho tiempo cómo describirla en palabras, pero solo me viene a la mente una notación matemática. En general, tales transformaciones son comunes.
f(x) para lo cual la propiedad tiene
f(x+y)=f(x) cdotf(y) , y tanto el argumento como el resultado de la función no son necesariamente solo un número, sino operaciones
+ y
cdot - solo algunas operaciones binarias en estos objetos. Lo que puedes probar con esto:
- suma y multiplicación de todo tipo de números extraños, vectores, matrices, cuaterniones ( a cdot(x+y)=a cdotx+a cdoty )
- operadores lineales, en particular todo tipo de integrales, diferenciales, convoluciones, filtros digitales, transformadas de Fourier, etc. ( F[x+y]=F[x]+F[y] )
- operaciones en objetos idénticos en diferentes representaciones, por ejemplo
- M(qa cdotqb)=M(qa) cdotM(qb) donde qa y qb Son cuaterniones individuales, y M(q) - operación de convertir un cuaternión en una matriz base equivalente
- F[a circb]=F[a] cdotF[b] donde a y b Son señales circ - convolución cdot - multiplicación, y F - Transformada de Fourier
Un ejemplo de una tarea un poco más "ordinaria": para probar un algoritmo de fusión de diccionario complicado, puede hacer algo como esto:
a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b)
En lugar de una conclusión
Eso es básicamente todo lo que quería contar en este artículo. Espero que haya sido interesante, y un poco más de personas comenzarán a poner todo esto en práctica. Para hacer la tarea un poco más fácil, le daré una lista de marcos de trabajo de diferentes grados de validez para diferentes idiomas:
Y, por supuesto, gracias especiales a las personas que alguna vez escribieron artículos maravillosos, gracias a los cuales aprendí sobre este enfoque hace un par de años, dejaron de preocuparse y comenzaron a escribir pruebas basadas en propiedades: