Composición de Generadores
⏱ 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 setupArbCompositionModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupArbCompositionModule") {
description = "Creates the base module and files for the arbitrary composition lesson"
module.set("pbt:arbitrary:composition")
doLast {
createFiles(
"forms",
main to "UserInput.kt",
test to "FormValidationTest.kt",
)
createFiles(
"config",
test to "ConfigGenerators.kt"
)
}
}
Preocúpate de que el plugin stats
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupArbCompositionModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
En property-based testing (PBT), la clave es generar datos variados y realistas para verificar que una biblioteca de software funcione correctamente en distintos escenarios. En Behavior-Driven Development (BDD), primero definimos las expectativas sobre el comportamiento de la funcionalidad antes de implementar los generadores.
Kotest nos permite definir pruebas expresivas siguiendo BDD y generar datos complejos mediante composición de generadores. Esto es útil en bibliotecas de software que manejan estructuras configurables o datos extensibles.
📝 Definiendo los Tests Antes de los Generadores
Antes de crear los generadores, escribiremos los tests que definirán el comportamiento esperado de la biblioteca. Supongamos que estamos desarrollando una biblioteca de validación de formularios, donde validamos que los datos generados cumplan ciertas condiciones.
Usaremos Kotest con property-based testing para definir las siguientes pruebas:
- Código esencial
- Código completo
checkAll(arbUserInput()) { user ->
user.name shouldHaveMinLength 3
}
checkAll(arbUserInput()) { user ->
user.email shouldMatch Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
}
checkAll(arbUserInput()) { user ->
(user.age shouldBeGreaterThanOrEqual 18) and
(user.age shouldBeLessThanOrEqual 99)
}
package cl.ravenhill.forms
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.string.shouldHaveMinLength
import io.kotest.matchers.string.shouldMatch
import io.kotest.property.Arb
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.stringPattern
import io.kotest.property.checkAll
class FormValidationTest : FreeSpec({
"Given a user input" - {
"when validating it" - {
"then it should have at least 3 characters" {
checkAll(arbUserInput()) { user ->
user.name shouldHaveMinLength 3
}
}
"then it should have the correct format" {
checkAll(arbUserInput()) { user ->
user.email shouldMatch Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
}
}
"then it should be between 18 and 99" {
checkAll(arbUserInput()) { user ->
(user.age shouldBeGreaterThanOrEqual 18) and
(user.age shouldBeLessThanOrEqual 99)
}
}
}
}
})
Aquí, cada prueba define una propiedad que debe cumplirse para todos los datos generados. Ahora, implementaremos los generadores para que los tests pasen.
🚀 Construyendo Generadores para Validaciones
Kotest proporciona herramientas para construir generadores reutilizables. Vamos a definir generadores para nombres, correos electrónicos y edades, asegurándonos de que cumplan con los requisitos de validación.
- Código esencial
- Código completo
fun arbName(): Arb<String> = Arb.string(3..20) // Nombres de mínimo 3 caracteres
fun arbEmail(): Arb<String> = Arb.stringPattern("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") // Emails válidos
fun arbAge(): Arb<Int> = Arb.int(18..99) // Edad dentro del rango permitido
Ahora, combinemos estos generadores en una sola estructura con Arb.bind
:
fun arbUserInput(): Arb<UserInput> = Arb.bind(
arbName(),
arbEmail(),
arbAge()
) { name, email, age -> UserInput(name, email, age) }
package cl.ravenhill.forms
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.string.shouldHaveMinLength
import io.kotest.matchers.string.shouldMatch
import io.kotest.property.Arb
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.stringPattern
import io.kotest.property.checkAll
class FormValidationTest : FreeSpec({
"Given a user input" - {
"when validating it" - {
"then it should have at least 3 characters" {
checkAll(arbUserInput()) { user ->
user.name shouldHaveMinLength 3
}
}
"then it should have the correct format" {
checkAll(arbUserInput()) { user ->
user.email shouldMatch Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
}
}
"then it should be between 18 and 99" {
checkAll(arbUserInput()) { user ->
(user.age shouldBeGreaterThanOrEqual 18) and
(user.age shouldBeLessThanOrEqual 99)
}
}
}
}
})
private fun arbName(): Arb<String> = Arb.string(3..20) // Nombres de mínimo 3 caracteres
private fun arbEmail(): Arb<String> = Arb.stringPattern("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") // Emails válidos
private fun arbAge(): Arb<Int> = Arb.int(18..99) // Edad dentro del rango permitido
private fun arbUserInput(): Arb<UserInput> = Arb.bind(
arbName(),
arbEmail(),
arbAge()
) { name, email, age -> UserInput(name, email, age) }
Arb.bind
combina múltiples generadores en una estructura de datos compleja. En este caso, generamos un UserInput
con un nombre, un correo electrónico y una edad válidos.
Con este generador, cada ejecución de arbUserInput()
producirá un usuario con datos aleatorios pero válidos.
🔄 Generación de Datos en Colecciones
Si nuestra biblioteca maneja listas de usuarios, podemos definir un generador de listas:
fun arbUserList(): Arb<List<UserInput>> = Arb.list(arbUserInput(), 1..10)
Si queremos garantizar que los emails no se repitan en una lista de usuarios, usamos un conjunto:
fun arbUniqueUsers(): Arb<Set<UserInput>> = Arb.set(arbUserInput(), 1..10)
📚 Ejercicio Práctico: Generar Configuración de una Biblioteca
Ejercicio
Supongamos que estamos diseñando una biblioteca para gestionar configuraciones de una aplicación. Los usuarios pueden definir opciones de configuración, que pueden incluir valores de diferentes tipos, como:
String
para nombres de usuario.Boolean
para opciones de activación/desactivación.Int
para límites numéricos.
💡 Objetivo: Crea un generador que produzca una lista de opciones de configuración. Cada opción debe ser representada como un par <clave, valor>
, donde la clave es un String
y el valor puede ser un String
, Int
o Boolean
.
Ver hints
- Utiliza el generador
Arb.pair: (Arb<A>, Arb<B>) -> Arb<Pair<A, B>>
para combinar generadores de claves y valores. - Utiliza
Arb.choice: (Arb<A>, Arb<A>, ...) -> Arb<A>
para elegir entre diferentes tipos de valores. Puedes considerar el tipo deA
como unAny
.
Solución
- Código esencial
- Código completo
fun arbConfigOption(): Arb<Pair<String, Any>> = Arb.pair(
Arb.string(5..15), // Clave con longitud entre 5 y 15 caracteres
Arb.choice(Arb.string(1..20), Arb.int(1..100), Arb.boolean()) // Valores de diferentes tipos
)
fun arbConfigList(): Arb<List<Pair<String, Any>>> = Arb.list(arbConfigOption(), 3..10)
package cl.ravenhill.config
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldHaveAtLeastSize
import io.kotest.matchers.collections.shouldHaveAtMostSize
import io.kotest.matchers.or
import io.kotest.matchers.should
import io.kotest.matchers.string.shouldHaveMinLength
import io.kotest.matchers.types.beInstanceOf
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.checkAll
class ConfigGeneratorsTest : FreeSpec({
"Given a configuration option generator" - {
"when generating a configuration option" - {
"then the key should be a non-empty string with at least 5 characters" {
checkAll(arbConfigOption()) { (key, _) ->
key shouldHaveMinLength 5
}
}
"then the value should be a String, Int, or Boolean" {
checkAll(arbConfigOption()) { (_, value) ->
value should (beInstanceOf<String>()
or beInstanceOf<Int>()
or beInstanceOf<Boolean>())
}
}
}
}
"Given a configuration list generator" - {
"when generating a list of configuration options" - {
"then the list should contain between 3 and 10 elements" {
checkAll(arbConfigList()) { configList ->
configList
.shouldHaveAtLeastSize(3)
.shouldHaveAtMostSize(10)
}
}
"then each configuration option should have a valid key-value pair" {
checkAll(arbConfigList()) { configList ->
configList.forEach { (key, value) ->
key.shouldHaveMinLength(5)
value should (beInstanceOf<String>()
or beInstanceOf<Int>()
or beInstanceOf<Boolean>())
}
}
}
}
}
})
private fun arbConfigOption(): Arb<Pair<String, Any>> = Arb.pair(
Arb.string(5..15),
Arb.choice(Arb.string(1..20), Arb.int(1..100), Arb.boolean())
)
private fun arbConfigList(): Arb<List<Pair<String, Any>>> = Arb.list(arbConfigOption(), 3..10)
🎯 Conclusiones
La composición de generadores es una herramienta poderosa para diseñar pruebas expresivas y robustas en el desarrollo de bibliotecas de software. Al combinar generadores básicos en estructuras más complejas, no solo validamos el comportamiento esperado, sino que también acercamos nuestros tests a los escenarios reales de uso.
Este enfoque resulta especialmente útil cuando desarrollamos bibliotecas que operan sobre datos configurables, entradas dinámicas o estructuras heterogéneas. Gracias a herramientas como Kotest, podemos aplicar property-based testing de forma sencilla y declarativa, manteniendo los tests legibles, reutilizables y alineados con los principios de BDD.
🔑 Puntos clave
- Composición con
Arb.bind
: Permite generar estructuras complejas combinando generadores simples. - Tests primero, generadores después: Al definir primero los tests, guiamos el diseño de nuestros generadores en función del comportamiento esperado.
- Colecciones y restricciones: Podemos generar listas, conjuntos o mapas aplicando restricciones adicionales como unicidad o tamaño.
- Adaptabilidad a distintos dominios: El patrón se aplica bien a bibliotecas de validación, configuración, procesamiento de datos, parsers, entre otros.
🧰 ¿Qué nos llevamos?
El uso de composición de generadores nos permite construir pruebas más expresivas, minimizar falsos positivos y detectar errores ocultos al cubrir una mayor variedad de escenarios. Esto no solo aumenta la calidad del software que entregamos, sino que también promueve un diseño más claro, modular y orientado a comportamiento.
Adoptar este enfoque en nuestras bibliotecas contribuye a un desarrollo más seguro y colaborativo, ya que los generadores también documentan las expectativas del dominio de manera precisa y reutilizable.
📖 Referencias
🔥 Recomendadas
- 🌐 Generator Operations | Kotest. (s. f.). Recuperado 20 de marzo de 2025, de https://kotest.io/docs/proptest/generator-operations.html