El desarrollo de programas de alta calidad implica que el programa y sus partes se prueban. La prueba de unidad clásica implica dividir un programa grande en bloques pequeños que son convenientes para la prueba. O, si el desarrollo de las pruebas se lleva a cabo en paralelo con el desarrollo del código o las pruebas se desarrollan antes del programa (TDD - desarrollo impulsado por pruebas), el programa se desarrolla inicialmente en pequeños bloques adecuados para los requisitos de las pruebas.
Una de las variedades de pruebas unitarias puede considerarse una prueba basada en la propiedad (este enfoque se implementa, por ejemplo, en las bibliotecas QuickCheck , ScalaCheck ). Este enfoque se basa en encontrar propiedades universales que deberían ser válidas para cualquier dato de entrada. Por ejemplo, la serialización seguida de la deserialización debería producir el mismo objeto . O bien, la reordenación no debería cambiar el orden de los elementos de la lista . Para verificar tales propiedades universales, las bibliotecas anteriores admiten un mecanismo para generar datos de entrada aleatorios. Este enfoque funciona especialmente bien para programas basados en leyes matemáticas que sirven como propiedades universales que son válidas para una amplia clase de programas. Incluso hay una biblioteca de propiedades matemáticas preparadas ( disciplina ) que le permite verificar el rendimiento de estas propiedades en nuevos programas (un buen ejemplo de reutilización de pruebas).
A veces resulta que es necesario probar un programa complejo sin poder analizarlo en partes verificables independientemente. En este caso, el programa de prueba es negro caja blanca (blanca, porque tenemos la oportunidad de estudiar la estructura interna del programa).
Debajo del corte, se describen varios enfoques para probar programas complejos con una entrada con diferentes grados de complejidad (participación) y diferentes grados de cobertura.
* En este artículo, suponemos que el programa bajo prueba se puede representar como una función pura sin un estado interno. (Algunas de las siguientes consideraciones se pueden aplicar si el estado interno está presente, pero es posible restablecer este estado a un valor fijo).
Banco de pruebas
En primer lugar, dado que solo se prueba una función, cuyo código de llamada es siempre el mismo, no necesitamos crear pruebas unitarias separadas. Todas esas pruebas serían las mismas, precisas para la entrada y las comprobaciones. Es suficiente transmitir los datos de origen ( input
) en un bucle y verificar los resultados ( input
expectedOutput
). Para identificar un conjunto problemático de datos de prueba en caso de detección de errores, todos los datos de prueba deben estar etiquetados. Por lo tanto, un conjunto de datos de prueba se puede representar como un triple:
case class TestCase[A, B](label: String, input: A, expectedOutput: B)
El resultado de una ejecución se puede representar como TestCaseResult
:
case class TestCaseResult[A, B](testCase: TestCase[A, B], actualOutput: Try[B])
(Presentamos el resultado del lanzamiento usando Try
detectar posibles excepciones).
Para simplificar la ejecución de todos los datos de prueba a través del programa bajo prueba, puede usar una función auxiliar que llamará al programa para cada valor de entrada:
def runTestCases[A, B](cases: Seq[TestCase[A, B])(f: A => B): Seq[TestCaseResult[A, B]] = cases .map{ testCase => TestCaseResult(testCase, Try{ f(testCase.input) } ) } .filter(r => r.actualOutput != Success(r.testCase.expectedOutput))
Esta función auxiliar devolverá los datos problemáticos y los resultados que son diferentes de lo esperado.
Para mayor comodidad, puede formatear los resultados de la prueba.
def report(results: Seq[TestCaseResult[_, _]]): String = s"Failed ${results.length}:\n" + results .map(r => r.testCase.label + ": expected " + r.testCase.expectedOutput + ", but got " + r.actualOutput) .mkString("\n")
y mostrar un informe solo en caso de errores:
val testCases = Seq( TestCase("1", 0, 0) ) test("all test cases"){ val testBench = runTestCases(testCases) _ val results = testBench(f) assert(results.isEmpty, report(results)) }
Preparación de entrada
En el caso más simple, puede crear manualmente datos de prueba para probar el programa, escribirlos directamente en el código de prueba y usarlos, como se muestra arriba. A menudo resulta que los casos interesantes de datos de prueba tienen mucho en común y pueden presentarse como una instancia básica, con cambios menores.
val baseline = MyObject(...)
Cuando se trabaja con estructuras de datos inmutables anidadas, las lentes son de gran ayuda, por ejemplo, de la biblioteca Monocle :
val baseline = ??? val testObject1 = (field1 composeLens field2).set("123")(baseline)
Las lentes le permiten "modificar" con elegancia las partes profundamente anidadas de las estructuras de datos: cada lente es un captador y configurador para una propiedad. Las lentes se pueden combinar para producir lentes que se "enfoquen" en el siguiente nivel.
Usando DSL para presentar cambios
A continuación, consideraremos la formación de datos de prueba haciendo cambios en algún objeto de entrada inicial. Por lo general, para obtener el objeto de prueba que necesitamos, necesitamos hacer algunos cambios. Al mismo tiempo, es muy útil incluir una lista de cambios en la descripción del texto de TestCase:
val testCases = Seq( TestCase("baseline", baseline, ???), TestCase("baseline + " + "(field1 = 123) + " +
Entonces siempre sabremos para qué datos de prueba se realiza la prueba.
Para que la lista textual de cambios no difiera de los cambios reales, debe seguir el principio de "una única versión de la verdad". (Si se requiere / utiliza la misma información en varios puntos, entonces debería haber una única fuente primaria de información única, y la información debería distribuirse a todos los demás puntos de uso automáticamente, con las transformaciones necesarias. Si se viola este principio y la copia manual de la información es inevitable . información de la versión discrepancia en diferentes puntos en otras palabras, en la descripción de los datos de prueba, que vemos uno, y datos de prueba -. otro ejemplo, la copia de un cambio field2 = "456"
y realizar los ajustes en el field3 = "789"
que Mauger accidentalmente se olvide de corregir la descripción. Como resultado, la descripción reflejará solamente dos cambios de tres).
En nuestro caso, la fuente principal de información son los cambios en sí mismos, o más bien, el código fuente del programa que realiza los cambios. Nos gustaría deducir de ellos un texto que describe los cambios. De manera informal, como primera opción, puede sugerir el uso de una macro que capturará el código fuente de los cambios y usar el código fuente como documentación. Aparentemente, esta es una forma buena y relativamente sencilla de documentar los cambios reales y bien puede aplicarse en algunos casos. Desafortunadamente, si presentamos los cambios en texto plano, perdemos la capacidad de realizar transformaciones significativas de la lista de cambios. Por ejemplo, detecte y elimine cambios duplicados o superpuestos, elabore una lista de cambios de una manera conveniente para el usuario final.
Para poder manejar los cambios, debe tener un modelo estructurado de ellos. El modelo debe ser lo suficientemente expresivo como para describir todos los cambios que nos interesan. Parte de este modelo, por ejemplo, será el direccionamiento de campos de objetos, constantes, operaciones de asignación.
El modelo de cambio debería permitir resolver las siguientes tareas:
- Generar instancias de modelo de cambio. (Es decir, crear una lista específica de cambios).
- Formación de una descripción textual de los cambios.
- Aplicación de cambios a objetos de dominio.
- Realizando transformaciones de optimización en el modelo.
Si se usa un lenguaje de programación universal para realizar cambios, puede ser difícil representar estos cambios en el modelo. El código fuente del programa puede usar construcciones complejas que no son compatibles con el modelo. Dicho programa puede usar patrones secundarios, como lentes o el método de copy
, para cambiar los campos de un objeto, que son abstracciones de nivel inferior en relación con el nivel del modelo de cambio. Como resultado, puede ser necesario un análisis adicional de dichos patrones para generar instancias de cambios. Por lo tanto, inicialmente una buena opción usando una macro no es muy conveniente.
Otra forma de crear instancias del modelo de cambio puede ser un lenguaje especializado (DSL), que crea objetos de modelo de cambio utilizando un conjunto de métodos de extensión y operadores auxiliares. Bueno, en los casos más simples, las instancias del modelo de cambio se pueden crear directamente a través de los constructores.
Cambiar detalles del idiomaEl cambio de lenguaje es una construcción bastante compleja que incluye varios componentes que, a su vez, no son triviales.
- Modelo de estructura de datos.
- Cambiar modelo
- DSL realmente integrado (?): Construcciones auxiliares, métodos de extensión, para la construcción conveniente de cambios.
- Un intérprete de cambios que le permite realmente "modificar" un objeto (de hecho, por supuesto, crear una copia modificada).
Aquí hay un ejemplo de un programa escrito usando DSL:
val target: Entity[Target]
Es decir, utilizando los métodos de extensión \
y :=
, PropertyAccess
, los objetos SetProperty
se forman a partir de los objetos target
, field1
, subobject
, field2
creados previamente. Además, debido a las conversiones implícitas (peligrosas), la cadena "123" está empaquetada en un LiftedString
(puede hacerlo sin conversiones implícitas y llamar explícitamente al método correspondiente: lift("123")
).
Una ontología escrita puede usarse como modelo de datos (consulte https://habr.com/post/229035/ y https://habr.com/post/222553/ ). (En resumen: se declaran objetos de nombre que representan las propiedades de cualquier tipo de dominio: val field1: Property[Target, String]
). En este caso, los datos reales se pueden almacenar, por ejemplo, en forma de JSON. La conveniencia de una ontología tipificada en nuestro caso radica en el hecho de que el modelo de cambio generalmente opera con propiedades individuales de los objetos, y la ontología solo proporciona una herramienta adecuada para abordar las propiedades.
Para representar los cambios, necesita un conjunto de clases del mismo plan que la clase SetProperty
anterior:
Modify
- aplicación de la función,Changes
: aplicar múltiples cambios secuencialmenteForEach
: aplique los cambios a cada elemento de la colección,- etc.
El intérprete de cambio de lenguaje es un evaluador de expresiones recursivas regular basado en PatternMatching. Algo como:
def eval(expression: DslExpression, gamma: Map[String, Any]): Any = expression match { case LiftedString(str) => str case PropertyAccess(obj, prop) => Getter(prop)(gamma).get(obj) } def change[T] (expression: DslChangeExpression, gamma: Map[String, Any], target: T): T = expression match { case SetProperty(path, valueExpr) => val value = eval(valueExpr, gamma) Setter(path)(gamma).set(value)(target) }
Para operar directamente sobre las propiedades de los objetos, debe especificar getter y setter para cada propiedad utilizada en el modelo de cambio. Esto se puede lograr completando el mapa entre las propiedades ontológicas y sus lentes correspondientes.
Este enfoque en su conjunto funciona y, de hecho, le permite describir los cambios una vez, pero gradualmente es necesario representar cambios cada vez más complejos y el modelo de cambios está creciendo un poco. Por ejemplo, si necesita cambiar una propiedad utilizando el valor de otra propiedad del mismo objeto (por ejemplo, field1 = field2 + 1
), debe admitir variables en el nivel DSL. Y si cambiar una propiedad no es trivial, a nivel DSL, se requiere soporte para expresiones y funciones aritméticas.
Prueba de rama
El código de prueba puede ser lineal y, en general, un conjunto de datos de prueba es suficiente para comprender si funciona. Si hay una rama ( if-then-else
), debe ejecutar el cuadro blanco al menos dos veces con diferentes datos de entrada para que ambas ramas se ejecuten. El número de conjuntos de datos de entrada suficientes para cubrir todas las ramas es aparentemente numéricamente igual a la complejidad ciclomática del código con ramas.
¿Cómo formar todos los conjuntos de datos de entrada? Como estamos tratando con un cuadro blanco, podemos aislar las condiciones de ramificación y modificar el objeto de entrada dos veces para que en un caso se ejecute una rama, en el otro caso. Considere un ejemplo:
if (object.field1 == "123") A else B
Teniendo tal condición, podemos formar dos casos de prueba:
val testCase1 = TestCase("A", field1.set("123")(baseline), ) val testCase2 = TestCase("B", field1.set("123" + "1">)(baseline), )
(En caso de que no se pueda crear uno de los escenarios de prueba, podemos suponer que se ha detectado un código muerto y que la condición, junto con la rama correspondiente, se puede eliminar de forma segura).
Si se comprueban las propiedades independientes de un objeto en varias ramas, entonces es bastante simple formar un conjunto exhaustivo de objetos de prueba modificados que cubra completamente todas las combinaciones posibles.
DSL para formar todas las combinaciones de cambiosConsideremos con más detalle el mecanismo que permite formar todas las listas posibles de cambios que brindan una cobertura total de todas las sucursales. Para usar la lista de cambios durante las pruebas, necesitamos combinar todos los cambios en un solo objeto, que enviaremos a la entrada del código probado, es decir, se requiere soporte para la composición. Para hacer esto, puede usar el DSL anterior para modelar cambios, y luego una simple lista de cambios es suficiente, o puede presentar un cambio como una función de modificación T => T
:
val change1: T => T = field1.set("123")(_)
entonces la cadena de cambios será simplemente una composición de funciones:
val changes = change1 compose change2
o, para obtener una lista de cambios:
val rawChangesList: Seq[T => T] = Seq(change1, change2) val allChanges: T => T = rawChangesList.foldLeft(identity)(_ compose _)
Para registrar de forma compacta todos los cambios correspondientes a todas las ramas posibles, puede usar el DSL del siguiente nivel de abstracción, que simula la estructura del cuadro blanco probado:
val tests: Seq[(String, T => T)] = IF("field1 == '123'")
Aquí la colección de tests
contiene cambios agregados correspondientes a todas las combinaciones posibles de ramas. Un parámetro de tipo String
contendrá todos los nombres de las condiciones y todas las descripciones de los cambios a partir de los cuales se forma la función de cambio agregado. Y el segundo elemento de un par de tipo T => T
es solo la función agregada de los cambios obtenidos como resultado de la composición de los cambios individuales.
Para obtener los objetos modificados, debe aplicar todas las funciones de cambio agregado al objeto de línea de base:
val tests2: Seq[(String, T)] = tests.map(_.map_2(_(baseline)))
Como resultado, obtenemos una colección de pares, y la línea describirá los cambios aplicados, y el segundo elemento del par será el objeto en el que se combinan todos estos cambios.
Según la estructura del modelo del código probado en forma de árbol, las listas de cambios representarán la ruta desde la raíz hasta las hojas de este árbol. Por lo tanto, una parte significativa de los cambios se duplicará. Puede deshacerse de esta duplicación utilizando la opción DSL, en la que los cambios se aplican directamente al objeto de línea de base a medida que avanza por las ramas. En este caso, se realizarán menos cálculos innecesarios.
Como estamos tratando con una caja blanca, podemos ver todas las ramas. Esto hace posible construir un modelo de lógica contenido en un cuadro blanco y usar el modelo para generar datos de prueba. Si el código de prueba está escrito en Scala, puede, por ejemplo, usar scalameta para leer el código, con la posterior conversión a un modelo lógico. Nuevamente, como en el tema previamente discutido de modelar la lógica de los cambios, es difícil para nosotros modelar todas las posibilidades de un lenguaje universal. Además, asumiremos que el código probado se implementa utilizando un subconjunto limitado del idioma, o en otro idioma o DSL, que inicialmente es limitado. Esto nos permite centrarnos en aquellos aspectos del lenguaje que nos interesan.
Considere un ejemplo de código que contiene una sola rama:
if(object.field1 == "123") A else B
La condición divide el conjunto de valores de field1
en dos clases de equivalencia: == "123"
y != "123"
. Por lo tanto, todo el conjunto de datos de entrada también se divide en dos clases de equivalencia con respecto a esta condición: ClassCondition1IsTrue
y ClassCondition1IsFalse
. Desde el punto de vista de la cobertura completa, es suficiente que tomemos al menos un ejemplo de estas dos clases para cubrir ambas ramas A
y B
Para la primera clase, podemos construir un ejemplo, en cierto sentido, de una manera única: tome un objeto aleatorio, pero cambie field1
a "123"
. Además, el objeto ciertamente ClassCondition1IsTrue
en la clase de equivalencia ClassCondition1IsTrue
y los cálculos irán a lo largo de la rama A
Hay más ejemplos para la segunda clase. Una forma de generar algún ejemplo de la segunda clase es generar objetos de entrada arbitrarios y descartar aquellos con field1 == "123"
. Otra forma: tomar un objeto aleatorio, pero cambie el field1
a "123" + "*"
(para modificar, puede usar cualquier cambio en la línea de control para asegurarse de que la nueva línea no sea igual a la línea de control).
Arbitrary
y Gen
de la biblioteca ScalaCheck son bastante adecuados como Arbitrary
datos aleatorios.
Esencialmente, llamamos a la función booleana utilizada en la if
. Es decir, encontramos todos los valores del objeto de entrada para los cuales esta función booleana toma el valor true
- ClassCondition1IsTrue
, y todos los valores del objeto de entrada para el cual toma el valor false
- ClassCondition1IsFalse
.
De manera similar, es posible generar datos adecuados para las restricciones generadas por operadores condicionales simples con constantes (más / menos que una constante, incluida en un conjunto, comienza con una constante). Tales condiciones son fáciles de revertir. Incluso si se invocan funciones simples en el código de prueba, podemos reemplazar su llamada con su definición (en línea) y aún invertir expresiones condicionales.
Funciones reversibles duras
La situación es diferente cuando la condición usa una función que es difícil de revertir. Por ejemplo, si se utiliza una función hash, no parece posible generar automáticamente un ejemplo que proporcione el valor deseado del código hash.
En este caso, puede agregar un parámetro adicional al objeto de entrada que represente el resultado del cálculo de la función, reemplazar la llamada de función con una llamada a este parámetro y actualizar este parámetro, a pesar de la violación de la conexión funcional:
if(sha(object.field1)=="a9403...") ... // ==> if(object.sha_field1 == "a9403...") ...
Un parámetro adicional permite la ejecución de código dentro de la rama, pero, obviamente, puede conducir a resultados realmente incorrectos. Es decir, el programa de prueba producirá resultados que nunca se pueden observar en la realidad. Sin embargo, verificar parte del código que de otro modo sería inaccesible para nosotros sigue siendo útil y puede considerarse como una forma de prueba unitaria. Después de todo, incluso durante las pruebas unitarias, se llama a una subfunción con argumentos que nunca se pueden usar en el programa.
Con tales manipulaciones, reemplazamos (reemplazamos) el objeto de prueba. Sin embargo, en cierto sentido, el programa recién creado incluye la lógica del programa anterior. De hecho, si como valores de los nuevos parámetros artificiales tomamos los resultados del cálculo de las funciones que reemplazamos con los parámetros, el programa producirá los mismos resultados. Aparentemente, probar el programa modificado aún puede ser de interés. Solo necesita recordar bajo qué condiciones el programa modificado se comportará igual que el original.
Condiciones dependientes
, . , , , . , . (, , x > 0
, — x <= 1
. , — (-∞, 0]
, (0, 1]
, (1, +∞)
, — .)
, , , true
false
. , , " " .
, , :
if(x > 0) if(y > 0) if (y > x)
( > 0
, — y > x
.)
"", , , , , . , " " .
, "", ( y == x + 1
), , .
"" ( y > x + 1 && y < x + 2
), , .
, , - , "c " ( Symbolic Execution , ), . ( field1 = field1_initial_value
). , . :
val a = field1 + 10
— true
false
. . . Por ejemplo
if(a > 0) A else B
, , , , . , (, , ).
. , , . , . , .
, . . , , . , , , . , , , , , ?
Y- ( " " , stackoverflow:What is a Y-combinator? (2- ) , habr: Y- 7 ). , . ( , , .) . , . , "" . Y- " " ( ).
( ). , . , . , . , , , TestCase
'. , , ( throw
Nothing
bottom
, ). .
, . . , , . , . , , . , , , , . , . , .
, , , , 100% . , , . Hm. . , , , ? , , - .
:
- .
- ( ).
- , .
- , .
, , . -, , , , . -, , , ( , ), , , "" . / , .
Conclusión
" " " ". , , , , . , .
, , , , . -, , ( ), . -, -, . DSL, , . -, , . -, , ( , , ). .
, , . , , - .
Agradecimientos
@mneychev .