Generadores a partir de GPAN
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/testing-kt
Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupPrngModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupPrngModule") {
description = "Creates the base module and files for the PRNG based generator lesson"
module.set("pbt:arbitrary:prng")
doLast {
createFiles(
"lists",
main to "average.kt",
test to "AverageTest.kt",
)
createFiles(
"maps",
test to "IntStringMapTest.kt",
)
}
}
Preocúpate de que el plugin prng
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupPrngModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
En el contexto de las pruebas basadas en propiedades, la generación de datos aleatorios reproducibles es clave. Para ello, se recurre a generadores pseudoaleatorios de números (GPAN, pseudorandom number generator o PRNG por sus siglas en inglés), que son algoritmos deterministas capaces de producir secuencias de números que simulan ser aleatorias. Aunque estas secuencias no son verdaderamente aleatorias —ya que están completamente determinadas por un estado inicial (la semilla)—, resultan ser suficientemente impredecibles y variadas para muchas aplicaciones prácticas, como simulaciones por el método de Monte Carlo, criptografía, y testing automatizado.
La mayoría de los GPAN modernos, como el Mersenne Twister o Blum Blum Shub, están diseñados para pasar múltiples pruebas estadísticas de aleatoriedad, lo que permite confiar en su calidad. No obstante, como advertía Robert R. Coveyou: "La generación de números aleatorios es demasiado importante como para ser dejada al azar", por lo que estos algoritmos requieren un análisis matemático cuidadoso para validar su uso en contextos críticos.
En Kotest, una de las formas más simples de utilizar un GPAN es a través de la función de orden superior arbitrary
. Esta función permite definir generadores personalizados que aprovechan el generador pseudoaleatorio interno de Kotest, ofreciendo así una herramienta poderosa para crear datos de prueba controlados, variados y reproducibles, fundamentales en la verificación automatizada de propiedades.
📋 Ejemplo: Generador de listas de números
🧭 Definiendo la especificación
Supongamos que queremos generar listas de números reales (Double) de tamaño arbitrario para probar una función que calcula el promedio. Para lograrlo, podemos definir un generador que cree un par de datos: el primer elemento será una lista de números y el segundo será el promedio esperado de esa lista. De esta manera, podremos validar fácilmente si la función de promedio calcula correctamente el resultado.
Comencemos por definir una especificación BDD (Behavior-Driven Development) para nuestro test:
"Given a list of integers" - {
"when calculating the average of a non-empty list" - {
("should return the sum of the elements divided by the number of " +
"elements") {}
}
}
Este enfoque sigue el flujo natural del lenguaje BDD, lo que permite expresar el comportamiento de la función de manera clara y comprensible.
Ahora implementamos los detalles de nuestro test, donde usaremos un generador para crear pares de listas de números y sus promedios correspondientes:
checkAll(...) { (list, expectedAverage) ->
average(list) shouldBe expectedAverage
}
Este ejemplo ilustra cómo esperamos que nuestro generador funcione. Generará pares de listas de números junto con sus promedios esperados. Luego, el test verificará que el promedio calculado por nuestra función sea igual al promedio esperado generado previamente. Esto garantiza que nuestra implementación de la función de promedio sea correcta bajo una variedad de casos de prueba.
🛠️ Implementando el generador
Para crear un generador personalizado en Kotest, utilizaremos la función arbitrary
, que nos permite definir cómo se generan los datos de prueba según nuestro caso de uso específico. A continuación, seguiremos dos convenciones para mantener nuestro código limpio y organizado:
- El nombre de todos los generadores debe comenzar con
arb
, indicando que estamos definiendo un generador arbitrario. - Especificamos claramente el tipo de datos que generará, lo que mejora la legibilidad y facilita la comprensión del propósito del generador.
- Código esencial
- Código completo
private typealias ListAndAverage = Pair<MutableList<Double>, Double>
private fun arbIntListAndAverage(): Arb<ListAndAverage> =
arbitrary { (random, seed) ->
val list = mutableListOf<Int>()
val size = random.nextInt(1, 100)
var average = 0
repeat(size) {
val number = random.nextInt(1, 100)
list += number
average += number / size
}
list to average
}
checkAll(arbIntListAndAverage()) { (list, expectedAverage) ->
average(list)
.shouldBeGreaterThanOrEqual(expectedAverage - 0.0001)
.shouldBeLessThan(expectedAverage + 0.0001)
}
package com.github.username.lists
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
import io.kotest.matchers.doubles.shouldBeLessThan
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
typealias ListAndAverage = Pair<MutableList<Double>, Double>
class AverageTest : FreeSpec({
"Given a list of integers" - {
"when calculating the average of a non-empty list" - {
("should return the sum of the elements divided by the number of " +
"elements") {
checkAll(arbIntListAndAverage()) { (list, average) ->
average(list)
.shouldBeGreaterThanOrEqual(average - 0.0001)
.shouldBeLessThan(average + 0.0001)
}
}
}
}
})
private fun arbIntListAndAverage(): Arb<ListAndAverage> = arbitrary { (random, seed) ->
val list = mutableListOf<Double>()
val size = random.nextInt(1, 100)
var average = 0.0
repeat(size) {
val number = random.nextDouble(1.0, 100.0)
list += number
average += number / size
}
list to average
}
- Definimos un alias
ListAndAverage
para representar un par que contiene una lista de números reales y su promedio aproximado. Esto hace que el propósito del generador sea más explícito. - Implementamos un generador llamado
arbIntListAndAverage
que produce listas aleatorias y calcula un promedio de forma deliberadamente distinta a la implementación real:- En lugar de sumar todos los elementos y luego dividir, el generador utiliza una acumulación parcial: suma el resultado de dividir cada número por el tamaño de la lista.
- Esta forma alternativa introduce pequeñas imprecisiones por errores de redondeo, lo cual es intencional.
- ¿Por qué hacerlo así?
- El objetivo del generador no es replicar exactamente la lógica de la función
average
, sino proporcionar un valor de referencia suficientemente cercano que no dependa de la misma lógica que estamos testeando. - Al usar una fórmula distinta, aumentamos la confianza de que estamos validando correctamente el comportamiento de la función.
- El objetivo del generador no es replicar exactamente la lógica de la función
- Para compensar la posible diferencia introducida por la forma alternativa de cálculo, el test no exige una igualdad exacta, sino que verifica que el resultado esté dentro de un margen de tolerancia razonable (
±0.0001
). - La función
arbitrary
recibe una pareja(random, seed)
, donderandom
es un generador de números aleatorios que usamos para construir nuestros datos.
Cuando escribas un generador para una prueba basada en propiedades, evita replicar directamente la lógica de la función bajo prueba. Si ambos usan el mismo algoritmo, el test puede pasar incluso si la lógica está equivocada en ambos lados.
En lugar de eso, genera los datos de entrada de forma aleatoria y calcula los valores esperados usando una lógica alternativa, una aproximación o una propiedad matemática conocida. Luego, compara los resultados permitiendo un pequeño margen de error si es necesario (por ejemplo, con tolerancia para Double
o Float
).
💡 Esto ayuda a que el test valide el comportamiento real de la función y no simplemente replique su implementación.