Ir al contenido principal
Versión: 5.9.x

Eventually

[Traducción Beta No Oficial]

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

[Nuevo módulo mejorado]

A partir de Kotest 5.7, las funciones de pruebas no deterministas se han movido al módulo kotest-assertions-core, y están disponibles bajo el nuevo paquete io.kotest.assertions.nondeterministic. Las versiones anteriores de estas funciones siguen disponibles, pero están obsoletas.

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.sleep o suspender una función con delay, esperando a que la operación termine. El umbral de espera debe ser lo suficientemente alto para asegurar que las operaciones se completen tanto en máquinas rápidas como lentas. Además, significa que tu prueba permanecerá inactiva esperando el tiempo de espera incluso si el código se completa rápidamente en una máquina veloz.

  • 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 función eventually que resuelve el caso de uso común: "Espero que este código se ejecute correctamente tras un breve periodo de tiempo".

eventually funciona invocando periódicamente una lambda dada, ignorando excepciones específicas, hasta que la lambda se ejecuta correctamente, se alcanza un tiempo de espera, o se supera el número máximo de iteraciones. Esto 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 éxito o fracaso de la lambda, con un listener, etc.

API

Hay dos formas de usar eventually. La primera es simplemente proporcionando una duración usando el tipo Duration de Kotlin, seguido del código que debería ejecutarse correctamente sin lanzar excepciones.

Por ejemplo:

eventually(5.seconds) {
userRepository.getById(1).name shouldBe "bob"
}

La segunda es mediante un bloque de configuración. Este método debe usarse cuando necesitas establecer más opciones además de la duración. También permite compartir la configuración entre múltiples invocaciones de eventually.

Por ejemplo:

val config = eventuallyConfig {
duration = 1.seconds
interval = 100.milliseconds
}

eventually(config) {
userRepository.getById(1).name shouldBe "bob"
}

Opciones de configuración

Duraciones e intervalos

La duración es el tiempo total durante el cual se seguirá intentando pasar la prueba. El interval nos permite especificar con qué frecuencia debe intentarse la prueba. Así, si establecemos una duración de 5 segundos y un intervalo de 250 milisegundos, la prueba se intentaría como máximo 5000 / 250 = 20 veces.

val config = eventuallyConfig {
duration = 5.seconds
interval = 250.milliseconds
}

Alternativamente, en lugar de especificar el intervalo como un número fijo, podemos pasar una función. Esto permite implementar backoff o cualquier otra lógica necesaria.

Por ejemplo, para usar un intervalo creciente de Fibonacci, comenzando en 100ms:

val config = eventuallyConfig {
duration = 5.seconds
intervalFn = 100.milliseconds.fibonacci()
}

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:

val config = eventuallyConfig {
initialDelay = 1.seconds
}

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.

val config = eventuallyConfig {
duration = 8.seconds
retries = 10
}

eventually(config) {
userRepository.getById(1).name shouldBe "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 necesitas mayor precisión, puedes indicarle a eventually que ignore excepciones específicas; cualquier otra hará fallar la prueba inmediatamente. Llamamos a estas excepciones excepciones esperadas.

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.

val config = eventuallyConfig {
duration = 5.seconds
expectedExceptions = setOf(UserNotFoundException::class)
}

eventually(config) {
userRepository.getById(1).name shouldBe "bob"
}

Como alternativa a pasar un conjunto de excepciones, podemos proporcionar una función que reciba la excepción lanzada. Esta función debe devolver true si la excepción debe ignorarse, o false si debe propagarse.

val config = eventuallyConfig {
duration = 5.seconds
expectedExceptions = { it is UserNotFoundException }
}

eventually(config) {
userRepository.getById(1).name shouldBe "bob"
}

Listeners (Oyentes)

Podemos adjuntar un listener que se invocará en cada iteración, recibiendo el número de iteración actual y la excepción que causó el fallo. Nota: El listener no se ejecutará en invocaciones exitosas.

val config = eventuallyConfig {
duration = 5.seconds
listener = { k, throwable -> println("Iteration $k, with cause $throwable") }
}

eventually(config) {
userRepository.getById(1).name shouldBe "bob"
}

Compartir configuración

Compartir la configuración para eventually es sencillo con el builder eventuallyConfig. Supón que has clasificado las operaciones de tu sistema en "rápidas" y "lentas". En lugar de recordar qué valores temporales corresponden a cada categoría, podemos crear objetos compartidos entre pruebas y personalizarlos por suite. ¡Este es también el momento perfecto para mostrar las capacidades de listeners de eventually, que te permiten monitorear el valor actual del resultado de tu productor y el estado de las iteraciones!

val slow = eventuallyConfig {
duration = 5.minutes
interval = 25.milliseconds.fibonacci()
listener = { i, t -> logger.info("Current $i after {${t.times} attempts") }
}

val fast = slow.copy(duration = 5.seconds)

class FooTests : FunSpec({
test("server eventually provides a result for /foo") {
eventually(slow) {
fooApi()
}
}
})

class BarTests : FunSpec({
test("server eventually provides a result for /bar") {
eventually(fast) {
barApi()
}
}
})