Skip to content

Commit

Permalink
Replace until with eventually and make eventually more configurable (#…
Browse files Browse the repository at this point in the history
…2022)

* initial work to replace until with eventually and make eventually more configurable

* add eventually state to listener

* get tests working

* fix imports

* add some docs

* replace invoke with an eventually that accepts config

* remove list in compiler args

* add test for eventually retry limit
  • Loading branch information
jschneidereit committed Jan 30, 2021
1 parent 3ae77ed commit a1c230f
Show file tree
Hide file tree
Showing 17 changed files with 437 additions and 179 deletions.
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



0 comments on commit a1c230f

Please sign in to comment.