Skip to content

Commit b191f72

Browse files
committed
Allow conditional flaky tests in JUnit
1 parent a635cb7 commit b191f72

11 files changed

Lines changed: 208 additions & 52 deletions

File tree

‎buildSrc/src/main/kotlin/dd-trace-java.configure-tests.gradle.kts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,12 @@ tasks.named("check") {
103103
}
104104

105105
tasks.withType<Test>().configureEach {
106-
// Flaky tests management for JUnit 5
107-
(options as? JUnitPlatformOptions)?.apply {
108-
if (skipFlakyTestsProvider.isPresent) {
109-
excludeTags("flaky")
110-
} else if (runFlakyTestsProvider.isPresent) {
111-
includeTags("flaky")
112-
}
106+
// Keep suites without test-utils out of flaky-only runs. Runtime extensions refine this tag.
107+
if (!skipFlakyTestsProvider.isPresent && runFlakyTestsProvider.isPresent) {
108+
(options as? JUnitPlatformOptions)?.includeTags("flaky")
113109
}
114110

115-
// Set system property flag that is checked from tests to determine if they should be skipped or run
111+
// Let the JUnit and Spock extensions evaluate @Flaky conditions before selecting tests.
116112
if (skipFlakyTestsProvider.isPresent) {
117113
jvmArgs("-Drun.flaky.tests=false")
118114
} else if (runFlakyTestsProvider.isPresent) {

‎dd-smoke-tests/sample-trace/src/test/groovy/datadog/smoketest/SampleTraceSmokeTest.groovy‎

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package datadog.smoketest;
2+
3+
import static datadog.smoketest.backend.AgentBackend.testAgent;
4+
import static java.util.concurrent.TimeUnit.SECONDS;
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
7+
import datadog.environment.JavaVirtualMachine;
8+
import datadog.smoketest.backend.AgentBackend;
9+
import datadog.trace.test.util.Flaky;
10+
import java.io.File;
11+
import java.util.function.Predicate;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.extension.RegisterExtension;
15+
16+
class SampleTraceSmokeTest {
17+
private static final AgentBackend BACKEND = testAgent();
18+
19+
@RegisterExtension
20+
static final SmokeCliApp app =
21+
SmokeCliApp.named("sample-trace")
22+
.jar(System.getProperty("datadog.smoketest.agent.shadowJar.path"))
23+
// tests tracer as a jar sending sample traces instead of a javaagent
24+
.noAgent()
25+
.backend(BACKEND)
26+
.placeholder("agent.host", () -> BACKEND.url().getHost())
27+
.placeholder("agent.port", () -> Integer.toString(BACKEND.port()))
28+
.jvmArgs(
29+
"-Ddd.agent.host=${agent.host}",
30+
"-Ddd.trace.agent.port=${agent.port}",
31+
"-Ddd.test.agent.session.token=" + BACKEND.sessionToken())
32+
.args("sampleTrace", "-c", "10", "-i", "0.1")
33+
.workingDirectory(new File(System.getProperty("datadog.smoketest.builddir")))
34+
.build();
35+
36+
@Test
37+
@DisplayName("sample traces are sent")
38+
@Flaky(condition = IbmJvm.class)
39+
void sampleTracesAreSent() {
40+
app.traces().waitForTraceCount(10);
41+
app.assertCompletesWithValue(30, SECONDS, 0);
42+
assertEquals(10, app.traces().getTraces().size());
43+
}
44+
45+
static class IbmJvm implements Predicate<String> {
46+
@Override
47+
public boolean test(String suite) {
48+
return JavaVirtualMachine.isIbm();
49+
}
50+
}
51+
}

‎docs/how_to_test.md‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,33 @@ This mechanism exists to make sure either java agent state or static data are re
4141
4242
### Flaky Tests
4343

44-
If a test runs unreliably, or doesn't have a fully deterministic behavior, this will lead to recurrent unexpected errors in continuous integration.
45-
In order to identify such tests and avoid the continuous integration to fail, they are marked as _flaky_ and must be annotated with the `@Flaky` annotation.
44+
Mark unreliable test methods or classes with `@Flaky` in both JUnit and Spock.
45+
46+
All tests run by default. Use `-PskipFlakyTests` to skip flaky tests or `-PrunFlakyTests` to run only flaky tests.
47+
48+
If a test is flaky only in certain environments, use `condition`. In Java, supply a predicate class
49+
with a no-argument constructor. Its `test` method returns `true` when the test is flaky:
50+
51+
```java
52+
import datadog.environment.JavaVirtualMachine;
53+
import java.util.function.Predicate;
54+
55+
@Test
56+
@Flaky(condition = IbmJvm.class)
57+
void testOnSupportedJvms() {
58+
// ...
59+
}
60+
61+
static class IbmJvm implements Predicate<String> {
62+
@Override
63+
public boolean test(String suite) {
64+
return JavaVirtualMachine.isIbm();
65+
}
66+
}
67+
```
68+
69+
Use `suites = {"SomeSubclass"}` to limit the annotation to particular test classes, such as subclasses
70+
that inherit a test. When both `suites` and `condition` are specified, both must match.
4671

4772
> [!TIP]
4873
> In case your pull request checks failed due to some unexpected flaky tests, you can retry the continuous

‎utils/test-utils/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies {
3737

3838
compileOnly(project(":components:annotations"))
3939
compileOnly(libs.junit.jupiter)
40+
compileOnly(libs.junit.platform.launcher)
4041
compileOnly(libs.logback.core)
4142
compileOnly(libs.logback.classic)
4243

‎utils/test-utils/gradle.lockfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ org.junit.jupiter:junit-jupiter-params:5.14.1=compileClasspath,testCompileClassp
5454
org.junit.jupiter:junit-jupiter:5.14.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
5555
org.junit.platform:junit-platform-commons:1.14.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
5656
org.junit.platform:junit-platform-engine:1.14.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
57-
org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath
57+
org.junit.platform:junit-platform-launcher:1.14.1=compileClasspath,testRuntimeClasspath
5858
org.junit:junit-bom:5.14.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
5959
org.mockito:mockito-core:4.4.0=testRuntimeClasspath
6060
org.objenesis:objenesis:3.3=compileClasspath,testCompileClasspath,testRuntimeClasspath

‎utils/test-utils/src/main/groovy/datadog/trace/test/util/Flaky.java‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.lang.annotation.Target;
77
import java.util.function.Predicate;
88
import org.junit.jupiter.api.Tag;
9+
import org.junit.jupiter.api.extension.ExtendWith;
910

1011
/**
1112
* Use this annotation for suites or test cases that are flaky. When running in CI, these will be
@@ -14,19 +15,21 @@
1415
@Retention(RetentionPolicy.RUNTIME)
1516
@Target({ElementType.TYPE, ElementType.METHOD})
1617
@Tag("flaky")
18+
@ExtendWith(FlakyJUnitExtension.class)
1719
public @interface Flaky {
1820
/** Reason why the test is flaky (optional). */
1921
String value() default "";
2022

2123
/**
22-
* Fully qualified name of the test suite classes where this test is flaky. Only required when the
23-
* test is flaky only when run in a subclass.
24+
* Names of the test suite classes where this test is flaky, typically subclasses that inherit the
25+
* test. Spock uses simple class names; JUnit accepts simple or fully qualified class names.
2426
*/
2527
String[] suites() default {};
2628

2729
/**
28-
* Closure with a predicate to test at runtime if the actual spec is flaky (e.g. check the JVM
29-
* vendor), the parameter is the actual name of the spec under test
30+
* Predicate class with a no-argument constructor that determines whether the test is flaky (e.g.
31+
* check the JVM vendor). JUnit passes the concrete test class's simple name. Spock also supports
32+
* Groovy closures.
3033
*/
3134
Class<? extends Predicate<String>> condition() default True.class;
3235

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package datadog.trace.test.util;
2+
3+
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
4+
5+
import java.lang.reflect.AnnotatedElement;
6+
import java.lang.reflect.Constructor;
7+
import java.lang.reflect.Method;
8+
import java.util.function.Predicate;
9+
import org.junit.jupiter.api.Nested;
10+
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
11+
import org.junit.jupiter.api.extension.ExecutionCondition;
12+
import org.junit.jupiter.api.extension.ExtensionConfigurationException;
13+
import org.junit.jupiter.api.extension.ExtensionContext;
14+
15+
/** Selects JUnit tests using the same flaky-test modes as {@link FlakySpockExtension}. */
16+
public final class FlakyJUnitExtension implements ExecutionCondition {
17+
private static final String RUN_FLAKY_TESTS = "run.flaky.tests";
18+
19+
@Override
20+
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
21+
if (!"false".equals(System.getProperty(RUN_FLAKY_TESTS))
22+
|| !context.getTestClass().isPresent()) {
23+
return ConditionEvaluationResult.enabled("Flaky tests are not skipped");
24+
}
25+
Flaky flaky = findFlaky(context.getRequiredTestClass(), context.getTestMethod().orElse(null));
26+
if (flaky == null) {
27+
return ConditionEvaluationResult.enabled("Test is not flaky");
28+
}
29+
return ConditionEvaluationResult.disabled(
30+
flaky.value().isEmpty() ? "Flaky test" : "Flaky test: " + flaky.value());
31+
}
32+
33+
static Flaky findFlaky(Class<?> testClass, Method method) {
34+
Flaky flaky = findFlakyClass(testClass);
35+
return flaky != null || method == null ? flaky : matchingAnnotation(method, testClass);
36+
}
37+
38+
private static Flaky findFlakyClass(Class<?> testClass) {
39+
for (Class<?> current = testClass; current != null; current = current.getSuperclass()) {
40+
Flaky flaky = matchingAnnotation(current, testClass);
41+
if (flaky != null) {
42+
return flaky;
43+
}
44+
}
45+
if (testClass.isAnnotationPresent(Nested.class)) {
46+
return findFlakyClass(testClass.getEnclosingClass());
47+
}
48+
return null;
49+
}
50+
51+
private static Flaky matchingAnnotation(AnnotatedElement element, Class<?> testClass) {
52+
Flaky flaky = findAnnotation(element, Flaky.class).orElse(null);
53+
if (flaky == null) {
54+
return null;
55+
}
56+
if (flaky.suites().length > 0) {
57+
boolean matches = false;
58+
for (String suite : flaky.suites()) {
59+
if (suite.equals(testClass.getSimpleName()) || suite.equals(testClass.getName())) {
60+
matches = true;
61+
break;
62+
}
63+
}
64+
if (!matches) {
65+
return null;
66+
}
67+
}
68+
if (flaky.condition() == Flaky.True.class) {
69+
return flaky;
70+
}
71+
try {
72+
Constructor<? extends Predicate<String>> constructor =
73+
flaky.condition().getDeclaredConstructor();
74+
constructor.setAccessible(true);
75+
return constructor.newInstance().test(testClass.getSimpleName()) ? flaky : null;
76+
} catch (ReflectiveOperationException | RuntimeException e) {
77+
throw new ExtensionConfigurationException(
78+
"Could not evaluate @Flaky condition " + flaky.condition().getName() + " on " + element,
79+
e);
80+
}
81+
}
82+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package datadog.trace.test.util;
2+
3+
import static datadog.trace.test.util.FlakyJUnitExtension.findFlaky;
4+
5+
import org.junit.platform.engine.FilterResult;
6+
import org.junit.platform.engine.TestDescriptor;
7+
import org.junit.platform.engine.TestSource;
8+
import org.junit.platform.engine.support.descriptor.MethodSource;
9+
import org.junit.platform.launcher.PostDiscoveryFilter;
10+
11+
/** Excludes non-flaky JUnit tests from flaky-only runs before execution. */
12+
public final class FlakyJUnitFilter implements PostDiscoveryFilter {
13+
@Override
14+
public FilterResult apply(TestDescriptor descriptor) {
15+
if (!"true".equals(System.getProperty("run.flaky.tests"))
16+
|| !descriptor.getUniqueId().getEngineId().filter("junit-jupiter"::equals).isPresent()) {
17+
return FilterResult.included("Flaky-only filtering does not apply");
18+
}
19+
TestSource source = descriptor.getSource().orElse(null);
20+
if (source instanceof MethodSource) {
21+
MethodSource method = (MethodSource) source;
22+
return findFlaky(method.getJavaClass(), method.getJavaMethod()) != null
23+
? FilterResult.included("Flaky test")
24+
: FilterResult.excluded("Test is not flaky");
25+
}
26+
// Keep containers until their methods have been filtered; JUnit prunes empty containers.
27+
return FilterResult.included("Container may contain flaky tests");
28+
}
29+
}

‎utils/test-utils/src/main/groovy/datadog/trace/test/util/FlakySpockExtension.groovy‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ class FlakySpockExtension extends AbstractGlobalExtension {
4242
}
4343
}
4444
}
45+
46+
if (shouldRunFlakyTestsOnly()) {
47+
// Preserve selected features through the JUnit Platform's flaky tag filter.
48+
spec.getAllFeatures().findAll { !it.excluded }.each { it.addTestTag("flaky") }
49+
}
4550
}
4651

4752
private static void skip(final node) {

0 commit comments

Comments
 (0)