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 all 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
9 changes: 7 additions & 2 deletions build.gradle.kts
Expand Up @@ -81,8 +81,13 @@ kotlin {
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().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
Expand Down
69 changes: 57 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,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<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 = EventuallyConfig<ServerResponse, ServerException>(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 <a name="continually"></a>

Expand Down Expand Up @@ -112,11 +160,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.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
Expand All @@ -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() {
Expand Down Expand Up @@ -64,7 +66,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 +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")
}
Expand All @@ -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)
Expand All @@ -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<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"
}

"eventually has a shareable configuration" {
val slow = EventuallyConfig<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
}

eventually(slow) {
5
}

var i = 0
eventually(fast, predicate = { i == 1 }) {
i++
}

i shouldBe 1
}

"throws if retry limit is exceeded" {
val message = shouldThrow<AssertionError> {
eventually(EventuallyConfig(retries = 2)) {
1 shouldBe 2
}
}.message

message.shouldContain("Eventually block failed after Infinity")
message.shouldContain("attempted 2 time(s)")
}
}
}
}
@@ -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