Skip to content

Commit bdb82b7

Browse files
authored
JUnit rule for flaky test retry (#1680)
1 parent 8042f9c commit bdb82b7

File tree

13 files changed

+394
-46
lines changed

13 files changed

+394
-46
lines changed

build.gradle

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ subprojects {
1212
apply plugin: 'idea'
1313
apply plugin: 'io.franzbecker.gradle-lombok'
1414
apply plugin: 'com.github.johnrengelman.shadow'
15-
apply from: "$rootDir/gradle/publishing.gradle"
16-
apply from: "$rootDir/gradle/bintray.gradle"
1715

1816
group = "org.testcontainers"
1917

@@ -35,9 +33,29 @@ subprojects {
3533
}
3634
}
3735

38-
project.tasks.sourceJar.from(delombok)
36+
repositories {
37+
jcenter()
38+
mavenCentral()
39+
}
40+
41+
// specific modules should be excluded from publication
42+
if ( ! ["test-support", "jdbc-test", "docs-examples"].contains(it.name) ) {
43+
apply from: "$rootDir/gradle/publishing.gradle"
44+
apply from: "$rootDir/gradle/bintray.gradle"
45+
46+
project.tasks.sourceJar.from(delombok)
47+
48+
publishing {
49+
publications {
50+
mavenJava(MavenPublication) { publication ->
51+
artifacts.removeAll { it.classifier == null }
52+
artifact project.tasks.shadowJar
53+
}
54+
}
55+
}
3956

40-
task release(dependsOn: bintrayUpload)
57+
task release(dependsOn: bintrayUpload)
58+
}
4159

4260
test {
4361
defaultCharacterEncoding = "UTF-8"
@@ -49,11 +67,6 @@ subprojects {
4967
}
5068
}
5169

52-
repositories {
53-
jcenter()
54-
mavenCentral()
55-
}
56-
5770
shadowJar {
5871
configurations = []
5972
classifier = null
@@ -87,15 +100,6 @@ subprojects {
87100
}
88101
}
89102

90-
publishing {
91-
publications {
92-
mavenJava(MavenPublication) { publication ->
93-
artifacts.removeAll { it.classifier == null }
94-
artifact project.tasks.shadowJar
95-
}
96-
}
97-
}
98-
99103
dependencies {
100104
testCompile 'ch.qos.logback:logback-classic:1.2.3'
101105
}

core/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ dependencies {
113113
testCompile files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar')
114114

115115
testCompile 'org.assertj:assertj-core:3.12.2'
116-
116+
testCompile project(':test-support')
117117

118118
jarFileTestCompileOnly "org.projectlombok:lombok:${lombok.version}"
119119
jarFileTestCompile 'junit:junit:4.12'

core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.rnorth.ducttape.unreliables.Unreliables;
1515
import org.testcontainers.containers.Container;
1616
import org.testcontainers.containers.GenericContainer;
17+
import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
1718
import org.testcontainers.utility.Base58;
1819
import org.testcontainers.utility.TestEnvironment;
1920

@@ -128,29 +129,6 @@ public static void setupContent() throws FileNotFoundException {
128129
.withExtraHost("somehost", "192.168.1.10")
129130
.withCommand("/bin/sh", "-c", "while true; do cat /etc/hosts | nc -l -p 80; done");
130131

131-
// @Test
132-
// public void simpleRedisTest() {
133-
// String ipAddress = redis.getContainerIpAddress();
134-
// Integer port = redis.getMappedPort(REDIS_PORT);
135-
//
136-
// // Use Redisson to obtain a List that is backed by Redis
137-
// Config redisConfig = new Config();
138-
// redisConfig.useSingleServer().setAddress(ipAddress + ":" + port);
139-
//
140-
// Redisson redisson = Redisson.create(redisConfig);
141-
//
142-
// List<String> testList = redisson.getList("test");
143-
// testList.add("foo");
144-
// testList.add("bar");
145-
// testList.add("baz");
146-
//
147-
// List<String> testList2 = redisson.getList("test");
148-
// assertEquals("The list contains the expected number of items (redis is working!)", 3, testList2.size());
149-
// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("foo"));
150-
// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("bar"));
151-
// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("baz"));
152-
// }
153-
154132
@Test
155133
public void testIsRunning() {
156134
try (GenericContainer container = new GenericContainer().withCommand("top")) {
@@ -404,7 +382,8 @@ public void addExposedPortAfterWithExposedPortsTest() {
404382
@Test
405383
public void sharedMemorySetTest() {
406384
try (GenericContainer containerWithSharedMemory = new GenericContainer()
407-
.withSharedMemorySize(42L * FileUtils.ONE_MB)) {
385+
.withSharedMemorySize(42L * FileUtils.ONE_MB)
386+
.withStartupCheckStrategy(new OneShotStartupCheckStrategy())) {
408387

409388
containerWithSharedMemory.start();
410389

modules/couchbase/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ description = "Testcontainers :: Couchbase"
33
dependencies {
44
compile project(':testcontainers')
55
compile 'com.couchbase.client:java-client:2.7.7'
6+
7+
testCompile project(':test-support')
68
}

modules/couchbase/src/test/java/org/testcontainers/couchbase/BaseCouchbaseContainerTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import com.couchbase.client.java.view.View;
1010
import com.google.common.collect.Lists;
1111
import org.junit.Assert;
12+
import org.junit.Rule;
1213
import org.junit.Test;
14+
import org.testcontainers.testsupport.Flaky;
15+
import org.testcontainers.testsupport.FlakyTestJUnit4RetryRule;
1316

1417
import java.util.List;
1518

@@ -25,7 +28,11 @@ public abstract class BaseCouchbaseContainerTest extends AbstractCouchbaseTest {
2528

2629
private static final String DOCUMENT = "{\"name\":\"toto\"}";
2730

31+
@Rule
32+
public FlakyTestJUnit4RetryRule retry = new FlakyTestJUnit4RetryRule();
33+
2834
@Test
35+
@Flaky(githubIssueUrl = "https://github.com/testcontainers/testcontainers-java/issues/1453", reviewDate = "2019-10-01")
2936
public void shouldInsertDocument() {
3037
RawJsonDocument expected = RawJsonDocument.create(ID, DOCUMENT);
3138
getBucket().upsert(expected);
@@ -34,6 +41,7 @@ public void shouldInsertDocument() {
3441
}
3542

3643
@Test
44+
@Flaky(githubIssueUrl = "https://github.com/testcontainers/testcontainers-java/issues/1453", reviewDate = "2019-10-01")
3745
public void shouldExecuteN1ql() {
3846
getBucket().query(N1qlQuery.simple("INSERT INTO " + TEST_BUCKET + " (KEY, VALUE) VALUES ('" + ID + "', " + DOCUMENT + ")"));
3947

@@ -46,6 +54,7 @@ public void shouldExecuteN1ql() {
4654
}
4755

4856
@Test
57+
@Flaky(githubIssueUrl = "https://github.com/testcontainers/testcontainers-java/issues/1453", reviewDate = "2019-10-01")
4958
public void shouldCreateView() {
5059
View view = DefaultView.create(VIEW_NAME, VIEW_FUNCTION);
5160
DesignDocument document = DesignDocument.create(VIEW_NAME, Lists.newArrayList(view));

modules/jdbc-test/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ dependencies {
2828
testCompile 'commons-dbutils:commons-dbutils:1.6'
2929

3030
testCompile 'com.googlecode.junit-toolbox:junit-toolbox:2.4'
31+
testCompile project(':test-support')
3132
}

modules/jdbc-test/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
package org.testcontainers.jdbc;
22

3+
import org.junit.Rule;
4+
import org.junit.Test;
5+
import org.testcontainers.containers.Container;
6+
import org.testcontainers.containers.JdbcDatabaseContainer;
7+
import org.testcontainers.testsupport.Flaky;
8+
import org.testcontainers.testsupport.FlakyTestJUnit4RetryRule;
9+
310
import java.io.IOException;
411
import java.sql.Connection;
512
import java.sql.DriverManager;
613
import java.sql.SQLException;
7-
import org.junit.Test;
8-
import org.testcontainers.containers.Container;
9-
import org.testcontainers.containers.JdbcDatabaseContainer;
1014

1115
import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals;
1216

1317
public class DatabaseDriverTmpfsTest {
1418

19+
@Rule
20+
public FlakyTestJUnit4RetryRule retry = new FlakyTestJUnit4RetryRule();
21+
1522
@Test
23+
@Flaky(githubIssueUrl = "https://github.com/testcontainers/testcontainers-java/issues/1687", reviewDate = "2019-10-01")
1624
public void tmpfs() throws IOException, InterruptedException, SQLException {
1725
final String jdbcUrl = "jdbc:tc:postgresql:9.6.8://hostname/databasename?TC_TMPFS=/testtmpfs:rw";
1826
try (Connection ignored = DriverManager.getConnection(jdbcUrl)) {

settings.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ file('modules').eachDir { dir ->
4141

4242
include "docs-examples"
4343
project(":docs-examples").projectDir = file("docs/examples")
44+
include 'test-support'
45+

test-support/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dependencies {
2+
implementation 'junit:junit:4.12'
3+
implementation 'org.slf4j:slf4j-api:1.7.26'
4+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package org.testcontainers.testsupport;
2+
3+
import java.lang.annotation.Retention;
4+
import java.lang.annotation.Target;
5+
6+
import static java.lang.annotation.ElementType.METHOD;
7+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
8+
9+
/**
10+
* Annotation for test methods that should be retried in the event of failure. See {@link FlakyTestJUnit4RetryRule} for
11+
* more details.
12+
*/
13+
@Retention(RUNTIME)
14+
@Target({METHOD})
15+
public @interface Flaky {
16+
17+
/**
18+
* @return a URL for a GitHub issue where this flaky test can be discussed, and where actions to resolve it can be
19+
* coordinated.
20+
*/
21+
String githubIssueUrl();
22+
23+
/**
24+
* @return a date at which this should be reviewed, in {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE}
25+
* format (e.g. {@code 2020-12-03}). Now + 3 months is suggested. Once this date has passed, retries will no longer
26+
* be applied.
27+
*/
28+
String reviewDate();
29+
30+
/**
31+
* @return the total number of times to try running this test (default 3)
32+
*/
33+
int maxTries() default 3;
34+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.testcontainers.testsupport;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.junit.rules.TestRule;
5+
import org.junit.runner.Description;
6+
import org.junit.runners.model.MultipleFailureException;
7+
import org.junit.runners.model.Statement;
8+
9+
import java.time.LocalDate;
10+
import java.time.format.DateTimeParseException;
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
/**
15+
* <p>
16+
* JUnit 4 @Rule that implements retry for flaky tests (tests that suffer from sporadic random failures).
17+
* </p>
18+
* <p>
19+
* This rule should be used in conjunction with the @{@link Flaky} annotation. When this Rule is applied to a test
20+
* class, any test method with this annotation will be invoked up to 3 times or until it succeeds.
21+
* </p>
22+
* <p>
23+
* Tests should <em>not</em> be marked @{@link Flaky} for a long period of time. Every usage should be
24+
* accompanied by a GitHub issue URL, and should be subject to review at a suitable point in the (near) future.
25+
* Should the review date pass without the test's instability being fixed, the retry behaviour will cease to have an
26+
* effect and the test will be allowed to sporadically fail again.
27+
* </p>
28+
*/
29+
@Slf4j
30+
public class FlakyTestJUnit4RetryRule implements TestRule {
31+
32+
@Override
33+
public Statement apply(Statement base, Description description) {
34+
35+
final Flaky annotation = description.getAnnotation(Flaky.class);
36+
37+
if (annotation == null) {
38+
// leave the statement as-is
39+
return base;
40+
}
41+
42+
if (annotation.githubIssueUrl().trim().length() == 0) {
43+
throw new IllegalArgumentException("A GitHub issue URL must be set for usages of the @Flaky annotation");
44+
}
45+
46+
final int maxTries = annotation.maxTries();
47+
48+
if (maxTries < 1) {
49+
throw new IllegalArgumentException("@Flaky annotation maxTries must be at least one");
50+
}
51+
52+
final LocalDate reviewDate;
53+
try {
54+
reviewDate = LocalDate.parse(annotation.reviewDate());
55+
} catch (DateTimeParseException e) {
56+
throw new IllegalArgumentException("@Flaky reviewDate could not be parsed. Please provide a date in yyyy-mm-dd format");
57+
}
58+
59+
// the annotation should only have an effect before the review date, to encourage review and resolution
60+
if ( LocalDate.now().isBefore(reviewDate) ) {
61+
return new RetryingStatement(base, description, maxTries);
62+
} else {
63+
return base;
64+
}
65+
}
66+
67+
private static class RetryingStatement extends Statement {
68+
private final Statement base;
69+
private final Description description;
70+
private final int maxTries;
71+
72+
RetryingStatement(Statement base, Description description, int maxTries) {
73+
this.base = base;
74+
this.description = description;
75+
this.maxTries = maxTries;
76+
}
77+
78+
@Override
79+
public void evaluate() {
80+
81+
int attempts = 0;
82+
final List<Throwable> causes = new ArrayList<>();
83+
84+
while (++attempts <= maxTries) {
85+
try {
86+
base.evaluate();
87+
return;
88+
} catch (Throwable throwable) {
89+
log.warn("Retrying @Flaky-annotated test: {}", description.getDisplayName());
90+
causes.add(throwable);
91+
}
92+
}
93+
94+
throw new IllegalStateException(
95+
"@Flaky-annotated test failed despite retries.",
96+
new MultipleFailureException(causes));
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)