Skip to main content

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

Be lazy...

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
}
¿Qué acabamos de hacer?

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.
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)
}
¿Qué acabamos de hacer?
  • 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.
  • 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), donde random es un generador de números aleatorios que usamos para construir nuestros datos.
Buenas prácticas al diseñar generadores

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.

🧮 Implementando la función de promedio

Finalmente, implementamos la función average que calcula el promedio de una lista de números. Es importante recordar que la implementación de la función debe ser distinta a la lógica del generador, ya que replicar esa lógica no sería útil para realizar pruebas efectivas.

pbt/arbitrary/prng/src/main/kotlin/com/github/username/lists/average.kt
package com.github.username.lists.average

fun average(list: List<Double>): Double {
var sum = 0.0
for (number in list) {
sum += number
}
return sum / list.size
}
¿Qué acabamos de hacer?
  • En esta implementación iterativa, usamos un bucle for para recorrer la lista de números.
  • Cada número se suma al acumulador sum, que luego se divide por el tamaño de la lista para obtener el promedio.
  • Este enfoque es sencillo y fácil de entender, pero puede ser menos idiomático en Kotlin que otras alternativas más funcionales.
📐 Controlar la semilla del generador

En algunos casos, es útil controlar la semilla del generador aleatorio para garantizar reproducibilidad de los tests. Esto puede ser importante cuando necesitas depurar un fallo o mantener pruebas estables.

En Kotest, esto se puede lograr usando PropTestConfig y marcando la clase con @OptIn(ExperimentalKotest::class):

@OptIn(ExperimentalKotest::class)
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(
PropTestConfig(seed = 123456), // 🔐 Semilla fija
arbIntListAndAverage()
) { (list, average) ->
average(list)
.shouldBeGreaterThanOrEqual(average - 0.0001)
.shouldBeLessThan(average + 0.0001)
}
}
}
}
})

Esto asegura que el test use la misma secuencia de valores generados cada vez, facilitando la identificación de errores difíciles de reproducir.

🧪 Ejercicio: Generador de diccionarios

Ejercicio

Implementa un generador arbitrario que produzca diccionarios donde:

  • La llave sea un número entero.
  • El valor sea un String generado por la concatenación de caracteres aleatorios.

Detalles del ejercicio:

  1. El tamaño del diccionario debe generarse de manera aleatoria.
  2. Para generar los caracteres aleatorios que componen los valores del diccionario, puedes utilizar la función nextInt: Random.() -> Int para obtener números aleatorios dentro del rango de valores Unicode (entre 0x0000 y 0xFFFF).
  3. Una vez generado el número, conviértelo en un carácter usando la función toChar: Int.() -> Char.

Ejemplo de generación de un carácter aleatorio:

random.nextInt(0x0000, 0xFFFF).toChar()

Este código generará un número aleatorio en el rango Unicode y lo convertirá a su correspondiente carácter. Usa este enfoque para construir las cadenas que serán los valores del diccionario.

Solución
typealias IntStringMap = Map<Int, String>

fun arbIntStringMap(): Arb<IntStringMap> = arbitrary { (random, seed) ->
val map = mutableMapOf<Int, String>()
val size = random.nextInt(1, 100)
while (map.size < size) {
val key = random.nextInt()
val stringSize = random.nextInt(1, 100)
val value = List(stringSize) {
random.nextInt(0x0000, 0xFFFF).toChar()
}.joinToString("")
map[key] = value
}
map
}

🎯 Conclusiones

En esta lección aprendimos a diseñar generadores personalizados usando la función arbitrary de Kotest, aprovechando un generador de números pseudoaleatorios (PRNG) para crear datos variados, controlables y reproducibles. Este enfoque es fundamental en pruebas basadas en propiedades.

🔑 Puntos clave:

  • El generador no debe replicar la lógica de la función bajo prueba.
    En lugar de eso, utilizamos una aproximación distinta para calcular el resultado esperado. Esto ayuda a detectar errores reales en la implementación.
  • La reproducibilidad es posible gracias a la configuración de semillas.
    Usar PropTestConfig(seed = ...) permite depurar fallos y mantener resultados consistentes entre ejecuciones.
  • Los generadores pueden componerse libremente.
    Vimos cómo construir estructuras más complejas como listas o diccionarios a partir de combinaciones de generadores simples.
  • Las comparaciones deben tener en cuenta la naturaleza de los datos.
    Para tipos como Double, es importante permitir un margen de tolerancia debido a los errores de redondeo acumulados.

🧰 ¿Qué nos llevamos?

Al usar generadores personalizados basados en PRNG, ganamos mayor control sobre los datos, mejor reproducibilidad de tests, y la posibilidad de crear verificaciones más robustas y variadas, lo que resulta especialmente valioso en el desarrollo de librerías confiables.

📖 Referencias

🔥 Recomendadas

🌐 Generador de números pseudoaleatorios. (2024). En Wikipedia, la enciclopedia libre. https://es.wikipedia.org/w/index.php?title=Generador_de_n%C3%BAmeros_pseudoaleatorios&oldid=161973175

🔹 Adicionales

📚 Random Numbers. (1997). En D. E. Knuth, The Art of Computer Programming, Volume 2: Seminumerical Algorithms (3rd ed, pp. 1–193). Addison-Wesley.