From 7598f0f5e27c87e8cb4b6f849243075ce3770bab Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Wed, 27 Jan 2021 13:05:57 -0800 Subject: [PATCH 1/8] initial work to replace until with eventually and make eventually more configurable --- .editorconfig | 5 + .gitattributes | 2 + build.gradle.kts | 8 +- .../kotest-assertions-core/build.gradle.kts | 2 +- .../assertions/timing/EventuallyTest.kt | 76 ++++++++- .../kotest/assertions/until/UntilTest.kt | 1 + .../kotest-assertions-shared/build.gradle.kts | 8 - .../assertions/NondeterministicHelpers.kt | 19 +++ .../io/kotest/assertions/timing/Eventually.kt | 158 ++++++++++++------ .../kotest/assertions/timing/continually.kt | 66 +++++--- .../io/kotest/assertions/until/until.kt | 93 ++++------- 11 files changed, 294 insertions(+), 144 deletions(-) create mode 100644 .gitattributes create mode 100644 kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt diff --git a/.editorconfig b/.editorconfig index 6df5107d916..6f4a02b180e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,8 @@ max_line_length = 120 trim_trailing_whitespace = true insert_final_newline = true + +end_of_line = lf + +[*.bat] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..6c84be07dc0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text=auto eol=crlf diff --git a/build.gradle.kts b/build.gradle.kts index 09b52ccf7d8..1f7ba9687ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,8 +81,12 @@ kotlin { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = "1.8" - kotlinOptions.apiVersion = "1.4" + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlin.time.ExperimentalTime") + jvmTarget = "1.8" + apiVersion = "1.4" + } + } val publications: PublicationContainer = (extensions.getByName("publishing") as PublishingExtension).publications diff --git a/kotest-assertions/kotest-assertions-core/build.gradle.kts b/kotest-assertions/kotest-assertions-core/build.gradle.kts index 3d42b104f7a..fa8fbaf8ae0 100644 --- a/kotest-assertions/kotest-assertions-core/build.gradle.kts +++ b/kotest-assertions/kotest-assertions-core/build.gradle.kts @@ -41,7 +41,7 @@ kotlin { targets.all { compilations.all { kotlinOptions { - freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlin.time.ExperimentalTime") } } } diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt index ccbfeea9bd8..7a2dbe53b4a 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt @@ -1,8 +1,13 @@ package com.sksamuel.kotest.assertions.timing import io.kotest.assertions.fail +import io.kotest.assertions.nondeterministicListener import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.timing.eventually +import io.kotest.assertions.until.fibonacci +import io.kotest.assertions.until.fixed +import io.kotest.assertions.until.until +import io.kotest.assertions.until.untilListener import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual @@ -12,6 +17,8 @@ import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.delay import java.io.FileNotFoundException import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.time.ExperimentalTime import kotlin.time.TimeSource import kotlin.time.days @@ -64,13 +71,16 @@ class EventuallyTest : WordSpec() { } "fail tests throw unexpected exception type" { shouldThrow { - eventually(2.seconds, IOException::class) { + eventually(2.seconds, exceptionClass = IOException::class) { (null as String?)!!.length } } } "pass tests that throws FileNotFoundException for some time" { val end = System.currentTimeMillis() + 150 + eventually(duration = 5.days) { + + } eventually(5.days) { if (System.currentTimeMillis() < end) throw FileNotFoundException("foo") @@ -116,9 +126,9 @@ class EventuallyTest : WordSpec() { System.currentTimeMillis() } } - "allow configuring poll delay" { + "allow configuring interval delay" { var count = 0 - eventually(200.milliseconds, 40.milliseconds) { + eventually(200.milliseconds, 40.milliseconds.fixed()) { count += 1 } count.shouldBeLessThan(6) @@ -133,6 +143,66 @@ class EventuallyTest : WordSpec() { } mark.elapsedNow().toLongMilliseconds().shouldBeGreaterThanOrEqual(50) } + + "eventually with boolean predicate" { + eventually(5.seconds) { + System.currentTimeMillis() > 0 + } + } + + "eventually with boolean predicate and interval" { + eventually(5.seconds, 1.seconds.fixed()) { + System.currentTimeMillis() > 0 + } + } + + "eventually with T predicate" { + var t = "" + eventually(5.seconds, predicate = { t == "xxxx" }) { + t += "x" + } + } + + "eventually with T predicate and interval" { + var t = "" + val result = eventually(5.seconds, 250.milliseconds.fixed(), { t == "xxxxxxxxxxx" }) { + t += "x" + t + } + result shouldBe "xxxxxxxxxxx" + } + + "eventually with T predicate, interval, and listener" { + var t = "" + val latch = CountDownLatch(5) + val listener = nondeterministicListener { latch.countDown() } + val result = eventually(5.seconds, 250.milliseconds.fixed(), listener, predicate = { t == "xxxxxxxxxxx" }) { + t += "x" + t + } + latch.await(15, TimeUnit.SECONDS) shouldBe true + result shouldBe "xxxxxxxxxxx" + } + + "fail tests that fail a predicate" { + shouldThrow { + eventually(1.seconds, { it == 2 }) { + 1 + } + } + } + + "support fibonacci intervals" { + var t = "" + val latch = CountDownLatch(5) + val listener = nondeterministicListener { latch.countDown() } + val result = eventually(10.seconds, 200.milliseconds.fibonacci(), listener, predicate = { t == "xxxxxx" }) { + t += "x" + t + } + latch.await(10, TimeUnit.SECONDS) shouldBe true + result shouldBe "xxxxxx" + } } } } diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/until/UntilTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/until/UntilTest.kt index 5472074fe26..b074caa98b2 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/until/UntilTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/until/UntilTest.kt @@ -1,6 +1,7 @@ package com.sksamuel.kotest.assertions.until import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.timing.eventually import io.kotest.assertions.until.fibonacci import io.kotest.assertions.until.fixed import io.kotest.assertions.until.until diff --git a/kotest-assertions/kotest-assertions-shared/build.gradle.kts b/kotest-assertions/kotest-assertions-shared/build.gradle.kts index 2bacdd00ef1..25f6909d36a 100644 --- a/kotest-assertions/kotest-assertions-shared/build.gradle.kts +++ b/kotest-assertions/kotest-assertions-shared/build.gradle.kts @@ -37,14 +37,6 @@ kotlin { iosArm32() } - targets.all { - compilations.all { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn" - } - } - } - sourceSets { val commonMain by getting { diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt new file mode 100644 index 00000000000..7ae9862e3e4 --- /dev/null +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt @@ -0,0 +1,19 @@ +package io.kotest.assertions + +typealias SuspendingPredicate = suspend (T) -> Boolean + +typealias SuspendingProducer = suspend () -> T + +interface NondeterministicListener { + suspend fun onEval(t: T) + + companion object { + val noop = object : NondeterministicListener { + override suspend fun onEval(t: Any?) { } + } + } +} + +fun nondeterministicListener(f: suspend (T) -> Unit) = object : NondeterministicListener { + override suspend fun onEval(t: T) = f(t) +} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt index a50a5874a0f..885d5fb1cae 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt @@ -1,6 +1,11 @@ package io.kotest.assertions.timing +import io.kotest.assertions.NondeterministicListener +import io.kotest.assertions.SuspendingPredicate +import io.kotest.assertions.SuspendingProducer import io.kotest.assertions.failure +import io.kotest.assertions.until.Interval +import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay import kotlin.reflect.KClass import kotlin.time.Duration @@ -9,63 +14,116 @@ import kotlin.time.TimeSource import kotlin.time.milliseconds @OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, f: suspend () -> T): T = - eventually(duration, 25.milliseconds, Exception::class, f) +data class Eventually ( + val duration: Duration = Duration.INFINITE, + val interval: Interval = 25.milliseconds.fixed(), + val listener: NondeterministicListener = NondeterministicListener.noop, + val retries: Int = Int.MAX_VALUE, + val exceptionClass: KClass? = null, +) { + init { + require(retries > 0) { "Retries should not be less than one" } + } + + fun withInterval(interval: Interval) = this.copy(interval = interval) + fun withListener(listener: NondeterministicListener) = this.copy(listener = listener) + fun withRetries(retries: Int) = this.copy(retries = retries) + + suspend operator fun invoke( + predicate: SuspendingPredicate = { true }, + f: SuspendingProducer, + ): T { + val end = TimeSource.Monotonic.markNow().plus(duration) + var times = 0 + var firstError: Throwable? = null + var lastError: Throwable? = null + while (end.hasNotPassedNow() && times < retries) { + try { + val result = f() + listener.onEval(result) + if (predicate(result)) { + return result + } + } catch (e: Throwable) { + if (AssertionError::class.isInstance(e) || exceptionClass?.isInstance(e) == true) { + if (firstError == null) { + firstError = e + } else { + lastError = e + } + } else { + throw e + } + } + times++ + delay(interval.next(times)) + } + + val message = StringBuilder().apply { + append("Eventually block failed after ${duration}; attempted $times time(s); $interval delay between attempts") + + if (firstError != null) { + appendLine("The first error was caused by: ${firstError.message}") + appendLine(firstError.stackTraceToString()) + } + + if (lastError != null) { + appendLine("The last error was caused by: ${lastError.message}") + appendLine(lastError.stackTraceToString()) + } + } + + throw failure(message.toString()) + } +} @OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, poll: Duration, f: suspend () -> T): T = - eventually(duration, poll, Exception::class, f) +suspend fun eventually( + duration: Duration = Duration.INFINITE, + interval: Interval = 25.milliseconds.fixed(), + listener: NondeterministicListener = NondeterministicListener.noop, + retries: Int = Int.MAX_VALUE, + exceptionClass: KClass = Exception::class, + predicate: SuspendingPredicate = { true }, + f: SuspendingProducer +): T = Eventually(duration, interval, listener, retries, exceptionClass).invoke(predicate, f) @OptIn(ExperimentalTime::class) -suspend fun eventually( - duration: Duration, - exceptionClass: KClass, - f: suspend () -> T -): T = eventually(duration, 25.milliseconds, exceptionClass, f) +suspend fun eventually(duration: Duration, interval: Interval, f: SuspendingProducer): T = + eventually(duration, interval, f = f) @OptIn(ExperimentalTime::class) -suspend fun eventually( - duration: Duration, - poll: Duration, - exceptionClass: KClass, - f: suspend () -> T -): T { - val end = TimeSource.Monotonic.markNow().plus(duration) - var times = 0 - var firstError: Throwable? = null - var lastError: Throwable? = null - while (end.hasNotPassedNow()) { - try { - return f() - } catch (e: Throwable) { - // we only accept exceptions of type exceptionClass and AssertionError - // if we didn't accept AssertionError then a matcher failure would immediately fail this function - if (!exceptionClass.isInstance(e) && !AssertionError::class.isInstance(e)) - throw e - if (firstError == null) - firstError = e - else - lastError = e - } - times++ - delay(poll.toLongMilliseconds()) - } +suspend fun eventually(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(duration, predicate = predicate, f = f) - val sb = StringBuilder() - sb.appendLine("Eventually block failed after ${duration}; attempted $times time(s); $poll delay between attempts") - sb.appendLine() +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, exceptionClass: KClass, f: SuspendingProducer): T = + eventually(duration, exceptionClass = exceptionClass, f = f) - if (firstError != null) { - sb.appendLine("The first error was caused by: ${firstError.message}") - sb.appendLine(firstError.stackTraceToString()) - } +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, interval: Interval, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(duration, interval, predicate = predicate, f = f) - if (lastError != null) { - sb.appendLine("The last error was caused by: ${lastError.message}") - sb.appendLine(lastError.stackTraceToString()) - } +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, interval: Interval, exceptionClass: KClass, f: SuspendingProducer): T = + eventually(duration, interval, exceptionClass = exceptionClass, f = f) + +@OptIn(ExperimentalTime::class) +@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", + ReplaceWith("eventually(duration, poll.fixed(), f = f)", "io.kotest.assertions.until.fixed") +) +suspend fun eventually(duration: Duration, poll: Duration, f: SuspendingProducer): T = + eventually(duration, poll.fixed(), f = f) + +@OptIn(ExperimentalTime::class) +@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", + ReplaceWith("eventually(duration, poll.fixed(), predicate = predicate, f = f)", "io.kotest.assertions.until.fixed")) +suspend fun eventually(duration: Duration, poll: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(duration, poll.fixed(), predicate = predicate, f = f) + +@OptIn(ExperimentalTime::class) +@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", + ReplaceWith("eventually(duration, poll.fixed(), exceptionClass = exceptionClass, f = f)", "io.kotest.assertions.until.fixed")) +suspend fun eventually(duration: Duration, poll: Duration, exceptionClass: KClass, f: suspend () -> T): T = + eventually(duration, poll.fixed(), exceptionClass = exceptionClass, f = f) - throw failure( - sb.toString() - ) -} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt index 9fa2d0ade9f..93b0ff63369 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt @@ -1,6 +1,10 @@ package io.kotest.assertions.timing +import io.kotest.assertions.NondeterministicListener +import io.kotest.assertions.SuspendingProducer import io.kotest.assertions.failure +import io.kotest.assertions.until.Interval +import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay import kotlin.time.Duration import kotlin.time.ExperimentalTime @@ -8,30 +12,44 @@ import kotlin.time.TimeSource import kotlin.time.milliseconds @OptIn(ExperimentalTime::class) -suspend fun continually(duration: Duration, f: suspend () -> T): T? = continually(duration, 10.milliseconds, f) - -@OptIn(ExperimentalTime::class) -suspend fun continually(duration: Duration, poll: Duration, f: suspend () -> T): T? { - val mark = TimeSource.Monotonic.markNow() - val end = mark.plus(duration) - var times = 0 - var result: T? = null - while (end.hasNotPassedNow()) { - try { - result = f() - } catch (e: AssertionError) { - // if this is the first time the check was executed then just rethrow the underlying error - if (times == 0) - throw e - // if not the first attempt then include how many times/for how long the test passed - throw failure( - "Test failed after ${mark.elapsedNow() - .toLongMilliseconds()}ms; expected to pass for ${duration.toLongMilliseconds()}ms; attempted $times times\nUnderlying failure was: ${e.message}", - e - ) +data class Continually ( + val duration: Duration = Duration.INFINITE, + val interval: Interval = 25.milliseconds.fixed(), + val listener: NondeterministicListener = NondeterministicListener.noop, +) { + suspend operator fun invoke(f: SuspendingProducer): T? { + val mark = TimeSource.Monotonic.markNow() + val end = mark.plus(duration) + var times = 0 + var result: T? = null + while (end.hasNotPassedNow()) { + try { + result = f() + listener.onEval(result) + } catch (e: AssertionError) { + // if this is the first time the check was executed then just rethrow the underlying error + if (times == 0) + throw e + // if not the first attempt then include how many times/for how long the test passed + throw failure( + "Test failed after ${mark.elapsedNow()}; expected to pass for ${duration.toLongMilliseconds()}ms; attempted $times times\nUnderlying failure was: ${e.message}", + e + ) + } + times++ + delay(interval.next(times)) } - times++ - delay(poll.toLongMilliseconds()) + return result } - return result } + +@OptIn(ExperimentalTime::class) +@Deprecated("Use continually with an interval, using Duration based poll is deprecated", + ReplaceWith("continually(duration, poll.fixed(), f = f)", "io.kotest.assertions.until.fixed") +) +suspend fun continually(duration: Duration, poll: Duration, f: suspend () -> T) = + continually(duration, poll.fixed(), f = f) + +@OptIn(ExperimentalTime::class) +suspend fun continually(duration: Duration, interval: Interval = 10.milliseconds.fixed(), f: suspend () -> T) = + Continually(duration, interval).invoke(f) diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt index 191efa97e1f..e01071fa3c3 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt @@ -1,12 +1,14 @@ package io.kotest.assertions.until -import io.kotest.assertions.failure -import kotlinx.coroutines.delay +import io.kotest.assertions.SuspendingPredicate +import io.kotest.assertions.SuspendingProducer +import io.kotest.assertions.nondeterministicListener +import io.kotest.assertions.timing.eventually import kotlin.time.Duration import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource import kotlin.time.seconds +@Deprecated("Use NondeterministicListener") interface UntilListener { fun onEval(t: T) @@ -17,69 +19,48 @@ interface UntilListener { } } +@Deprecated("Use nondeterministicListener") fun untilListener(f: (T) -> Unit) = object : UntilListener { override fun onEval(t: T) { f(t) } } -/** - * Executes a function until it returns true or the duration elapses. - * - * @param f the function to execute - * @param duration the maximum amount of time to continue trying for success - * @param interval the delay between invocations - */ @OptIn(ExperimentalTime::class) -suspend fun until( - duration: Duration, - interval: Interval = 1.seconds.fixed(), - f: () -> Boolean -) = until(duration, interval, { it }, UntilListener.noop, f) +@Deprecated( + "Use eventually", + ReplaceWith("eventually(duration, interval, f = f)", "io.kotest.assertions.timing.eventually") +) +suspend fun until(duration: Duration, interval: Interval = 1.seconds.fixed(), f: suspend () -> Boolean) = + eventually(duration, interval, f = f) @OptIn(ExperimentalTime::class) -suspend fun until( - duration: Duration, - predicate: (T) -> Boolean, - f: () -> T -): T = until(duration, 1.seconds.fixed(), predicate, UntilListener.noop, f) +@Deprecated( + "Use eventually", ReplaceWith( + "eventually(duration, 1.seconds.fixed(), predicate = predicate, f = f)", + "io.kotest.assertions.timing.eventually", + "kotlin.time.seconds" + ) +) +suspend fun until(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(duration, 1.seconds.fixed(), predicate = predicate, f = f) @OptIn(ExperimentalTime::class) -suspend fun until( - duration: Duration, - interval: Interval, - predicate: (T) -> Boolean, - f: () -> T -): T = until(duration, interval, predicate = predicate, listener = UntilListener.noop, f = f) +@Deprecated( + "Use eventually", ReplaceWith( + "eventually(duration, interval, predicate = predicate, f = f)", + "io.kotest.assertions.timing.eventually" + ) +) +suspend fun until(duration: Duration, interval: Interval, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(duration, interval, predicate = predicate, f = f) -/** - * Executes a function until the given predicate returns true or the duration elapses. - * - * @param f the function to execute - * @param predicate passed the result of the function f to evaluate if successful - * @param listener notified on each invocation of f - * @param duration the maximum amount of time to continue trying for success - * @param interval the delay between invocations - */ @OptIn(ExperimentalTime::class) -suspend fun until( - duration: Duration, - interval: Interval, - predicate: (T) -> Boolean, - listener: UntilListener, - f: () -> T -): T { - val end = TimeSource.Monotonic.markNow().plus(duration) - var count = 0 - while (end.hasNotPassedNow()) { - val result = f() - if (predicate(result)) { - return result - } else { - listener.onEval(result) - count++ - } - delay(interval.next(count).toLongMilliseconds()) - } - throw failure("Test failed after ${duration.toLongMilliseconds()}ms; attempted $count times") -} +@Deprecated( + "Use eventually", ReplaceWith( + "eventually(duration, interval, listener = nondeterministicListener { listener.onEval(it) }, predicate = predicate, f = f)", + "io.kotest.assertions.timing.eventually" + ) +) +suspend fun until(duration: Duration, interval: Interval, predicate: SuspendingPredicate, listener: UntilListener, f: SuspendingProducer): T = + eventually(duration, interval, listener = nondeterministicListener { listener.onEval(it) }, predicate = predicate, f = f) From 3b2b274e113026a4e44350cf0aa47fe52143730c Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Thu, 28 Jan 2021 18:23:57 -0800 Subject: [PATCH 2/8] add eventually state to listener --- .../assertions/NondeterministicHelpers.kt | 20 ++++++++++--------- .../io/kotest/assertions/timing/Eventually.kt | 13 ++++++------ .../io/kotest/assertions/until/until.kt | 5 ++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt index 7ae9862e3e4..14c77009d4f 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt @@ -1,19 +1,21 @@ package io.kotest.assertions +import kotlin.time.ExperimentalTime +import kotlin.time.TimeMark + typealias SuspendingPredicate = suspend (T) -> Boolean typealias SuspendingProducer = suspend () -> T -interface NondeterministicListener { - suspend fun onEval(t: T) +@OptIn(ExperimentalTime::class) +data class NondeterministicState ( + val start: TimeMark, val end: TimeMark, val times: Int, val firstError: Throwable?, val lastError: Throwable? +) + +fun interface NondeterministicListener { + fun onEval(t: T, state: NondeterministicState) companion object { - val noop = object : NondeterministicListener { - override suspend fun onEval(t: Any?) { } - } + val noop = NondeterministicListener { _, _ -> } } } - -fun nondeterministicListener(f: suspend (T) -> Unit) = object : NondeterministicListener { - override suspend fun onEval(t: T) = f(t) -} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt index 885d5fb1cae..e0a07eeb7c2 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt @@ -1,9 +1,6 @@ package io.kotest.assertions.timing -import io.kotest.assertions.NondeterministicListener -import io.kotest.assertions.SuspendingPredicate -import io.kotest.assertions.SuspendingProducer -import io.kotest.assertions.failure +import io.kotest.assertions.* import io.kotest.assertions.until.Interval import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay @@ -33,14 +30,16 @@ data class Eventually ( predicate: SuspendingPredicate = { true }, f: SuspendingProducer, ): T { - val end = TimeSource.Monotonic.markNow().plus(duration) + val start = TimeSource.Monotonic.markNow() + val end = start.plus(duration) var times = 0 var firstError: Throwable? = null var lastError: Throwable? = null + while (end.hasNotPassedNow() && times < retries) { try { val result = f() - listener.onEval(result) + listener.onEval(result, NondeterministicState(start, end, times, firstError, lastError)) if (predicate(result)) { return result } @@ -60,7 +59,7 @@ data class Eventually ( } val message = StringBuilder().apply { - append("Eventually block failed after ${duration}; attempted $times time(s); $interval delay between attempts") + append("Eventually block failed after ${duration}; attempted ${times} time(s); $interval delay between attempts") if (firstError != null) { appendLine("The first error was caused by: ${firstError.message}") diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt index e01071fa3c3..4f75739a0b9 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt @@ -2,7 +2,6 @@ package io.kotest.assertions.until import io.kotest.assertions.SuspendingPredicate import io.kotest.assertions.SuspendingProducer -import io.kotest.assertions.nondeterministicListener import io.kotest.assertions.timing.eventually import kotlin.time.Duration import kotlin.time.ExperimentalTime @@ -58,9 +57,9 @@ suspend fun until(duration: Duration, interval: Interval, predicate: Suspend @OptIn(ExperimentalTime::class) @Deprecated( "Use eventually", ReplaceWith( - "eventually(duration, interval, listener = nondeterministicListener { listener.onEval(it) }, predicate = predicate, f = f)", + "eventually(duration, interval, listener = { it, _ -> listener.onEval(it) }, predicate = predicate, f = f)", "io.kotest.assertions.timing.eventually" ) ) suspend fun until(duration: Duration, interval: Interval, predicate: SuspendingPredicate, listener: UntilListener, f: SuspendingProducer): T = - eventually(duration, interval, listener = nondeterministicListener { listener.onEval(it) }, predicate = predicate, f = f) + eventually(duration, interval, listener = { it, _ -> listener.onEval(it) }, predicate = predicate, f = f) From 415f7da4af242019b516203c6fd5e3439d797279 Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Thu, 28 Jan 2021 23:23:04 -0800 Subject: [PATCH 3/8] get tests working --- .../assertions/timing/EventuallyTest.kt | 20 ++-- .../assertions/NondeterministicHelpers.kt | 10 -- .../io/kotest/assertions/timing/Eventually.kt | 98 +++++++++---------- .../kotest/assertions/timing/continually.kt | 29 +++--- .../assertions/until/ExponentialInterval.kt | 2 + .../assertions/until/FibonacciInterval.kt | 2 + .../kotest/assertions/until/FixedInterval.kt | 2 + .../io/kotest/assertions/until/Interval.kt | 2 +- .../io/kotest/assertions/until/until.kt | 28 ++++-- 9 files changed, 98 insertions(+), 95 deletions(-) diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt index 7a2dbe53b4a..6acb02c24b7 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt @@ -1,13 +1,10 @@ package com.sksamuel.kotest.assertions.timing import io.kotest.assertions.fail -import io.kotest.assertions.nondeterministicListener import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.timing.eventually import io.kotest.assertions.until.fibonacci import io.kotest.assertions.until.fixed -import io.kotest.assertions.until.until -import io.kotest.assertions.until.untilListener import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual @@ -78,9 +75,6 @@ class EventuallyTest : WordSpec() { } "pass tests that throws FileNotFoundException for some time" { val end = System.currentTimeMillis() + 150 - eventually(duration = 5.days) { - - } eventually(5.days) { if (System.currentTimeMillis() < end) throw FileNotFoundException("foo") @@ -116,7 +110,7 @@ class EventuallyTest : WordSpec() { } } }.message - message.shouldContain("Eventually block failed after 100ms; attempted \\d+ time\\(s\\); 25.0ms delay between attempts".toRegex()) + message.shouldContain("Eventually block failed after 100ms; attempted \\d+ time\\(s\\); FixedInterval\\(duration=25.0ms\\) delay between attempts".toRegex()) message.shouldContain("The first error was caused by: first") message.shouldContain("The last error was caused by: last") } @@ -165,7 +159,7 @@ class EventuallyTest : WordSpec() { "eventually with T predicate and interval" { var t = "" - val result = eventually(5.seconds, 250.milliseconds.fixed(), { t == "xxxxxxxxxxx" }) { + val result = eventually(5.seconds, 250.milliseconds.fixed(), predicate = { t == "xxxxxxxxxxx" }) { t += "x" t } @@ -175,8 +169,8 @@ class EventuallyTest : WordSpec() { "eventually with T predicate, interval, and listener" { var t = "" val latch = CountDownLatch(5) - val listener = nondeterministicListener { latch.countDown() } - val result = eventually(5.seconds, 250.milliseconds.fixed(), listener, predicate = { t == "xxxxxxxxxxx" }) { + val result = eventually(5.seconds, 250.milliseconds.fixed(), + listener = { _, _ -> latch.countDown() }, predicate = { t == "xxxxxxxxxxx" }) { t += "x" t } @@ -186,7 +180,7 @@ class EventuallyTest : WordSpec() { "fail tests that fail a predicate" { shouldThrow { - eventually(1.seconds, { it == 2 }) { + eventually(1.seconds, predicate = { it == 2 }) { 1 } } @@ -195,8 +189,8 @@ class EventuallyTest : WordSpec() { "support fibonacci intervals" { var t = "" val latch = CountDownLatch(5) - val listener = nondeterministicListener { latch.countDown() } - val result = eventually(10.seconds, 200.milliseconds.fibonacci(), listener, predicate = { t == "xxxxxx" }) { + val result = eventually(10.seconds, 200.milliseconds.fibonacci(), + listener = { _, _ -> latch.countDown() }, predicate = { t == "xxxxxx" }) { t += "x" t } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt index 14c77009d4f..22cf19769d3 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt @@ -7,15 +7,5 @@ typealias SuspendingPredicate = suspend (T) -> Boolean typealias SuspendingProducer = suspend () -> T -@OptIn(ExperimentalTime::class) -data class NondeterministicState ( - val start: TimeMark, val end: TimeMark, val times: Int, val firstError: Throwable?, val lastError: Throwable? -) -fun interface NondeterministicListener { - fun onEval(t: T, state: NondeterministicState) - companion object { - val noop = NondeterministicListener { _, _ -> } - } -} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt index e0a07eeb7c2..1d3c2ebed47 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt @@ -5,16 +5,44 @@ import io.kotest.assertions.until.Interval import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay import kotlin.reflect.KClass -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource -import kotlin.time.milliseconds +import kotlin.time.* + +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, f: SuspendingProducer): T = + Eventually(duration = duration, exceptionClass = Throwable::class).invoke(f = f) + +@OptIn(ExperimentalTime::class) +@Deprecated(""" +Use eventually with an interval, using Duration based poll is deprecated. +To convert an existing duration to an interval you can Duration.fixed(), Duration.exponential(), or Duration.fibonacci(). +""", + ReplaceWith( + "eventually(duration, interval = poll.fixed(), f = f)", + "io.kotest.assertions.until.fixed" + )) +suspend fun eventually(duration: Duration, poll: Duration, f: SuspendingProducer): T = + Eventually(duration = duration, interval = poll.fixed(), exceptionClass = Throwable::class).invoke(f = f) + +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, exceptionClass: KClass, f: SuspendingProducer): T = + Eventually(duration = duration, exceptionClass = exceptionClass).invoke(f = f) + +@OptIn(ExperimentalTime::class) +suspend fun eventually( + duration: Duration = Duration.INFINITE, + interval: Interval = 25.milliseconds.fixed(), + listener: EventuallyListener = EventuallyListener.noop, + retries: Int = Int.MAX_VALUE, + exceptionClass: KClass? = Throwable::class, + predicate: SuspendingPredicate = { true }, + f: SuspendingProducer +): T = Eventually(duration, interval, listener, retries, exceptionClass).invoke(predicate, f) @OptIn(ExperimentalTime::class) data class Eventually ( val duration: Duration = Duration.INFINITE, val interval: Interval = 25.milliseconds.fixed(), - val listener: NondeterministicListener = NondeterministicListener.noop, + val listener: EventuallyListener = EventuallyListener.noop, val retries: Int = Int.MAX_VALUE, val exceptionClass: KClass? = null, ) { @@ -23,7 +51,7 @@ data class Eventually ( } fun withInterval(interval: Interval) = this.copy(interval = interval) - fun withListener(listener: NondeterministicListener) = this.copy(listener = listener) + fun withListener(listener: EventuallyListener) = this.copy(listener = listener) fun withRetries(retries: Int) = this.copy(retries = retries) suspend operator fun invoke( @@ -39,7 +67,7 @@ data class Eventually ( while (end.hasNotPassedNow() && times < retries) { try { val result = f() - listener.onEval(result, NondeterministicState(start, end, times, firstError, lastError)) + listener.onEval(result, EventuallyState(start, end, times, firstError, lastError)) if (predicate(result)) { return result } @@ -59,7 +87,7 @@ data class Eventually ( } val message = StringBuilder().apply { - append("Eventually block failed after ${duration}; attempted ${times} time(s); $interval delay between attempts") + appendLine("Eventually block failed after ${duration}; attempted $times time(s); $interval delay between attempts") if (firstError != null) { appendLine("The first error was caused by: ${firstError.message}") @@ -77,52 +105,14 @@ data class Eventually ( } @OptIn(ExperimentalTime::class) -suspend fun eventually( - duration: Duration = Duration.INFINITE, - interval: Interval = 25.milliseconds.fixed(), - listener: NondeterministicListener = NondeterministicListener.noop, - retries: Int = Int.MAX_VALUE, - exceptionClass: KClass = Exception::class, - predicate: SuspendingPredicate = { true }, - f: SuspendingProducer -): T = Eventually(duration, interval, listener, retries, exceptionClass).invoke(predicate, f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, interval: Interval, f: SuspendingProducer): T = - eventually(duration, interval, f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = - eventually(duration, predicate = predicate, f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, exceptionClass: KClass, f: SuspendingProducer): T = - eventually(duration, exceptionClass = exceptionClass, f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, interval: Interval, predicate: SuspendingPredicate, f: SuspendingProducer): T = - eventually(duration, interval, predicate = predicate, f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, interval: Interval, exceptionClass: KClass, f: SuspendingProducer): T = - eventually(duration, interval, exceptionClass = exceptionClass, f = f) - -@OptIn(ExperimentalTime::class) -@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", - ReplaceWith("eventually(duration, poll.fixed(), f = f)", "io.kotest.assertions.until.fixed") +data class EventuallyState ( + val start: TimeMark, val end: TimeMark, val times: Int, val firstError: Throwable?, val lastError: Throwable?, ) -suspend fun eventually(duration: Duration, poll: Duration, f: SuspendingProducer): T = - eventually(duration, poll.fixed(), f = f) -@OptIn(ExperimentalTime::class) -@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", - ReplaceWith("eventually(duration, poll.fixed(), predicate = predicate, f = f)", "io.kotest.assertions.until.fixed")) -suspend fun eventually(duration: Duration, poll: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = - eventually(duration, poll.fixed(), predicate = predicate, f = f) - -@OptIn(ExperimentalTime::class) -@Deprecated("Use eventually with an interval, using Duration based poll is deprecated", - ReplaceWith("eventually(duration, poll.fixed(), exceptionClass = exceptionClass, f = f)", "io.kotest.assertions.until.fixed")) -suspend fun eventually(duration: Duration, poll: Duration, exceptionClass: KClass, f: suspend () -> T): T = - eventually(duration, poll.fixed(), exceptionClass = exceptionClass, f = f) +fun interface EventuallyListener { + fun onEval(t: T, state: EventuallyState) + companion object { + val noop = EventuallyListener { _, _ -> } + } +} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt index 93b0ff63369..24ad0b050e9 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/continually.kt @@ -1,38 +1,45 @@ package io.kotest.assertions.timing -import io.kotest.assertions.NondeterministicListener import io.kotest.assertions.SuspendingProducer import io.kotest.assertions.failure import io.kotest.assertions.until.Interval import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource -import kotlin.time.milliseconds +import kotlin.time.* + +@OptIn(ExperimentalTime::class) +data class ContinuallyState(val start: TimeMark, val end: TimeMark, val times: Int) + +fun interface ContinuallyListener { + fun onEval(t: T, state: ContinuallyState) + + companion object { + val noop = ContinuallyListener { _, _ -> } + } +} @OptIn(ExperimentalTime::class) data class Continually ( val duration: Duration = Duration.INFINITE, val interval: Interval = 25.milliseconds.fixed(), - val listener: NondeterministicListener = NondeterministicListener.noop, -) { + val listener: ContinuallyListener = ContinuallyListener.noop, + ) { suspend operator fun invoke(f: SuspendingProducer): T? { - val mark = TimeSource.Monotonic.markNow() - val end = mark.plus(duration) + val start = TimeSource.Monotonic.markNow() + val end = start.plus(duration) var times = 0 var result: T? = null while (end.hasNotPassedNow()) { try { result = f() - listener.onEval(result) + listener.onEval(result, ContinuallyState(start, end, times)) } catch (e: AssertionError) { // if this is the first time the check was executed then just rethrow the underlying error if (times == 0) throw e // if not the first attempt then include how many times/for how long the test passed throw failure( - "Test failed after ${mark.elapsedNow()}; expected to pass for ${duration.toLongMilliseconds()}ms; attempted $times times\nUnderlying failure was: ${e.message}", + "Test failed after ${start.elapsedNow()}; expected to pass for ${duration.toLongMilliseconds()}ms; attempted $times times\nUnderlying failure was: ${e.message}", e ) } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/ExponentialInterval.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/ExponentialInterval.kt index 96d96b77820..4a089daec08 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/ExponentialInterval.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/ExponentialInterval.kt @@ -7,6 +7,8 @@ import kotlin.time.milliseconds @OptIn(ExperimentalTime::class) class ExponentialInterval(private val base: Duration) : Interval { + override fun toString() = "ExponentialInterval(${::base.name}=$base)" + override fun next(count: Int): Duration { val amount = base.inMilliseconds.pow(count.toDouble()).toLong() return amount.milliseconds diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FibonacciInterval.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FibonacciInterval.kt index fd906c7d648..f774401fcb0 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FibonacciInterval.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FibonacciInterval.kt @@ -21,6 +21,8 @@ class FibonacciInterval(private val base: Duration, private val offset: Int) : I require(offset >= 0) { "Offset must be greater than or equal to 0" } } + override fun toString() = "FibonacciInterval(${::base.name}=$base, ${::offset.name}=$offset)" + override fun next(count: Int): Duration { val baseMs = base.toLongMilliseconds() val total = baseMs * fibonacci(offset + count) diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FixedInterval.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FixedInterval.kt index ac9fcbead7d..2af09b7d035 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FixedInterval.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/FixedInterval.kt @@ -8,6 +8,8 @@ import kotlin.time.ExperimentalTime */ @OptIn(ExperimentalTime::class) class FixedInterval(private val duration: Duration) : Interval { + override fun toString() = "FixedInterval(${::duration.name}=$duration)" + override fun next(count: Int): Duration { return duration } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/Interval.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/Interval.kt index 273eaee5b8d..9309b382e73 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/Interval.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/Interval.kt @@ -6,6 +6,7 @@ import kotlin.time.ExperimentalTime /** * A [Interval] determines how often Kotest will invoke the predicate function for an [until] block. */ +@OptIn(ExperimentalTime::class) interface Interval { /** @@ -16,6 +17,5 @@ interface Interval { * * @return The duration of the next poll interval */ - @OptIn(ExperimentalTime::class) fun next(count: Int): Duration } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt index 4f75739a0b9..5c72fcac16c 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/until/until.kt @@ -27,15 +27,19 @@ fun untilListener(f: (T) -> Unit) = object : UntilListener { @OptIn(ExperimentalTime::class) @Deprecated( - "Use eventually", - ReplaceWith("eventually(duration, interval, f = f)", "io.kotest.assertions.timing.eventually") + "Use eventually or Eventually.invoke", + ReplaceWith( + "eventually(duration, interval, f = f)", + "io.kotest.assertions.timing.eventually" + ) ) suspend fun until(duration: Duration, interval: Interval = 1.seconds.fixed(), f: suspend () -> Boolean) = eventually(duration, interval, f = f) @OptIn(ExperimentalTime::class) @Deprecated( - "Use eventually", ReplaceWith( + "Use eventually or Eventually.invoke", + ReplaceWith( "eventually(duration, 1.seconds.fixed(), predicate = predicate, f = f)", "io.kotest.assertions.timing.eventually", "kotlin.time.seconds" @@ -46,12 +50,18 @@ suspend fun until(duration: Duration, predicate: SuspendingPredicate, f: @OptIn(ExperimentalTime::class) @Deprecated( - "Use eventually", ReplaceWith( + "Use eventually or Eventually.invoke", + ReplaceWith( "eventually(duration, interval, predicate = predicate, f = f)", "io.kotest.assertions.timing.eventually" ) ) -suspend fun until(duration: Duration, interval: Interval, predicate: SuspendingPredicate, f: SuspendingProducer): T = +suspend fun until( + duration: Duration, + interval: Interval, + predicate: SuspendingPredicate, + f: SuspendingProducer +): T = eventually(duration, interval, predicate = predicate, f = f) @OptIn(ExperimentalTime::class) @@ -61,5 +71,11 @@ suspend fun until(duration: Duration, interval: Interval, predicate: Suspend "io.kotest.assertions.timing.eventually" ) ) -suspend fun until(duration: Duration, interval: Interval, predicate: SuspendingPredicate, listener: UntilListener, f: SuspendingProducer): T = +suspend fun until( + duration: Duration, + interval: Interval, + predicate: SuspendingPredicate, + listener: UntilListener, + f: SuspendingProducer +): T = eventually(duration, interval, listener = { it, _ -> listener.onEval(it) }, predicate = predicate, f = f) From 7b51ab78ab896ae0056525569b3981132f1232f3 Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Thu, 28 Jan 2021 23:25:05 -0800 Subject: [PATCH 4/8] fix imports --- .../kotlin/io/kotest/assertions/NondeterministicHelpers.kt | 3 --- .../kotlin/io/kotest/assertions/timing/Eventually.kt | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt index 22cf19769d3..d6692ca774d 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt @@ -1,8 +1,5 @@ package io.kotest.assertions -import kotlin.time.ExperimentalTime -import kotlin.time.TimeMark - typealias SuspendingPredicate = suspend (T) -> Boolean typealias SuspendingProducer = suspend () -> T diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt index 1d3c2ebed47..b3c641b98ae 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt @@ -1,6 +1,8 @@ package io.kotest.assertions.timing -import io.kotest.assertions.* +import io.kotest.assertions.SuspendingPredicate +import io.kotest.assertions.SuspendingProducer +import io.kotest.assertions.failure import io.kotest.assertions.until.Interval import io.kotest.assertions.until.fixed import kotlinx.coroutines.delay From 4ffc0afba2370dceefc8829f63ab5d7b32a341bf Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Fri, 29 Jan 2021 19:07:15 -0800 Subject: [PATCH 5/8] add some docs --- .editorconfig | 3 + .../assertions/nondeterministic_testing.md | 71 +++++++++++++++---- .../assertions/timing/EventuallyTest.kt | 19 +++-- .../io/kotest/assertions/timing/Eventually.kt | 4 ++ 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6f4a02b180e..aa71438c994 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,6 @@ end_of_line = lf [*.bat] end_of_line = crlf + +[*.md] +indent_size = 2 diff --git a/documentation/docs/assertions/nondeterministic_testing.md b/documentation/docs/assertions/nondeterministic_testing.md index 3a6712150fb..1fdd387fde9 100644 --- a/documentation/docs/assertions/nondeterministic_testing.md +++ b/documentation/docs/assertions/nondeterministic_testing.md @@ -22,14 +22,14 @@ Or you can roll a loop and sleep and retry and sleep and retry, but this is just Another common approach is to use countdown latches and this works fine if you are able to inject the latches in the appropriate places but it isn't always possible to have the code under test trigger a latch. -As an alternative, Kotest provides the `eventually` function which will periodically -test the code until it either passes, or the timeout is reached. This is perfect for nondeterministic code. - +As an alternative, kotest provides the `eventually` function and the `Eventually` configuration which periodically test the code +ignoring your specified exceptions and ensuring the result satisfies an optional predicate, until the timeout is eventually reached or +too many iterations have passed. This is flexible and is perfect for testing nondeterministic code. ### Examples -#### Simple example +#### Simple examples Let's assume that we send a message to an asynchronous service. After the message is processed, a new row is inserted into user table. @@ -48,11 +48,6 @@ class MyTests : ShouldSpec() { } ``` - - - - - #### Exceptions By default, `eventually` will ignore any exception that is thrown inside the function (note, that means it won't catch `Error`). @@ -78,6 +73,61 @@ class MyTests : ShouldSpec() { } ``` +#### Predicates + +In addition to verifying a test case eventually runs without throwing, we can also verify the result and treat a non-throwing result as failing. + +```kotlin +class MyTests : StringSpec({ + "check that predicate eventually succeeds in time" { + var i = 0 + eventually(25.seconds, predicate = { it == 5 }) { + delay(1.seconds) + i++ + } + } +}) +``` + +#### Sharing configuration + +Sharing the configuration for eventually is a breeze with the `Eventually` data class. Suppose you have classified the operations in your +system to "slow" and "fast" operations. Instead of remembering which timing values were for slow and fast we can set up some objects to share between tests +and customize them per suite. This is also a perfect time to show off the listener capabilities of `eventually` which give you insight +into the current value of the result of your producer and the state of iterations! + +```kotlin +val slow = Eventually(5.minutes, interval = 25.milliseconds.fibonacci(), exceptionClass = ExpectedException::class) +val fast = slow.copy(duration = 5.seconds) + +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" { + fSlow.invoke { + server("/foo") + } + } +}) + +class BarTests : StringSpec({ + val logger = logger("BarTests") + val fSlow = slow.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts")}) + + "server eventually provides a result for /bar" { + fSlow.invoke { + server("/bar") + } + } +}) +``` + +Here we can see sharing of configuration can be useful to reduce duplicate code while allowing flexibility for things like +custom logging per test suite for clear test logs. + +The configuration data class `Eventually` is just a handy container for settings and doesn't execute anything, +you must instead call the invoke function with your producer (and optional predicate). ## Continually @@ -112,11 +162,8 @@ class MyTests: ShouldSpec() { } ``` - - ## Retry - Retry is similar to eventually, but rather than attempt a block of code for a period of time, it attempts a block of code a maximum number of times. We still provide a timeout period to avoid the loop running for ever. diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt index 6acb02c24b7..276fe675bc4 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt @@ -1,7 +1,9 @@ package com.sksamuel.kotest.assertions.timing +import io.kotest.assertions.assertSoftly import io.kotest.assertions.fail import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.timing.Eventually import io.kotest.assertions.timing.eventually import io.kotest.assertions.until.fibonacci import io.kotest.assertions.until.fixed @@ -16,17 +18,24 @@ import java.io.FileNotFoundException import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource -import kotlin.time.days -import kotlin.time.milliseconds -import kotlin.time.seconds +import kotlin.time.* @OptIn(ExperimentalTime::class) class EventuallyTest : WordSpec() { init { "eventually" should { + "eventually configuration can be shared" { + val slow = Eventually(duration = 5.seconds) + val fast = slow.copy(retries = 1) + + assertSoftly { + slow.retries shouldBe Int.MAX_VALUE + fast.retries shouldBe 1 + slow.duration shouldBe 5.seconds + fast.duration shouldBe 5.seconds + } + } "pass working tests" { eventually(5.days) { System.currentTimeMillis() diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt index b3c641b98ae..c59016ed9f8 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt @@ -13,6 +13,10 @@ import kotlin.time.* suspend fun eventually(duration: Duration, f: SuspendingProducer): T = Eventually(duration = duration, exceptionClass = Throwable::class).invoke(f = f) +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = + Eventually(duration = duration, exceptionClass = Throwable::class).invoke(predicate, f) + @OptIn(ExperimentalTime::class) @Deprecated(""" Use eventually with an interval, using Duration based poll is deprecated. From 01ae80c7b535aae28addc5c4534a96f13bf71534 Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Fri, 29 Jan 2021 22:17:56 -0800 Subject: [PATCH 6/8] replace invoke with an eventually that accepts config --- .../assertions/nondeterministic_testing.md | 16 +- .../assertions/timing/EventuallyTest.kt | 36 +++-- .../io/kotest/assertions/timing/Eventually.kt | 124 --------------- .../io/kotest/assertions/timing/eventually.kt | 145 ++++++++++++++++++ 4 files changed, 176 insertions(+), 145 deletions(-) delete mode 100644 kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt create mode 100644 kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/eventually.kt diff --git a/documentation/docs/assertions/nondeterministic_testing.md b/documentation/docs/assertions/nondeterministic_testing.md index 1fdd387fde9..2837b88325b 100644 --- a/documentation/docs/assertions/nondeterministic_testing.md +++ b/documentation/docs/assertions/nondeterministic_testing.md @@ -97,7 +97,7 @@ and customize them per suite. This is also a perfect time to show off the listen into the current value of the result of your producer and the state of iterations! ```kotlin -val slow = Eventually(5.minutes, interval = 25.milliseconds.fibonacci(), exceptionClass = ExpectedException::class) +val slow = EventuallyConfig(5.minutes, interval = 25.milliseconds.fibonacci(), exceptionClass = ServerException::class) val fast = slow.copy(duration = 5.seconds) class FooTests : StringSpec({ @@ -105,30 +105,28 @@ class FooTests : StringSpec({ val fSlow = slow.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts")}) "server eventually provides a result for /foo" { - fSlow.invoke { - server("/foo") + eventually(fSlow) { + fooApi() } } }) class BarTests : StringSpec({ val logger = logger("BarTests") - val fSlow = slow.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts")}) + val bFast = fast.copy(listener = { i, t -> logger.info("Current $i after {${t.times} attempts")}) "server eventually provides a result for /bar" { - fSlow.invoke { - server("/bar") + eventually(bFast) { + barApi() } } }) + ``` Here we can see sharing of configuration can be useful to reduce duplicate code while allowing flexibility for things like custom logging per test suite for clear test logs. -The configuration data class `Eventually` is just a handy container for settings and doesn't execute anything, -you must instead call the invoke function with your producer (and optional predicate). - ## Continually As the dual of eventually, `continually` allows you to assert that a block of code suceeds, and continues to succeed, for a period of time. diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt index 276fe675bc4..2985474a3fd 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt @@ -3,7 +3,7 @@ package com.sksamuel.kotest.assertions.timing import io.kotest.assertions.assertSoftly import io.kotest.assertions.fail import io.kotest.assertions.throwables.shouldThrow -import io.kotest.assertions.timing.Eventually +import io.kotest.assertions.timing.EventuallyConfig import io.kotest.assertions.timing.eventually import io.kotest.assertions.until.fibonacci import io.kotest.assertions.until.fixed @@ -25,17 +25,6 @@ class EventuallyTest : WordSpec() { init { "eventually" should { - "eventually configuration can be shared" { - val slow = Eventually(duration = 5.seconds) - val fast = slow.copy(retries = 1) - - assertSoftly { - slow.retries shouldBe Int.MAX_VALUE - fast.retries shouldBe 1 - slow.duration shouldBe 5.seconds - fast.duration shouldBe 5.seconds - } - } "pass working tests" { eventually(5.days) { System.currentTimeMillis() @@ -206,6 +195,29 @@ class EventuallyTest : WordSpec() { latch.await(10, TimeUnit.SECONDS) shouldBe true result shouldBe "xxxxxx" } + + "eventually has a shareable configuration" { + val slow = EventuallyConfig(duration = 5.seconds) + val fast = slow.copy(retries = 1) + + assertSoftly { + slow.retries shouldBe Int.MAX_VALUE + fast.retries shouldBe 1 + slow.duration shouldBe 5.seconds + fast.duration shouldBe 5.seconds + } + + eventually(slow) { + 5 + } + + var i = 0 + eventually(fast, predicate = { i == 1 }) { + i++ + } + + i shouldBe 1 + } } } } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt deleted file mode 100644 index c59016ed9f8..00000000000 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ /dev/null @@ -1,124 +0,0 @@ -package io.kotest.assertions.timing - -import io.kotest.assertions.SuspendingPredicate -import io.kotest.assertions.SuspendingProducer -import io.kotest.assertions.failure -import io.kotest.assertions.until.Interval -import io.kotest.assertions.until.fixed -import kotlinx.coroutines.delay -import kotlin.reflect.KClass -import kotlin.time.* - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, f: SuspendingProducer): T = - Eventually(duration = duration, exceptionClass = Throwable::class).invoke(f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = - Eventually(duration = duration, exceptionClass = Throwable::class).invoke(predicate, f) - -@OptIn(ExperimentalTime::class) -@Deprecated(""" -Use eventually with an interval, using Duration based poll is deprecated. -To convert an existing duration to an interval you can Duration.fixed(), Duration.exponential(), or Duration.fibonacci(). -""", - ReplaceWith( - "eventually(duration, interval = poll.fixed(), f = f)", - "io.kotest.assertions.until.fixed" - )) -suspend fun eventually(duration: Duration, poll: Duration, f: SuspendingProducer): T = - Eventually(duration = duration, interval = poll.fixed(), exceptionClass = Throwable::class).invoke(f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, exceptionClass: KClass, f: SuspendingProducer): T = - Eventually(duration = duration, exceptionClass = exceptionClass).invoke(f = f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually( - duration: Duration = Duration.INFINITE, - interval: Interval = 25.milliseconds.fixed(), - listener: EventuallyListener = EventuallyListener.noop, - retries: Int = Int.MAX_VALUE, - exceptionClass: KClass? = Throwable::class, - predicate: SuspendingPredicate = { true }, - f: SuspendingProducer -): T = Eventually(duration, interval, listener, retries, exceptionClass).invoke(predicate, f) - -@OptIn(ExperimentalTime::class) -data class Eventually ( - val duration: Duration = Duration.INFINITE, - val interval: Interval = 25.milliseconds.fixed(), - val listener: EventuallyListener = EventuallyListener.noop, - val retries: Int = Int.MAX_VALUE, - val exceptionClass: KClass? = null, -) { - init { - require(retries > 0) { "Retries should not be less than one" } - } - - fun withInterval(interval: Interval) = this.copy(interval = interval) - fun withListener(listener: EventuallyListener) = this.copy(listener = listener) - fun withRetries(retries: Int) = this.copy(retries = retries) - - suspend operator fun invoke( - predicate: SuspendingPredicate = { true }, - f: SuspendingProducer, - ): T { - val start = TimeSource.Monotonic.markNow() - val end = start.plus(duration) - var times = 0 - var firstError: Throwable? = null - var lastError: Throwable? = null - - while (end.hasNotPassedNow() && times < retries) { - try { - val result = f() - listener.onEval(result, EventuallyState(start, end, times, firstError, lastError)) - if (predicate(result)) { - return result - } - } catch (e: Throwable) { - if (AssertionError::class.isInstance(e) || exceptionClass?.isInstance(e) == true) { - if (firstError == null) { - firstError = e - } else { - lastError = e - } - } else { - throw e - } - } - times++ - delay(interval.next(times)) - } - - val message = StringBuilder().apply { - appendLine("Eventually block failed after ${duration}; attempted $times time(s); $interval delay between attempts") - - if (firstError != null) { - appendLine("The first error was caused by: ${firstError.message}") - appendLine(firstError.stackTraceToString()) - } - - if (lastError != null) { - appendLine("The last error was caused by: ${lastError.message}") - appendLine(lastError.stackTraceToString()) - } - } - - throw failure(message.toString()) - } -} - -@OptIn(ExperimentalTime::class) -data class EventuallyState ( - val start: TimeMark, val end: TimeMark, val times: Int, val firstError: Throwable?, val lastError: Throwable?, -) - -fun interface EventuallyListener { - fun onEval(t: T, state: EventuallyState) - - companion object { - val noop = EventuallyListener { _, _ -> } - } -} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/eventually.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/eventually.kt new file mode 100644 index 00000000000..e37733ec8ed --- /dev/null +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/eventually.kt @@ -0,0 +1,145 @@ +package io.kotest.assertions.timing + +import io.kotest.assertions.SuspendingPredicate +import io.kotest.assertions.SuspendingProducer +import io.kotest.assertions.failure +import io.kotest.assertions.until.Interval +import io.kotest.assertions.until.fixed +import kotlinx.coroutines.delay +import kotlin.reflect.KClass +import kotlin.time.* + +/** + * Runs a function until it doesn't throw as long as the specified duration hasn't passed + */ +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, f: SuspendingProducer): T = + eventually(EventuallyConfig(duration = duration, exceptionClass = Throwable::class), f = f) + +/** + * Runs a function until it doesn't throw and the result satisfies the predicate as long as the specified duration hasn't passed + */ +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, predicate: SuspendingPredicate, f: SuspendingProducer): T = + eventually(EventuallyConfig(duration = duration, exceptionClass = Throwable::class), predicate, f) + +@OptIn(ExperimentalTime::class) +@Deprecated(""" +Use eventually with an interval, using Duration based poll is deprecated. +To convert an existing duration to an interval you can Duration.fixed(), Duration.exponential(), or Duration.fibonacci(). +""", + ReplaceWith( + "eventually(duration, interval = poll.fixed(), f = f)", + "io.kotest.assertions.until.fixed" + )) +suspend fun eventually(duration: Duration, poll: Duration, f: SuspendingProducer): T = + eventually(EventuallyConfig(duration = duration, interval = poll.fixed(), exceptionClass = Throwable::class), f = f) + +/** + * Runs a function until it doesn't throw the specified exception as long as the specified duration hasn't passed + */ +@OptIn(ExperimentalTime::class) +suspend fun eventually(duration: Duration, exceptionClass: KClass, f: SuspendingProducer): T = + eventually(EventuallyConfig(duration = duration, exceptionClass = exceptionClass), f = f) + +/** + * Runs a function until the following constraints are eventually met: + * the optional [predicate] must be satisfied, defaults to true + * the optional [duration] has not passed now, defaults to [Duration.INFINITE] + * the number of iterations does not exceed the optional [retries], defaults to [Int.MAX_VALUE] + * + * [eventually] will catch the specified optional [exceptionClass] and (or when not specified) [AssertionError], defaults to [Throwable] + * [eventually] will delay the specified [interval] between iterations, defaults to 25 [milliseconds] + * [eventually] will pass the resulting value and state (see [EventuallyState]) into the optional [listener] + */ +@OptIn(ExperimentalTime::class) +suspend fun eventually( + duration: Duration = Duration.INFINITE, + interval: Interval = 25.milliseconds.fixed(), + listener: EventuallyListener = EventuallyListener.noop, + retries: Int = Int.MAX_VALUE, + exceptionClass: KClass? = Throwable::class, + predicate: SuspendingPredicate = { true }, + f: SuspendingProducer +): T = eventually(EventuallyConfig(duration, interval, listener, retries, exceptionClass), predicate, f) + +/** + * Runs a function until it doesn't throw and the result satisfies the predicate as long as the specified duration hasn't passed + * and uses [EventuallyConfig] to control the duration, interval, listener, retries, and exceptionClass. + */ +@OptIn(ExperimentalTime::class) +suspend fun eventually( + config: EventuallyConfig, + predicate: SuspendingPredicate = { true }, + f: SuspendingProducer, +): T { + val start = TimeSource.Monotonic.markNow() + val end = start.plus(config.duration) + var times = 0 + var firstError: Throwable? = null + var lastError: Throwable? = null + + while (end.hasNotPassedNow() && times < config.retries) { + try { + val result = f() + config.listener.onEval(result, EventuallyState(start, end, times, firstError, lastError)) + if (predicate(result)) { + return result + } + } catch (e: Throwable) { + if (AssertionError::class.isInstance(e) || config.exceptionClass?.isInstance(e) == true) { + if (firstError == null) { + firstError = e + } else { + lastError = e + } + } else { + throw e + } + } + times++ + delay(config.interval.next(times)) + } + + val message = StringBuilder().apply { + appendLine("Eventually block failed after ${config.duration}; attempted $times time(s); ${config.interval} delay between attempts") + + if (firstError != null) { + appendLine("The first error was caused by: ${firstError.message}") + appendLine(firstError.stackTraceToString()) + } + + if (lastError != null) { + appendLine("The last error was caused by: ${lastError.message}") + appendLine(lastError.stackTraceToString()) + } + } + + throw failure(message.toString()) +} + +@OptIn(ExperimentalTime::class) +data class EventuallyConfig ( + val duration: Duration = Duration.INFINITE, + val interval: Interval = 25.milliseconds.fixed(), + val listener: EventuallyListener = EventuallyListener.noop, + val retries: Int = Int.MAX_VALUE, + val exceptionClass: KClass? = null, +) { + init { + require(retries > 0) { "Retries should not be less than one" } + } +} + +@OptIn(ExperimentalTime::class) +data class EventuallyState ( + val start: TimeMark, val end: TimeMark, val times: Int, val firstError: Throwable?, val lastError: Throwable?, +) + +fun interface EventuallyListener { + fun onEval(t: T, state: EventuallyState) + + companion object { + val noop = EventuallyListener { _, _ -> } + } +} From b43bc2d55ca3dd99f44db80ee348c64fccc269bf Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Fri, 29 Jan 2021 22:21:22 -0800 Subject: [PATCH 7/8] remove list in compiler args --- build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1f7ba9687ba..8626811356b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,7 +82,8 @@ kotlin { tasks.withType().configureEach { kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlin.time.ExperimentalTime") + freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.time.ExperimentalTime" jvmTarget = "1.8" apiVersion = "1.4" } From 8f9307b9bbec8900b5257c6941baf259f7ef39e1 Mon Sep 17 00:00:00 2001 From: Jim Schneidereit Date: Fri, 29 Jan 2021 22:28:55 -0800 Subject: [PATCH 8/8] add test for eventually retry limit --- .../kotest/assertions/timing/EventuallyTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt index 2985474a3fd..ba9c2977e83 100644 --- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/timing/EventuallyTest.kt @@ -218,6 +218,17 @@ class EventuallyTest : WordSpec() { i shouldBe 1 } + + "throws if retry limit is exceeded" { + val message = shouldThrow { + eventually(EventuallyConfig(retries = 2)) { + 1 shouldBe 2 + } + }.message + + message.shouldContain("Eventually block failed after Infinity") + message.shouldContain("attempted 2 time(s)") + } } } }