Property-based testing con ScalaCheck.

0
1885

En este tutorial veremos como implementar nuestros test mediante Property-based testing, en concreto haciendo uso de la biblioteca ScalaCheck.

Índice de contenidos

1. Introducción

En 1999, Koen Claessen y John Hughes consideraron necesario encontrar una forma de probar sus aplicaciones automáticamente, tan solo especificando las funcionalidades que definían sus aplicaciones.

Así es como comenzaron a desarrollar su librería de combinadores mediante la cual podrían generar casos de test de manera automática para sus conjuntos de pruebas. La librería se llamó QuickCheck.

Esta herramienta ha sido re-implementada en múltiples ocasiones con el paso del tiempo. Ejemplos de estas reimplementaciones pueden ser QuickTheories para Java, Theft para C ó SwiftCheck para Swift.

Este tutorial se centrará en una la reimplementación de QuickCheck adaptada al lenguaje Scala: ScalaCheck.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro Retina 15′ (2,5 Ghz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS El High Sierra 10.13.2
  • Entorno de desarrollo: IntelliJ IDEA 2017.3.3

3. ¿Por qué utilizar property-based testing en lugar de example-based testing?

La mayoría de los tests que se escriben actualmente son tests guiados por ejemplos, tests en los que
se establecen una serie de datos concretos para nuestras funciones y a continuación se comprueba que
el resultado de la computación se corresponde con los valores esperados.

El enfoque de property-based testing o pruebas guiadas por propiedades, se basa en establecer un conjunto
de valores de entrada y determinar si los resultados obtenidos son correctos, a través de propiedades,
entendiendo por propiedad una sentencia que siempre es cierta para unas condiciones particulares.

Estableciendo estas propiedades o axiomas sobre nuestro código, forzamos la realización del ejercicio de acotar los argumentos, la lógica y el resultado de cada una de nuestras funciones, aumentando así la fiabilidad de nuestro código.

4. Las bases

La unidad de prueba de ScalaCheck son las propiedades. Una propiedad es una instancia de la clase Prop, que puede ser instanciada de múltiples maneras.

Un ejemplo de estas vías de instanciamiento puede ser el método forAll, que toma una función como parámetro y crea una propiedad. La función deberá devolver como resultado un valor booleano u otra propiedad, y puede tomar parámetros de cualquier tipo, mientras existan instancias implícitas de la clase Arbitrary para dichos tipos.

Las instancias de Arbitrary son las encargadas de generar los valores que recibirán las propiedades como parámetros. Por defecto ScalaCheck proveerá de instancias de Arbitrary para los tipos más comunes (Int, string, List, etc…) pero en el momento en el que se comience a trabajar con objetos/tipos propios será necesario declarar las instancias de Arbitrary para los mismos.

Ejemplos de uso de forAll:

val propertyIdentityValue = Prop.forAll {
	(s1: String) => (s + "") == s
}
val propertyCompositionLength = Prop.forAll {
  (s1: String, s2:String) => (s1+s2).length == s1.length + s2.length
}

Como se puede observar en los ejemplos anteriores se definen las propiedades que debe cumplir operación suma para el tipo String, en concreto valor identidad y composición.

Cuando se ejecuta el método check sobre una propiedad, ScalaCheck generará de manera aleatoria los valores para los parámetros declarados y los evaluará sobre la función que da cuerpo a la propiedad, informando en aquellos casos en los que encuentre discrepancias.

En ninguno de los ejemplos necesitamos proveer explicitamente un generador de datos a ScalaCheck porque la propia biblioteca se encarga de proveerlos a través del mecanismo de implícitos.

En aquellos casos en los que sea necesario establecer un contexto concreto, será posible declarar generadores específicos. Para definir un generador propio tendremos que hacer uso de la clase Gen.

Si bien con posterioridad se profundizará en la codificación de generadores, sirva el siguiente snippet de código a modo de ejemplo:

org.scalacheck.Gen.choose(0,100)

El código anterior instanciará un generador aleatorio de valores enteros entre 0 y 100.

Otra manera de crear propiedades es mediante la combinación de propiedades ya existentes.

Dadas dos propiedades (propiedadA y propiedadB), existen las siguientes maneras de combinarlas:

  • propiedadA && propiedadB ó all(propiedadA,propiedadB)
  • propiedadA || propiedadB ó atLeastOne(propiedadA, propiedadB)
  • propiedadA == propiedadB

Cuando se dispone de un conjunto de propiedades relacionadas entre sí, estas pueden agruparse a través de la interfaz Properties. Esta interfaz dispone de un método principal que puede ser utilizado para una ejecución simple del test basado en propiedades.

object MiEspecificación extends Properties("Mi funcionalidad") {
  import Prop.forAll

  property("propiedad a") = forAll {...}

  property("propiedad b") = forAll {...}

  property("propiedad c") = forAll(...) {...}

  ...
}

5. Generadores

Generadores preestablecidos

Tal y como se vió en el punto anterior, los generadores se representan mediante la clase org.scalacheck.Gen.

Existe un conjunto de generadores pre-existentes para los tipos más comunes por defecto en ScalaCheck, más concretamente en el companion object de la clase Gen. A continuación se muestra un subconjunto de generadores mediante ejemplos:

alphaStr: Generar un String aleatorio.

Gen.alphaStr

choose: Elegir un valor comprendido en un intervalo concreto.

Gen.choose(0, 1000)

oneOf: Elige un valor aleatorio de un conjunto de valores concreto.

Gen.oneOf('A', 'B', 'C', 'D')

frequency: Se puede establecer una frecuencia concreta de aparición sobre cada valor para los datos establecidos.

Gen.frequency(('A',5),('B','1'),('C',1),('D',1),('E',1),('F',1))

Si se necesitan crear generadores de datos personalizados para dar respuesta
a situaciones más complejas se puede hacer mediante la agregación de diferentes Generadores
pre-existenes. Por ejemplo:

val personaGen: Gen[(Persona, String, String, Double)] =
  for {
    nombre <- Gen.alphaStr
    email <- Gen.alphaStr
    edad <- Gen.choose(0,130)
  } yield(Persona(nombre,email,edad))

La definición de generadores personalizados mediante for-comprehension es una práctica generalizada, sin embargo no es obligatorio realizarlo de esta manera, el único requisito es que la función devuelva el tipo adecuado.

Generadores Arbitrarios

Los generadores arbitrarios son un tipo especial de generadores que se construyen sobre generadores ya existentes (instancias de Gen) y que permiten simplificar las propiedades implementando la generación de datos a través de la definición de funciones implícitas y estableciendo dichas funciones en el ámbito adecuado.

Los 2 pasos necesarios para crear un generador arbitrario son:

  • 1.- Crear un generador (por ejemplo personaGen).
  • 2.- Recubrir dicho generador con la clase Arbitrary y definiéndola como un
    valor implícito.
val personaGen: Gen[(Persona, String, String, Double)] =
  for {
    nombre <- Gen.alphaStr
    email <- Gen.alphaStr
    edad <- Gen.choose(0,130) } yield(Persona(nombre,email,edad)) implicit val arbPersonaGen = Arbitrary(personaGen) property("propiedad A") = forAll { (persona: Persona) => ...
}

6. Ejemplo

Pogamos por ejemplo un caso hipotético de una aplicación que gestione recetas de cocina.

	object Example {
		case class Ingredient(title: String, servings: Int, calories: Double) {
	    require(servings > 0, "La cantidad del ingrediente tiene que ser mayor que 0.")
	    require(!"".equals(title), "El nombre de este ingrediente no puede ser vacío.")
	  }

	  case class Recipe(title: String, ingredients: List[Ingredient], instructions: String) {
	    def servings: Int =
	      ingredients
	        .reduceLeftOption(
	          (ingredient1, ingredient2) =>
	            if (ingredient1.servings < ingredient2.servings) ingredient1 else ingredient2 ).fold(0)(_.servings) def totalCalories: Double = ingredients.foldRight(0.0)(_.calories + _) def isHypercaloric: Boolean = totalCalories > 2000.0
	  }
	}

	class ExampleTest extends PropSpec with Matchers with PropertyChecks {
	  val ingredientGen: Gen[Ingredient] =
	    for {
	      title <- arbitrary[String].suchThat(!_.isEmpty)
	      servings <- Gen.choose(1,5)
	      calories <- Gen.choose(1,5000)
	    } yield (Ingredient(title, servings, calories))

	  val recipeGen: Gen[Recipe] =
	    for {
	      title <- Gen.alphaStr
	      ingredients <- Gen.listOfN(5,ingredientGen)
	      instrunctions <- Gen.alphaStr } yield(Recipe(title, ingredients, instrunctions)) property("The recipe should concrete if a recipe is hypercaloric") { forAll(recipeGen) { (recipe:Recipe) =>
	        PropertyChecks.whenever(recipe.totalCalories > 2000.0) {
	          recipe.isHypercaloric shouldBe true
	        }
	    }
	  }
	}

7. Conclusiones

Este cambio de perspectiva sobre los tests puede resultar complejo inicialmente, puesto que las pruebas basadas en propiedades exigen tener la capacidad de deducir las propiedades que debe cumplir nuestro código para todos los conjuntos de datos de entrada.

Esta complejidad trae consigo grandes beneficios sobre nuestro código, el código que cumpla con estas reglas será mucho más robusto y fiable de acuerdo a las propiedades establecidas.

En este caso, la recomendación pasa por disponer de una buena suite de test unitarios que cubran, en la mayoría de los casos, nuestro código, y confiar en el property-based testing para aquella lógica que tenga un peso realmente importante en las aplicaciones.

A lo largo del tutorial también se han mostrado los generadores, una herramienta básica en el property-based testing muy potente cuyo ámbito de aplicación no tiene porqué verse reducido únicamente a los test basados en propiedades.

Un claro ejemplo de explotación de los generadores puede verse en los test unitarios, ayudando en la creación de Object Mothers cuya aplicación viene siendo una práctica extendida desde hace tiempo, por las numerosas ventajas que ofrece el tener centralizada la creación de sujetos necesarios para las pruebas.

8. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad