Skip to main content

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

Be lazy...

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:

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
}
}
¡Cuidado!

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:

checkAll(arbAnimalWrong()) { animal ->
collect(animal)
}

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 y Penguin), 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 generar Turtle, pero no aparece en las estadísticas. Esto sugiere que el bloque else -> Penguin está causando que Penguin 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:

fun arbAnimalCorrect(): Arb<Animal> = arbitrary { (random, _) ->
val x = random.nextDouble()
when {
x < 0.50 -> Lion
x < 0.75 -> Penguin
else -> Turtle
}
}

🔁 Paso 4: Recolectamos Estadísticas Nuevamente

Actualizamos la prueba para usar el generador corregido:

checkAll(arbAnimalCorrect()) { animal ->
collect(animal)
}

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.

Aproximación

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
}
}
  1. Escribe un test que use collect para examinar la distribución generada por este generador incorrecto.
  2. 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
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.
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)
}
}
}

🧭 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.