Repository navigation
KTOR-9796 Add flaky-test quarantine infrastructure and transient-only retry #5838
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
Changes from all commits
ed0efd1
0a15e87
80e026f
9b3cc02
c17a9a6
cb68054
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| /* | ||
| * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
| */ | ||
|
|
||
| package ktorbuild.targets | ||
|
|
||
| import io.ktor.test.constants.FLAKY_MODE_PROPERTY | ||
| import io.ktor.test.constants.FLAKY_NAME_TOKEN | ||
| import io.ktor.test.constants.FlakyTestsMode | ||
| import org.gradle.api.Project | ||
| import org.gradle.api.tasks.testing.AbstractTestTask | ||
| import org.gradle.kotlin.dsl.withType | ||
|
|
||
| /** Matches the token anywhere in the test name, which also covers the `[target]` name suffixes. */ | ||
| private const val FLAKY_TEST_PATTERN = "*$FLAKY_NAME_TOKEN*" | ||
|
|
||
| internal const val FLAKY_TEST_TASK = "flakyTest" | ||
|
|
||
| internal fun Project.flakyTestsMode(): FlakyTestsMode = | ||
| FlakyTestsMode.of(providers.gradleProperty(FLAKY_MODE_PROPERTY).orNull) | ||
|
|
||
| /** Test tasks that select `@Flaky` by annotation and must therefore *not* also get the name filter. */ | ||
| internal val ANNOTATION_DRIVEN_TEST_TASKS = setOf("jvmTest", "stressTest", FLAKY_TEST_TASK) | ||
|
|
||
| /** | ||
| * Applies the [FLAKY_MODE_PROPERTY] mode to every test task that cannot select `@Flaky` by | ||
| * annotation — every target without a JUnit Platform, plus JVM tasks outside | ||
| * [ANNOTATION_DRIVEN_TEST_TASKS]. The name token exists only for those. | ||
| */ | ||
| internal fun Project.configureFlakyTests() { | ||
| val mode = flakyTestsMode() | ||
|
|
||
| tasks.withType<AbstractTestTask>().configureEach { | ||
| if (name !in ANNOTATION_DRIVEN_TEST_TASKS) selectFlakyTests(mode) | ||
| } | ||
| } | ||
|
|
||
| private fun AbstractTestTask.selectFlakyTests(mode: FlakyTestsMode) { | ||
| when (mode) { | ||
| FlakyTestsMode.EXCLUDE -> filter.excludeTestsMatching(FLAKY_TEST_PATTERN) | ||
|
|
||
| FlakyTestsMode.ONLY -> { | ||
| filter.includeTestsMatching(FLAKY_TEST_PATTERN) | ||
| filter.isFailOnNoMatchingTests = false | ||
| } | ||
|
|
||
| FlakyTestsMode.ALL -> Unit | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
|
|
||
| package ktorbuild.targets | ||
|
|
||
| import io.ktor.test.constants.FLAKY_MODE_PROPERTY | ||
| import io.ktor.test.constants.FlakyTestsMode | ||
| import ktorbuild.ProjectTag | ||
| import ktorbuild.addProjectTag | ||
| import ktorbuild.internal.java | ||
|
|
@@ -12,13 +14,16 @@ import ktorbuild.internal.ktorBuild | |
| import ktorbuild.internal.libs | ||
| import org.gradle.api.Project | ||
| import org.gradle.api.provider.Provider | ||
| import org.gradle.api.tasks.TaskProvider | ||
| import org.gradle.api.tasks.testing.Test | ||
| import org.gradle.jvm.tasks.Jar | ||
| import org.gradle.jvm.toolchain.JavaLanguageVersion | ||
| import org.gradle.jvm.toolchain.JavaToolchainService | ||
| import org.gradle.kotlin.dsl.* | ||
| import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest | ||
|
|
||
| private const val TEST_BASE_MODULE = "ktor-test-base" | ||
|
|
||
| internal fun Project.configureJvm() { | ||
| addProjectTag(ProjectTag.Jvm) | ||
|
|
||
|
|
@@ -41,25 +46,60 @@ internal fun Project.configureJvm() { | |
| } | ||
|
|
||
| private fun Project.configureTests() { | ||
| val jvmTest = tasks.named<KotlinJvmTest>("jvmTest") { | ||
| val flakyTestsMode = flakyTestsMode() | ||
|
|
||
| tasks.withType<Test>().matching { it.name in ANNOTATION_DRIVEN_TEST_TASKS }.configureEach { | ||
| maxHeapSize = "2g" | ||
| exclude("**/*StressTest*") | ||
| systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") | ||
| systemProperty(FLAKY_MODE_PROPERTY, flakyTestsMode.propertyValue) | ||
| useJUnitPlatform() | ||
| configureJavaToolchain(java.toolchain.languageVersion, ktorBuild.jvmTestToolchain) | ||
| } | ||
|
|
||
| tasks.register<Test>("stressTest") { | ||
| classpath = files(jvmTest.map { it.classpath }) | ||
| testClassesDirs = files(jvmTest.map { it.testClassesDirs }) | ||
| val jvmTest = tasks.named<KotlinJvmTest>("jvmTest") { | ||
| exclude("**/*StressTest*") | ||
| } | ||
|
|
||
| maxHeapSize = "2g" | ||
| tasks.register<Test>("stressTest") { | ||
| inheritTestFilesFrom(jvmTest) | ||
| jvmArgs("-XX:+HeapDumpOnOutOfMemoryError") | ||
| setForkEvery(1) | ||
| systemProperty("enable.stress.tests", "true") | ||
| include("**/*StressTest*") | ||
| useJUnitPlatform() | ||
| configureJavaToolchain(java.toolchain.languageVersion, ktorBuild.jvmTestToolchain) | ||
| } | ||
|
|
||
| tasks.register<Test>(FLAKY_TEST_TASK) { | ||
| inheritTestFilesFrom(jvmTest) | ||
| inheritExecutionSettingsFrom(jvmTest) | ||
|
|
||
| val classpath = classpath | ||
|
|
||
| onlyIf("FlakyTestCondition is on the test runtime classpath") { | ||
| classpath.any { it.invariantSeparatorsPath.contains(TEST_BASE_MODULE) } | ||
| } | ||
|
|
||
| ignoreFailures = true | ||
| outputs.upToDateWhen { false } | ||
| systemProperty(FLAKY_MODE_PROPERTY, FlakyTestsMode.ONLY.propertyValue) | ||
| exclude("**/*StressTest*") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| /** Copies the test classpath and classes directories of [source] onto this task. */ | ||
| private fun Test.inheritTestFilesFrom(source: TaskProvider<KotlinJvmTest>) { | ||
| val jvmTest = source.get() | ||
| classpath = jvmTest.classpath | ||
| testClassesDirs = jvmTest.testClassesDirs | ||
| } | ||
|
|
||
| /** | ||
| * Copies the execution environment of [source] onto this task: the system properties and JVM | ||
| * arguments a module set on its own `jvmTest`, which decide how that module's tests behave. | ||
| */ | ||
| private fun Test.inheritExecutionSettingsFrom(source: TaskProvider<KotlinJvmTest>) { | ||
| val jvmTest = source.get() | ||
| systemProperties(jvmTest.systemProperties) | ||
| jvmArgs(jvmTest.jvmArgs.orEmpty()) | ||
|
VGoncharova marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private fun Project.configureJarManifest() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| package io.ktor.server.test.base | ||
|
|
||
| import io.ktor.test.* | ||
| import io.ktor.utils.io.InternalAPI | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.test.TestResult | ||
| import kotlin.time.Duration | ||
|
|
@@ -23,3 +24,36 @@ expect abstract class BaseTest() { | |
| block: suspend CoroutineScope.() -> Unit | ||
| ): TestResult | ||
| } | ||
|
|
||
| /** | ||
| * The retry, guard and lifecycle scaffolding shared by the [BaseTest.runTest] actuals. | ||
| * | ||
| * Only the coroutine context differs between targets, so that part stays in the actual and arrives | ||
| * here as [runAttempt] — the platform's `runTestWithRealTime` call. Everything else is identical: | ||
| * an assertion failure thrown by [block] is recorded and ends the retry loop, so a deterministic | ||
| * failure is reported by the attempt that produced it instead of being masked as flakiness. | ||
| * `runTest`'s own timeout error is an `AssertionError` too, but stays retryable because it is thrown | ||
| * around [block] rather than by it, so the guard never sees it. | ||
| */ | ||
| @OptIn(InternalAPI::class) | ||
| internal fun BaseTest.runTestAttempts( | ||
| retries: Int, | ||
| runAttempt: (block: suspend CoroutineScope.() -> Unit) -> TestResult, | ||
| block: suspend CoroutineScope.() -> Unit, | ||
| ): TestResult { | ||
| val guard = DeterministicFailureGuard() | ||
| return retryTest(retries, shouldRetry = { !guard.hasFailure }) { retry -> | ||
| runAttempt { | ||
| if (retry > 0) println("[Retry $retry/$retries]") | ||
| beforeTest() | ||
| try { | ||
| block() | ||
|
Comment on lines
+49
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } catch (cause: Throwable) { | ||
| guard.record(cause) | ||
| throw cause | ||
| } finally { | ||
| afterTest() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| /* | ||
| * Copyright 2014-2026 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 kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
| import kotlin.test.assertFailsWith | ||
| import kotlin.test.fail | ||
|
|
||
| /** | ||
| * Verifies how [BaseTest.runTest] classifies failures for retries. | ||
| * | ||
| * These assertions live in the JVM source set because there `TestResult` is `Unit`, so a failing | ||
| * test propagates its failure synchronously and can be asserted on. On Web the same call returns | ||
| * a `Promise`, which would reject asynchronously instead. | ||
| */ | ||
| class BaseTestRetryJvmTest { | ||
|
|
||
| private class Fixture : BaseTest() | ||
|
|
||
| @Test | ||
| fun `assertion failure is not retried`() { | ||
| var attempts = 0 | ||
|
|
||
| assertFailsWith<AssertionError> { | ||
| Fixture().runTest(retries = 3) { | ||
| attempts++ | ||
| fail("Deterministic failure") | ||
| } | ||
| } | ||
|
|
||
| assertEquals(1, attempts, "An assertion failure is deterministic and must not be retried") | ||
| } | ||
|
|
||
| @Test | ||
| fun `transient failure is retried`() { | ||
| var attempts = 0 | ||
|
|
||
| Fixture().runTest(retries = 3) { | ||
| if (attempts++ < 3) throw IllegalStateException("Transient failure") | ||
| } | ||
|
|
||
| assertEquals(4, attempts, "Transient failures should be retried until they pass") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, what if change this check to
if (this is Test)and configure the property here, while in theelsebranch call theselectFlakyTests? This would remove applying the flaky test mode fromJvmConfig.kt.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried this, but is Test doesn't discriminate correctly here: Android host unit tests are Test tasks too, but JvmConfig only wires use
JUnitPlatform()/autodetection(and therefore FlakyTestCondition) ontoANNOTATION_DRIVEN_TEST_TASKS. Branching on type instead of name would route Android host tests into the "set the property" arm with no condition installed to read it, silently un-quarantining flaky tests there, which is why this ended up name-based.