Recolección de estadísticas
⏱ 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 setupStatsModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupStatsModule") {
description = "Creates the base module and files for the statistics collection lesson"
module.set("pbt:arbitrary:stats")
doLast {
createFiles(
"zoo",
main to "Animal.kt",
test to "AnimalTest.kt",
)
createFiles(
"geometry",
main to "Triangle.kt",
test to "TriangleTest.kt",
)
}
}
Preocúpate de que el plugin stats
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupStatsModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
Cuando usamos pruebas basadas en propiedades, no basta con verificar que un programa funciona para algunos valores: queremos que funcione para todos los casos posibles, o al menos, para una muestra significativa y representativa.
Pero ¿cómo saber si esa muestra es realmente representativa? ¿Y si nuestro generador produce más de un tipo de dato que otro? Ahí entra en juego la función collect
. En Kotest, la función collect
permite obtener estadísticas sobre los valores generados, ayudándonos a detectar problemas como distribuciones sesgadas o errores en la implementación del generador.
En esta lección aprenderemos cómo usar collect
para verificar y corregir errores comunes en generadores personalizados mediante un ejemplo práctico.
🎛️ ¿Qué es la función collect
?
La función collect
permite contabilizar y mostrar la frecuencia con que ciertos valores o categorías son generados durante la ejecución de pruebas basadas en propiedades. Esto resulta especialmente útil cuando queremos asegurarnos de que un generador personalizado produce datos variados y equilibrados.
Sintaxis básica
checkAll(arbGeneradorPersonalizado()) { valorGenerado ->
collect(valorGenerado)
// Aquí van las verificaciones adicionales del test
}
🧪 Caso práctico: Detectando sesgos sutiles en un generador
En este ejemplo, queremos generar animales de tres tipos: Lion
, Penguin
y Turtle
, con la siguiente distribución:
- 50% de probabilidades de ser un
Lion
- 25% de probabilidades de ser un
Penguin
- 25% de probabilidades de ser una
Turtle
Suena simple, pero veremos que un error pequeño puede desviar por completo la distribución.
🔧 Paso 1: Definimos nuestro Generador
Este generador aparenta cumplir con la distribución deseada, pero contiene un error sutil en la última rama del if-else
:
- Código esencial
- Código completo
fun arbAnimalWrong(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble() // valor entre 0.0 y 1.0
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Penguin
}
}
package com.github.username.zoo
interface Animal
object Lion : Animal
object Penguin : Animal
object Turtle : Animal
package com.github.username.zoo
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
private fun arbAnimalWrong(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble() // valor entre 0.0 y 1.0
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Penguin
}
}
Observa que la última rama de else
repite Penguin
, en lugar de devolver Turtle
. A simple vista podría pasarse por alto.
📊 Paso 2: Verificamos la Distribución con collect
Para saber si nuestro generador está produciendo la distribución que esperamos, utilizamos la función collect
. Cada vez que se genera un animal, lo registramos como una categoría:
- Código esencial
- Código completo
checkAll(arbAnimalWrong()) { animal ->
collect(animal)
}
package com.github.username.zoo
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
private fun arbAnimalWrong(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble() // valor entre 0.0 y 1.0
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Penguin
}
}
class AnimalTest : FreeSpec({
"Given an arbitrary animal" - {
"when getting the animal with the incorrect implementation" - {
"should return a lion, or a penguin, but never a turtle" {
checkAll(arbAnimalWrong()) { animal ->
collect(animal)
// In this example, we don't perform any specific assertion,
// we're only interested in seeing the statistics printed by Kotest at the end.
}
}
}
}
})
Al ejecutar esta prueba, Kotest muestra al final algo parecido a lo siguiente (los valores son un ejemplo):
Statistics: [should return a lion, a penguin, or a turtle] (1000 iterations, 1 args)
com.github.username.zoo.Lion@684733d1 504 (50%)
com.github.username.zoo.Penguin@1a411b8c 496 (50%)
De este mensaje podemos observar lo siguiente:
- Distribución incorrecta: Esperábamos que se generaran tres tipos de animales (
Lion
,Penguin
y otro más), pero en los resultados solo aparecen dos (Lion
yPenguin
), ambos con distribuciones del 50%. Esto indica que el generador no está distribuyendo correctamente los valores. - Falta de
Turtle
: Según la implementación, debería existir una probabilidad para generarTurtle
, pero no aparece en las estadísticas. Esto sugiere que el bloqueelse -> Penguin
está causando quePenguin
se genere dos veces en lugar de asignar un tercer valor distinto. - Iteraciones: Se realizaron 1000 iteraciones con un solo argumento (
1 args
), lo que confirma que la prueba se ejecutó un número suficiente de veces para revelar inconsistencias en la distribución.
🛠️ Paso 3: Corregimos el Generador
La solución consiste en arreglar la última rama del else
, asegurando que devuelva Turtle
en lugar de Penguin
:
- Código esencial
- Código completo
fun arbAnimalCorrect(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble()
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Turtle
}
}
package com.github.username.zoo
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
private fun arbAnimalWrong(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble() // valor entre 0.0 y 1.0
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Penguin
}
}
private fun arbAnimalCorrect(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble()
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Turtle
}
}
class AnimalTest : FreeSpec({
"Given an arbitrary animal" - {
"when getting the animal with the incorrect implementation" - {
"should return a cat, a dog, or a turtle" {
checkAll(arbAnimalWrong()) { animal ->
collect(animal)
// In this example, we don't perform any specific assertion,
// we're only interested in seeing the statistics printed by Kotest at the end.
}
}
}
}
})
🔁 Paso 4: Recolectamos Estadísticas Nuevamente
Actualizamos la prueba para usar el generador corregido:
- Código esencial
- Código completo
checkAll(arbAnimalCorrect()) { animal ->
collect(animal)
}
package com.github.username.zoo
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
private fun arbAnimalWrong(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble() // valor entre 0.0 y 1.0
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Penguin
}
}
private fun arbAnimalCorrect(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble()
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Turtle
}
}
class AnimalTest : FreeSpec({
"Given an arbitrary animal" - {
"when getting the animal with the incorrect implementation" - {
"should return a lion, or a penguin, but never a turtle" {
checkAll(arbAnimalWrong()) { animal ->
collect(animal)
// In this example, we don't perform any specific assertion,
// we're only interested in seeing the statistics printed by Kotest at the end.
}
}
}
"when getting the animal with the correct implementation" - {
"should return a lion (~50%), a penguin (~25%), or a turtle (~25%)" {
checkAll(arbAnimalCorrect()) { animal ->
collect(animal)
// In this example, we don't perform any specific assertion,
// we're only interested in seeing the statistics printed by Kotest at the end.
}
}
}
}
})
Esta vez, al ejecutar la prueba, Kotest mostrará algo así:
Statistics: [should return a lion (~50%), a penguin (~25%), or a turtle (~25%)] (1000 iterations, 1 args)
com.github.username.zoo.Lion@4e3abea1 516 (52%)
com.github.username.zoo.Turtle@10f6634d 247 (25%)
com.github.username.zoo.Penguin@3d05b70d 237 (24%)
Lo que se acerca mucho más a la distribución que pretendíamos. Ahora podemos estar razonablemente seguros de que nuestro generador cumple el requisito de producir un 50% de Lion
, 25% de Penguin
y 25% de Turtle
.
Es importante recordar que, debido a la naturaleza aleatoria de los generadores, las estadísticas pueden variar ligeramente en cada ejecución. Sin embargo, si la muestra es lo suficientemente grande, las diferencias deberían ser mínimas.
🧠 ¿Por qué collect
es tan valioso?
- Muestra la frecuencia de valores generados.
- Ayuda a detectar sesgos en generadores.
- No requiere aserciones adicionales.
- Complementa otras técnicas de validación.
- 1000 iteraciones suelen bastar para notar errores.
📐 Ejercicio: Corrigiendo un generador de triángulos
Ejercicio
Implementa un generador arbitrario que genere triángulos según la clasificación de sus lados:
- Equilátero (todos los lados iguales)
- Isósceles (exactamente dos lados iguales)
- Escaleno (todos los lados distintos)
El generador proporcionado presenta errores en la distribución, generando casos incorrectos con frecuencia excesiva. Usa la función collect
para revelar este problema y posteriormente corrige la implementación.
No consideres la validez de los triángulos, solo la distribución de los tipos.
Generador Erróneo
private fun arbTriangleWrong(): Arb<Triangle> = arbitrary { (random, _) ->
val type = random.nextDouble()
val a = random.nextInt(1, 10)
when {
type < 0.5 -> Triangle(a, a, a) // Equilátero
type < 0.8 -> Triangle(a, a, random.nextInt(1, 10)) // Isósceles
else -> Triangle(a, random.nextInt(1, 10), random.nextInt(1, 10)) // Escaleno
}
}
- Escribe un test que use
collect
para examinar la distribución generada por este generador incorrecto. - Corrige el generador para asegurar una distribución adecuada entre los tipos de triángulos (50% Equilátero, 30% Isósceles, 20% Escaleno).
Solución 1
- Código esencial
- Código completo
checkAll(arbTriangleWrong()) { triangle ->
val triangleType = when {
triangle.a == triangle.b && triangle.b == triangle.c -> "Equilateral"
triangle.a == triangle.b ||
triangle.b == triangle.c ||
triangle.a == triangle.c -> "Isosceles"
else -> "Scalene"
}
collect(triangleType)
}
package com.github.username.geometry
class Triangle(val a: Int, val b: Int, val c: Int)
package com.github.username.geometry
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
private fun arbTriangleWrong(): Arb<Triangle> = arbitrary { (random, _) ->
val type = random.nextDouble()
val a = random.nextInt(1, 10)
when {
type < 0.5 -> Triangle(a, a, a) // Equilateral
type < 0.8 -> Triangle(a, a, random.nextInt(1, 10)) // Isosceles
else -> Triangle(a, random.nextInt(1, 10), random.nextInt(1, 10)) // Scalene
}
}
class TriangleTest : FreeSpec({
"Given an arbitrary triangle" - {
"when getting the triangle" - {
"should return an equilateral (~50%), an isosceles (~30%), or a scalene (~20%) triangle" {
checkAll(arbTriangleWrong()) { triangle ->
val triangleType = when {
triangle.a == triangle.b && triangle.b == triangle.c -> "Equilateral"
triangle.a == triangle.b ||
triangle.b == triangle.c ||
triangle.a == triangle.c -> "Isosceles"
else -> "Scalene"
}
collect(triangleType)
}
}
}
}
})
Ejemplo de salida:
Statistics: [should return an equilateral (~50%), an isosceles (~30%), or a scalene (~20%) triangle] (1000 iterations, 1 args)
Equilateral 540 (54%)
Isosceles 314 (31%)
Scalene 146 (15%)
Solución 2
- En la rama de isósceles, el tercer lado
random.nextInt(1, 10)
podría ser igual a los otros lados, generando un triángulo equilátero en lugar de un isósceles. - En la rama de escaleno,
random.nextInt(1, 10)
podría generar dos lados iguales, lo que haría que el triángulo no sea escaleno.
- Código esencial
- Código completo
private fun arbTriangle(): Arb<Triangle> = arbitrary { (random, _) ->
val type = random.nextDouble()
val a = random.nextInt(1, 10)
when {
type < 0.5 -> Triangle(a, a, a) // 50% Equilateral - All sides must be equal
type < 0.8 -> { // 30% Isosceles - The third side must be different
var b = random.nextInt(1, 10)
while (b == a) {
b = random.nextInt(1, 10)
}
Triangle(a, a, b)
}
else -> { // 20% Scalene - All sides must be different
var b = random.nextInt(1, 10)
while (b == a) {
b = random.nextInt(1, 10)
}
var c = random.nextInt(1, 10)
while (c == a || c == b) {
c = random.nextInt(1, 10)
}
Triangle(a, b, c)
}
}
}
package com.github.username.geometry
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.checkAll
private fun arbTriangle(): Arb<Triangle> = arbitrary { (random, _) ->
val type = random.nextDouble()
val a = random.nextInt(1, 10)
when {
type < 0.5 -> Triangle(a, a, a) // 50% Equilateral - All sides must be equal
type < 0.8 -> { // 30% Isosceles - The third side must be different
var b = random.nextInt(1, 10)
while (b == a) {
b = random.nextInt(1, 10)
}
Triangle(a, a, b)
}
else -> { // 20% Scalene - All sides must be different
var b = random.nextInt(1, 10)
while (b == a) {
b = random.nextInt(1, 10)
}
var c = random.nextInt(1, 10)
while (c == a || c == b) {
c = random.nextInt(1, 10)
}
Triangle(a, b, c)
}
}
}
class TriangleTest : FreeSpec({
"Given an arbitrary triangle" - {
"when getting the triangle" - {
"should return an equilateral (~50%), an isosceles (~30%), or a scalene (~20%) triangle" {
checkAll(arbTriangle()) { triangle ->
val triangleType = when {
triangle.a == triangle.b && triangle.b == triangle.c -> "Equilateral"
triangle.a == triangle.b ||
triangle.b == triangle.c ||
triangle.a == triangle.c -> "Isosceles"
else -> "Scalene"
}
collect(triangleType)
}
}
}
}
})
🧭 Buenas prácticas con collect
- Evita categorizar con datos de alta cardinalidad: por ejemplo, usar
collect(x.toString())
para enteros podría saturar la salida con miles de líneas distintas. - Usa
collect
para agrupar en buckets o categorías: como “Equilateral”, “Isosceles”, “Scalene” o tipos de animales. - No abuses de
collect
en asserts: su propósito es exploratorio, no para validaciones estrictas.
🎯 Conclusiones
El uso de collect
en pruebas basadas en propiedades no solo nos permite observar qué tan bien se comportan nuestros generadores, sino también descubrir errores sutiles que podrían pasar desapercibidos si solo nos fijáramos en casos individuales. A través de estadísticas sobre los valores generados, podemos evaluar si la distribución se ajusta a nuestras expectativas y ajustar la implementación en caso contrario.
La recolección de estadísticas es una herramienta sencilla pero poderosa, que fortalece la calidad de nuestras pruebas al agregar una capa de análisis cuantitativo sobre los datos generados.
🔑 Puntos clave
collect
permite ver la frecuencia de aparición de valores generados durante una prueba.- Es útil para detectar sesgos o errores en la lógica de los generadores personalizados.
- Podemos usar
collect
sin necesidad de definir aserciones adicionales. - El análisis de estadísticas complementa otras técnicas de validación, especialmente cuando trabajamos con distribución probabilística.
- Una muestra de 1000 iteraciones suele ser suficiente para revelar problemas evidentes.
🧰 ¿Qué nos llevamos?
Cuando escribimos pruebas basadas en propiedades, muchas veces confiamos en que nuestros generadores están haciendo lo correcto. Pero collect
nos recuerda que incluso los errores más simples —una rama mal escrita, una condición mal evaluada— pueden tener consecuencias profundas en la calidad de nuestras pruebas.
Nos llevamos, entonces, una lección esencial: probar no es solo verificar que algo funciona, sino entender cómo y con qué frecuencia funciona. Con collect
, agregamos una dimensión estadística que nos ayuda a escribir generadores más confiables, más variados y más representativos. En el fondo, es una invitación a mirar los datos con más atención, y a tomar decisiones informadas sobre su distribución y variedad.
📖 Referencias
🔥 Recomendadas
- 🌐 Statistics | Kotest. (s. f.). Recuperado 14 de marzo de 2025, de https://kotest.io/docs/proptest/property-test-statistics.html
- 📚 Custom Generators. (2019). En F. Hébert, Property-based testing with PropEr, Erlang, and Elixir: Find bugs before your users do (pp. 51–87). The Pragmatic Bookshelf.