Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace until with eventually and make eventually more configurable #2022

Merged
merged 8 commits into from Jan 30, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitattributes
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.bat text=auto eol=crlf
8 changes: 6 additions & 2 deletions build.gradle.kts
Expand Up @@ -81,8 +81,12 @@ kotlin {
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = "1.8"
kotlinOptions.apiVersion = "1.4"
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlin.time.ExperimentalTime")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this doesn't work because of something related to buildSrc 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it not compile or ... ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just list on multiple lines too

freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.time.ExperimentalTime"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't get rid of the warning about RequiresOptin and doesn't auto optin to ExperimentalTime unfortunately

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't get it to work on MPP either

jvmTarget = "1.8"
apiVersion = "1.4"
}

}

val publications: PublicationContainer = (extensions.getByName("publishing") as PublishingExtension).publications
Expand Down
2 changes: 1 addition & 1 deletion kotest-assertions/kotest-assertions-core/build.gradle.kts
Expand Up @@ -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")
}
}
}
Expand Down
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -64,13 +71,16 @@ class EventuallyTest : WordSpec() {
}
"fail tests throw unexpected exception type" {
shouldThrow<NullPointerException> {
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")
Expand Down Expand Up @@ -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)
Expand All @@ -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<String> { 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<AssertionError> {
eventually(1.seconds, { it == 2 }) {
1
}
}
}

"support fibonacci intervals" {
var t = ""
val latch = CountDownLatch(5)
val listener = nondeterministicListener<String> { 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"
}
}
}
}
@@ -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
Expand Down
8 changes: 0 additions & 8 deletions kotest-assertions/kotest-assertions-shared/build.gradle.kts
Expand Up @@ -37,14 +37,6 @@ kotlin {
iosArm32()
}

targets.all {
compilations.all {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
}
}
}

sourceSets {

val commonMain by getting {
Expand Down
@@ -0,0 +1,19 @@
package io.kotest.assertions

typealias SuspendingPredicate<T> = suspend (T) -> Boolean

typealias SuspendingProducer<T> = suspend () -> T

interface NondeterministicListener<in T> {
suspend fun onEval(t: T)

companion object {
val noop = object : NondeterministicListener<Any?> {
override suspend fun onEval(t: Any?) { }
jschneidereit marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

fun <T> nondeterministicListener(f: suspend (T) -> Unit) = object : NondeterministicListener<T> {
override suspend fun onEval(t: T) = f(t)
}
@@ -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
Expand All @@ -9,63 +14,116 @@ import kotlin.time.TimeSource
import kotlin.time.milliseconds

@OptIn(ExperimentalTime::class)
suspend fun <T> eventually(duration: Duration, f: suspend () -> T): T =
eventually(duration, 25.milliseconds, Exception::class, f)
data class Eventually<T, E : Throwable> (
val duration: Duration = Duration.INFINITE,
val interval: Interval = 25.milliseconds.fixed(),
val listener: NondeterministicListener<T> = NondeterministicListener.noop,
val retries: Int = Int.MAX_VALUE,
val exceptionClass: KClass<E>? = null,
) {
init {
require(retries > 0) { "Retries should not be less than one" }
}

fun withInterval(interval: Interval) = this.copy(interval = interval)
fun withListener(listener: NondeterministicListener<T>) = this.copy(listener = listener)
fun withRetries(retries: Int) = this.copy(retries = retries)

suspend operator fun invoke(
predicate: SuspendingPredicate<T> = { true },
f: SuspendingProducer<T>,
): 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 <T> eventually(duration: Duration, poll: Duration, f: suspend () -> T): T =
eventually(duration, poll, Exception::class, f)
suspend fun <T> eventually(
duration: Duration = Duration.INFINITE,
interval: Interval = 25.milliseconds.fixed(),
listener: NondeterministicListener<T> = NondeterministicListener.noop,
retries: Int = Int.MAX_VALUE,
exceptionClass: KClass<Exception> = Exception::class,
predicate: SuspendingPredicate<T> = { true },
f: SuspendingProducer<T>
): T = Eventually(duration, interval, listener, retries, exceptionClass).invoke(predicate, f)

@OptIn(ExperimentalTime::class)
suspend fun <T, E : Throwable> eventually(
duration: Duration,
exceptionClass: KClass<E>,
f: suspend () -> T
): T = eventually(duration, 25.milliseconds, exceptionClass, f)
suspend fun <T> eventually(duration: Duration, interval: Interval, f: SuspendingProducer<T>): T =
eventually(duration, interval, f = f)

@OptIn(ExperimentalTime::class)
suspend fun <T, E : Throwable> eventually(
duration: Duration,
poll: Duration,
exceptionClass: KClass<E>,
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 <T> eventually(duration: Duration, predicate: SuspendingPredicate<T>, f: SuspendingProducer<T>): 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 <T, E : Throwable> eventually(duration: Duration, exceptionClass: KClass<E>, f: SuspendingProducer<T>): 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 <T> eventually(duration: Duration, interval: Interval, predicate: SuspendingPredicate<T>, f: SuspendingProducer<T>): 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 <T, E : Throwable> eventually(duration: Duration, interval: Interval, exceptionClass: KClass<E>, f: SuspendingProducer<T>): T =
eventually(duration, interval, exceptionClass = exceptionClass, f = f)

@OptIn(ExperimentalTime::class)
@Deprecated("Use eventually with an interval, using Duration based poll is deprecated",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we put an example in the deprecatioin message, and add an extension method to turn a duration into an interval if we don't have one already

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, the first argument to ReplaceWith is an expression that describes how to replace the call site of the deprecated functions. But I also updated the message to include the fixed, fib, and exp calls to convert a Duration to an Interval 👍

ReplaceWith("eventually(duration, poll.fixed(), f = f)", "io.kotest.assertions.until.fixed")
)
suspend fun <T> eventually(duration: Duration, poll: Duration, f: SuspendingProducer<T>): 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 <T> eventually(duration: Duration, poll: Duration, predicate: SuspendingPredicate<T>, f: SuspendingProducer<T>): 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 <T, E : Throwable> eventually(duration: Duration, poll: Duration, exceptionClass: KClass<E>, f: suspend () -> T): T =
eventually(duration, poll.fixed(), exceptionClass = exceptionClass, f = f)

throw failure(
sb.toString()
)
}