Eventually
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
A partir de Kotest 4.6, se ha añadido un nuevo módulo experimental que contiene utilidades mejoradas
para probar código concurrente, asíncrono o no determinista. Este módulo
es kotest-framework-concurrency y está diseñado como reemplazo a largo plazo del módulo anterior. Las utilidades
anteriores siguen disponibles como parte del framework principal.
Probar código no determinista puede ser complicado. Puede que necesites gestionar hilos, tiempos de espera, condiciones de carrera y la imprevisibilidad de cuándo ocurren los eventos.
Por ejemplo, si estuvieras verificando que una escritura de archivo asíncrona se completó correctamente, necesitas esperar hasta que la operación de escritura haya finalizado y se haya volcado al disco.
Algunos enfoques comunes para estos problemas son:
Usar callbacks que se invocan una vez completada la operación. El callback puede usarse entonces para verificar que el estado del sistema es el esperado. Pero no todas las operaciones ofrecen funcionalidad de callback.
Bloquear el hilo usando
Thread.sleepo suspender una función condelay, esperando a que la operación termine. El umbral de espera debe establecerse lo suficientemente alto para garantizar que las operaciones se completen tanto en máquinas rápidas como lentas, e incluso cuando se complete, el hilo permanecerá bloqueado hasta que expire el tiempo de espera.Usar un bucle con espera y reintento, pero entonces necesitas escribir código repetitivo para controlar el número de iteraciones, manejar ciertas excepciones y fallar en otras, asegurar que el tiempo total no exceda el máximo, etc.
Usar countdown latches y bloquear hilos hasta que los latches se liberen por la operación no determinista. Esto puede funcionar bien si puedes inyectar los latches en los lugares adecuados, pero al igual que con los callbacks, no siempre es posible que el código bajo prueba se integre con un latch.
Como alternativa a las soluciones anteriores, kotest proporciona la utilidad eventually que resuelve el caso de uso común de
"espero que este código funcione tras un breve periodo de tiempo".
Eventually logra esto invocando periódicamente una lambda dada hasta que se alcanza el tiempo de espera o se supera el número máximo de iteraciones. Es flexible y perfecto para probar código no determinista. Eventually puede personalizarse en cuanto a los tipos de excepciones a manejar, cómo se considera que la lambda tiene éxito o falla, con un listener, etc.
API
Hay dos formas de usar eventually. La primera es simplemente proporcionar una duración en milisegundos
(o usando el tipo Duration de Kotlin) seguido del código que debería ejecutarse correctamente sin lanzar excepciones.
eventually(5000) { // duration in millis
userRepository.getById(1).name shouldBe "bob"
}
La segunda es proporcionando un bloque de configuración antes del código de prueba. Este método debe usarse cuando necesites establecer más opciones además de la duración.
eventually({
duration = 5000
interval = 1000.fixed()
}) {
userRepository.getById(1).name shouldBe "bob"
}
Configuración
Duraciones e intervalos
La duración es el tiempo total durante el cual se seguirá intentando pasar la prueba. El interval sin embargo nos permite
especificar con qué frecuencia se debe intentar la prueba. Si establecemos la duración en 5 segundos y el intervalo en 250 milisegundos,
entonces la prueba se intentaría como máximo 5000 / 250 = 20 veces.
Retraso inicial
Normalmente eventually comienza ejecutando el bloque de prueba inmediatamente, pero podemos añadir un retraso inicial antes de la primera
iteración usando initialDelay, como por ejemplo:
eventually({
duration = 5000
initialDelay = 1000
}) {
userRepository.getById(1).name shouldBe "bob"
}
Reintentos
Además de limitar el número de invocaciones por tiempo, podemos hacerlo por número de iteraciones. En el siguiente ejemplo reintentamos la operación 10 veces, o hasta que hayan expirado 8 segundos.
eventually({
duration = 8000
retries = 10
suppressExceptions = setOf(UserNotFoundException::class)
}) {
userRepository.getById(1).name shouldNotBe "bob"
}
Especificar excepciones a capturar
Por defecto, eventually ignorará cualquier AssertionError lanzado dentro de la función (nota: esto significa que no
capturará Error). Si deseas ser más específico, puedes indicar a eventually que ignore excepciones concretas;
cualquier otra excepción fallará la prueba inmediatamente.
Por ejemplo, al probar que un usuario debe existir en la base de datos, podría lanzarse una UserNotFoundException
si el usuario no existe. Sabemos que eventualmente ese usuario existirá. Pero si se lanza una IOException, no
queremos seguir reintentando, ya que esto indica un problema mayor que un simple desfase temporal.
Podemos lograr esto especificando que UserNotFoundException es una excepción a suprimir.
eventually({
duration = 8000
suppressExceptions = setOf(UserNotFoundException::class)
}) {
userRepository.getById(1).name shouldNotBe "bob"
}
Como alternativa a pasar un conjunto de excepciones, podemos proporcionar una función que se invoque pasando la excepción lanzada. Esta función debe devolver true si la excepción debe manejarse, o false si la excepción debe propagarse.
eventually({
duration = 8000
suppressExceptionIf = { it is UserNotFoundException && it.username == "bob" }
}) {
userRepository.getById(1).name shouldNotBe "bob"
}
Predicados
Además de verificar que un caso de prueba eventualmente se ejecute sin lanzar excepciones, también podemos verificar que el valor devuelto sea el esperado. Si no lo es, consideraremos esa iteración como un fallo y reintentaremos.
Por ejemplo, aquí seguimos añadiendo "x" a una cadena hasta que el resultado de la iteración anterior sea igual a "xxx".
var string = "x"
eventually({
duration = 5.seconds()
predicate = { it.result == "xxx" }
}) {
string += "x"
string
}
Listeners (Oyentes)
Podemos adjuntar un listener, que se invocará en cada iteración con el estado de esa iteración. El objeto de estado contiene la última excepción, el último valor, el conteo de iteraciones, etc.
eventually({
duration = 5.seconds()
listener = { println("iteration ${it.times} returned ${it.result}") }
}) {
string += "x"
string
}
Compartir configuración
Compartir la configuración de eventually es muy sencillo con la clase de datos EventuallyConfig. Supón que has clasificado las operaciones de tu sistema en "lentas" y "rápidas". En lugar de recordar qué valores temporales corresponden a cada tipo, podemos configurar objetos para compartir entre pruebas y personalizarlos por suite. ¡Este es también el momento perfecto para mostrar las capacidades de listeners de eventually, que brindan información sobre el valor actual del resultado de tu productor y el estado de las iteraciones!
val slow = EventuallyConfig<ServerResponse>(
duration = 5.minutes,
interval = 25.milliseconds.fibonacci(),
suppressExceptions = setOf(ServerException::class)
)
class FooTests : StringSpec({
val logger = logger("FooTests")
val fSlow = slow.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts") })
"server eventually provides a result for /foo" {
eventually(fSlow) {
fooApi()
}
}
})
class BarTests : StringSpec({
val logger = logger("BarTests")
val bFast = fast.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts") })
"server eventually provides a result for /bar" {
eventually(bFast) {
barApi()
}
}
})
Aquí vemos que compartir configuración puede ser útil para reducir código duplicado, permitiendo flexibilidad para aspectos como el registro personalizado por suite de pruebas, obteniendo así logs de pruebas más claros.