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 5 commits
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
8 changes: 8 additions & 0 deletions .editorconfig
Expand Up @@ -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
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
71 changes: 59 additions & 12 deletions documentation/docs/assertions/nondeterministic_testing.md
Expand Up @@ -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.

Expand All @@ -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`).
Expand All @@ -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<Int>(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<APIResult, ExpectedException>(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")})
jschneidereit marked this conversation as resolved.
Show resolved Hide resolved

"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 <a name="continually"></a>

Expand Down Expand Up @@ -112,11 +162,8 @@ class MyTests: ShouldSpec() {
}
```



## Retry <a name="retry"></a>


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.

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,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.Eventually
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
Expand All @@ -12,17 +16,26 @@ 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() {

init {
"eventually" should {
"eventually configuration can be shared" {
val slow = Eventually<Int, Throwable>(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()
Expand Down Expand Up @@ -64,7 +77,7 @@ 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
}
}
Expand Down Expand Up @@ -106,7 +119,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")
}
Expand All @@ -116,9 +129,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 +146,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(), 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<AssertionError> {
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"
}
}
}
}
@@ -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,8 @@
package io.kotest.assertions

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

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