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

KTOR-8043 Add retry to server tests by default #4593

Merged
merged 2 commits into from
Jan 13, 2025
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/*
* Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.config

import io.ktor.server.engine.*
import io.ktor.utils.io.*

internal actual val CONFIG_PATH: List<String>
get() = listOfNotNull(
Expand All @@ -18,6 +17,7 @@ internal actual val CONFIG_PATH: List<String>
public actual val configLoaders: List<ConfigLoader>
get() = _configLoaders

@Suppress("ObjectPropertyName")
private val _configLoaders: MutableList<ConfigLoader> = mutableListOf()

public fun addConfigLoader(loader: ConfigLoader) {
Expand Down
5 changes: 2 additions & 3 deletions ktor-server/ktor-server-test-base/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

description = ""
Expand All @@ -10,7 +10,7 @@ kotlin.sourceSets {
commonMain {
dependencies {
api(project(":ktor-server:ktor-server-test-host"))
api(libs.kotlin.test)
api(project(":ktor-shared:ktor-test-base"))
}
}

Expand All @@ -21,7 +21,6 @@ kotlin.sourceSets {
api(project(":ktor-client:ktor-client-apache"))
api(project(":ktor-network:ktor-network-tls:ktor-network-tls-certificates"))
api(project(":ktor-server:ktor-server-plugins:ktor-server-call-logging"))
api(project(":ktor-shared:ktor-test-base"))

if (jetty_alpn_boot_version != null) {
api(libs.jetty.alpn.boot)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.test.base
Expand All @@ -16,21 +16,15 @@ expect abstract class BaseTest() {
open fun afterTest()

fun collectUnhandledException(error: Throwable) // TODO: better name?
fun runTest(timeout: Duration = 60.seconds, block: suspend CoroutineScope.() -> Unit): TestResult
fun runTest(
timeout: Duration = 60.seconds,
retries: Int = DEFAULT_RETRIES,
block: suspend CoroutineScope.() -> Unit
): TestResult
}

fun BaseTest.runTest(
retry: Int,
timeout: Duration = this.timeout,
block: suspend CoroutineScope.() -> Unit
): TestResult {
lateinit var lastCause: Throwable
repeat(retry) {
try {
return runTest(timeout, block)
} catch (cause: Throwable) {
lastCause = cause
}
}
throw lastCause
}
/**
* Defaults to `1` on all platforms except for JVM.
* On JVM retries are disabled as we use test-retry Gradle plugin instead.
*/
internal expect val DEFAULT_RETRIES: Int
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.test.base

import io.ktor.util.*
import kotlinx.coroutines.test.TestResult
import kotlin.test.Test
import kotlin.test.fail

