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.
🧮 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.
- Implementación iterativa
- Implementación funcional
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
}
- 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.
package com.github.username.lists.average
fun average(list: List<Double>) =
list.fold(0.0) { acc, number ->
acc + number
} / list.size
- Esta implementación utiliza un enfoque funcional con la función
fold
, que recorre la lista acumulando el resultado en el parámetroacc
(acumulador). - Al final del recorrido, dividimos la suma acumulada por el tamaño de la lista para obtener el promedio.
- Este enfoque es más conciso y expresivo, aprovechando las características funcionales de Kotlin.
📐 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:
- El tamaño del diccionario debe generarse de manera aleatoria.
- 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 (entre0x0000
y0xFFFF
). - 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
- Código esencial
- Código completo
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
}
package com.github.username.maps
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
class IntStringMapTest : FreeSpec({
"Given a randomly generated map of Int to String" - {
"when generating a new map" - {
"then it should contain at least one entry" {
checkAll(arbIntStringMap()) { map ->
map.size shouldBeGreaterThan 0
}
}
"then it should not contain duplicate keys" {
checkAll(arbIntStringMap()) { map ->
map.keys shouldHaveSize map.size
}
}
"then all string values should have at least one character" {
checkAll(arbIntStringMap()) { map ->
map.values.forEach { value ->
value.length shouldBeGreaterThan 0
}
}
}
"then all string values should have at most 100 characters" {
checkAll(arbIntStringMap()) { map ->
map.values.forEach { value ->
value.length shouldBeLessThanOrEqual 100
}
}
}
"then the total number of entries should be at most 100" {
checkAll(arbIntStringMap()) { map ->
map.size shouldBeLessThanOrEqual 100
}
}
}
}
})
private typealias IntStringMap = Map<Int, String>
private 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.
UsarPropTestConfig(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 comoDouble
, 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.