diff --git a/.editorconfig b/.editorconfig index 6df5107d916..aa71438c994 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,11 @@ max_line_length = 120 trim_trailing_whitespace = true insert_final_newline = true + +end_of_line = lf + +[*.bat] +end_of_line = crlf + +[*.md] +indent_size = 2 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..8626811356b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,8 +81,13 @@ kotlin { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = "1.8" - kotlinOptions.apiVersion = "1.4" + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.time.ExperimentalTime" + jvmTarget = "1.8" + apiVersion = "1.4" + } + } val publications: PublicationContainer = (extensions.getByName("publishing") as PublishingExtension).publications diff --git a/documentation/docs/assertions/nondeterministic_testing.md b/documentation/docs/assertions/nondeterministic_testing.md index 3a6712150fb..2837b88325b 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,59 @@ 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 = EventuallyConfig(5.minutes, interval = 25.milliseconds.fibonacci(), exceptionClass = ServerException::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" { + 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() + } + } +}) + +``` + +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. ## Continually @@ -112,11 +160,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/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..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 @@ -1,8 +1,12 @@ 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.EventuallyConfig import io.kotest.assertions.timing.eventually +import io.kotest.assertions.until.fibonacci +import io.kotest.assertions.until.fixed import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual @@ -12,11 +16,9 @@ import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.delay import java.io.FileNotFoundException import java.io.IOException -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource -import kotlin.time.days -import kotlin.time.milliseconds -import kotlin.time.seconds +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.time.* @OptIn(ExperimentalTime::class) class EventuallyTest : WordSpec() { @@ -64,7 +66,7 @@ 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 } } @@ -106,7 +108,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") } @@ -116,9 +118,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 +135,100 @@ 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(), predicate = { t == "xxxxxxxxxxx" }) { + t += "x" + t + } + result shouldBe "xxxxxxxxxxx" + } + + "eventually with T predicate, interval, and listener" { + var t = "" + val latch = CountDownLatch(5) + val result = eventually(5.seconds, 250.milliseconds.fixed(), + listener = { _, _ -> latch.countDown() }, 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, predicate = { it == 2 }) { + 1 + } + } + } + + "support fibonacci intervals" { + var t = "" + val latch = CountDownLatch(5) + val result = eventually(10.seconds, 200.milliseconds.fibonacci(), + listener = { _, _ -> latch.countDown() }, predicate = { t == "xxxxxx" }) { + t += "x" + t + } + 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 + } + + "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)") + } } } } 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..d6692ca774d --- /dev/null +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/NondeterministicHelpers.kt @@ -0,0 +1,8 @@ +package io.kotest.assertions + +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 deleted file mode 100644 index a50a5874a0f..00000000000 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/timing/Eventually.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.kotest.assertions.timing - -import io.kotest.assertions.failure -import kotlinx.coroutines.delay -import kotlin.reflect.KClass -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -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) - -@OptIn(ExperimentalTime::class) -suspend fun eventually(duration: Duration, poll: Duration, f: suspend () -> T): T = - eventually(duration, poll, Exception::class, f) - -@OptIn(ExperimentalTime::class) -suspend fun eventually( - duration: Duration, - exceptionClass: KClass, - f: suspend () -> T -): T = eventually(duration, 25.milliseconds, exceptionClass, 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()) - } - - val sb = StringBuilder() - sb.appendLine("Eventually block failed after ${duration}; attempted $times time(s); $poll delay between attempts") - sb.appendLine() - - if (firstError != null) { - sb.appendLine("The first error was caused by: ${firstError.message}") - sb.appendLine(firstError.stackTraceToString()) - } - - if (lastError != null) { - sb.appendLine("The last error was caused by: ${lastError.message}") - sb.appendLine(lastError.stackTraceToString()) - } - - 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..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,37 +1,62 @@ package io.kotest.assertions.timing +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) -suspend fun continually(duration: Duration, f: suspend () -> T): T? = continually(duration, 10.milliseconds, f) +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) -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: ContinuallyListener = ContinuallyListener.noop, + ) { + suspend operator fun invoke(f: SuspendingProducer): T? { + 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, 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 ${start.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/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 { _, _ -> } + } +} 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 191efa97e1f..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 @@ -1,12 +1,13 @@ 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.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 +18,64 @@ 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 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) -suspend fun until( - duration: Duration, - predicate: (T) -> Boolean, - f: () -> T -): T = until(duration, 1.seconds.fixed(), predicate, UntilListener.noop, f) +@Deprecated( + "Use eventually or Eventually.invoke", + 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) +@Deprecated( + "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: (T) -> Boolean, - f: () -> T -): T = until(duration, interval, predicate = predicate, listener = UntilListener.noop, f = f) + 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) +@Deprecated( + "Use eventually", ReplaceWith( + "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: (T) -> Boolean, + predicate: SuspendingPredicate, 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") -} + f: SuspendingProducer +): T = + eventually(duration, interval, listener = { it, _ -> listener.onEval(it) }, predicate = predicate, f = f)