class BaseTestTest : BaseTest() {

@Test
fun `runTest - retry test by default on non-JVM platform`(): TestResult {
var retryCount = 0
return runTest {
if (!PlatformUtils.IS_JVM && retryCount++ < 1) fail("This test should be retried")
}
}

@Test
fun `runTest - don't retry test by default on JVM platform`(): TestResult {
var retryCount = 0
return runTest {
if (PlatformUtils.IS_JVM && retryCount++ > 0) fail("This test should not be retried")
}
}

@Test
fun `runTest - more than one retry`(): TestResult {
var retryCount = 0
return runTest(retries = 3) {
if (retryCount++ < 3) fail("This test should be retried")
}
}

@Test
fun `runTest - retry should work collected exceptions`(): TestResult {
osipxd marked this conversation as resolved.
Show resolved Hide resolved
var retryCount = 0
return runTest(retries = 1) {
if (retryCount++ < 1) collectUnhandledException(Exception("This test should be retried"))
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.test.base

import io.ktor.test.*
import io.ktor.test.dispatcher.*
import io.ktor.test.junit.*
import io.ktor.test.junit.coroutines.*
Expand Down Expand Up @@ -47,13 +48,20 @@ actual abstract class BaseTest actual constructor() {

actual fun runTest(
timeout: Duration,
retries: Int,
block: suspend CoroutineScope.() -> Unit
): TestResult = runTestWithRealTime(CoroutineName("test-$testName"), timeout) {
beforeTest()
try {
block()
} finally {
afterTest()
): TestResult = retryTest(retries) { retry ->
runTestWithRealTime(CoroutineName("test-$testName"), timeout) {
if (retry > 0) println("[Retry $retry/$retries]")
beforeTest()
try {
block()
} finally {
afterTest()
}
}
}
}

/** On JVM retries are disabled as we use test-retry Gradle plugin instead. */
internal actual const val DEFAULT_RETRIES: Int = 0
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.test.base

import io.ktor.test.*
import io.ktor.test.dispatcher.*
import io.ktor.utils.io.*
import io.ktor.utils.io.locks.*
Expand All @@ -12,15 +13,14 @@ import kotlinx.coroutines.test.TestResult
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@OptIn(InternalAPI::class)
actual abstract class BaseTest actual constructor() {
actual open val timeout: Duration = 10.seconds

private val errors = mutableListOf<Throwable>()

@OptIn(InternalAPI::class)
private val errorsLock = SynchronizedObject()

@OptIn(InternalAPI::class)
actual fun collectUnhandledException(error: Throwable) {
synchronized(errorsLock) {
errors.add(error)
Expand All @@ -31,6 +31,9 @@ actual abstract class BaseTest actual constructor() {
}

actual open fun afterTest() {
val errors = synchronized(errorsLock) { errors.toList() }
this.errors.clear()

if (errors.isEmpty()) return

val error = UnhandledErrorsException(
Expand All @@ -46,15 +49,21 @@ actual abstract class BaseTest actual constructor() {

actual fun runTest(
timeout: Duration,
retries: Int,
block: suspend CoroutineScope.() -> Unit
): TestResult = runTestWithRealTime(timeout = timeout) {
beforeTest()
try {
block()
} finally {
afterTest()
): TestResult = retryTest(retries) { retry ->
runTestWithRealTime(timeout = timeout) {
if (retry > 0) println("[Retry $retry/$retries]")
beforeTest()
try {
block()
} finally {
afterTest()
}
}
}
}

internal actual const val DEFAULT_RETRIES: Int = 1

private class UnhandledErrorsException(override val message: String) : Exception()
21 changes: 19 additions & 2 deletions ktor-shared/ktor-test-base/common/src/io/ktor/test/TestResult.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.test
Expand All @@ -18,4 +18,21 @@ expect inline fun TestResult.andThen(crossinline block: () -> Any): TestResult
internal expect inline fun testWithRecover(noinline recover: (Throwable) -> Unit, test: () -> TestResult): TestResult

internal expect inline fun <T> runTestForEach(items: Iterable<T>, crossinline test: (T) -> TestResult): TestResult
internal expect inline fun retryTest(retries: Int, crossinline test: (Int) -> TestResult): TestResult

/**
* Executes a test function with retry capabilities.
*
* ```
* retryTest(retires = 2) { retry ->
* runTest {
* println("This test passes only on second retry. Current retry is $retry")
* assertEquals(2, retry)
* }
* }
* ```
*
* @param retries The number of retries to attempt after an initial failure. Must be a non-negative integer.
* @param test A test to execute, which accepts the current retry attempt (starting at 0) as an argument.
* @return A [TestResult] representing the outcome of the test after all attempts.
*/
expect inline fun retryTest(retries: Int, crossinline test: (Int) -> TestResult): TestResult
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.test
Expand Down Expand Up @@ -101,18 +101,18 @@ class RunTestWithDataTest {
fun testRetriesHaveIndependentTimeout() = runTestWithData(
singleTestCase,
retries = 1,
timeout = 30.milliseconds,
timeout = 50.milliseconds,
test = { (_, retry) ->
realTimeDelay(20.milliseconds)
realTimeDelay(30.milliseconds)
if (retry == 0) fail("Try again, please")
},
)

@Test
fun testDifferentItemsHaveIndependentTimeout() = runTestWithData(
testCases = 1..2,
timeout = 30.milliseconds,
test = { realTimeDelay(20.milliseconds) },
timeout = 50.milliseconds,
test = { realTimeDelay(30.milliseconds) },
)

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.test
Expand All @@ -14,7 +14,8 @@ internal actual inline fun testWithRecover(
internal actual inline fun <T> runTestForEach(items: Iterable<T>, crossinline test: (T) -> TestResult): TestResult =
items.fold(DummyTestResult) { acc, item -> acc.andThen { test(item) } }

internal actual inline fun retryTest(retries: Int, crossinline test: (Int) -> TestResult): TestResult =
actual inline fun retryTest(retries: Int, crossinline test: (Int) -> TestResult): TestResult =
(1..retries).fold(test(0)) { acc, retry -> acc.catch { test(retry) } }

@PublishedApi
internal expect fun TestResult.catch(action: (Throwable) -> Any): TestResult
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.test.junit
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.test
Expand All @@ -25,7 +25,7 @@ internal actual inline fun <T> runTestForEach(items: Iterable<T>, test: (T) -> T
return DummyTestResult
}

internal actual inline fun retryTest(retries: Int, test: (Int) -> TestResult): TestResult {
actual inline fun retryTest(retries: Int, test: (Int) -> TestResult): TestResult {
lateinit var lastCause: Throwable
repeat(retries + 1) { attempt ->
try {
Expand Down
Loading