Skip to content

Commit 6c70fd5

Browse files
authored
Resolve issues with automatic retry and parameterized runners (#102)
1 parent 4a4c448 commit 6c70fd5

File tree

15 files changed

+498
-236
lines changed

15 files changed

+498
-236
lines changed

pom.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<modelVersion>4.0.0</modelVersion>
33
<groupId>com.nordstrom.tools</groupId>
44
<artifactId>junit-foundation</artifactId>
5-
<version>15.0.1-SNAPSHOT</version>
5+
<version>15.1.1-SNAPSHOT</version>
66
<packaging>jar</packaging>
77

88
<name>JUnit Foundation</name>
@@ -166,7 +166,6 @@
166166
<dependency>
167167
<groupId>pl.pragmatists</groupId>
168168
<artifactId>JUnitParams</artifactId>
169-
<scope>test</scope>
170169
</dependency>
171170
<dependency>
172171
<groupId>org.powermock</groupId>

src/main/java/com/nordstrom/automation/junit/ArtifactCollector.java

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package com.nordstrom.automation.junit;
22

3-
import static com.nordstrom.automation.junit.LifecycleHooks.toMapKey;
4-
53
import java.io.IOException;
64
import java.nio.file.Files;
75
import java.nio.file.Path;
@@ -22,14 +20,14 @@
2220
*/
2321
public class ArtifactCollector<T extends ArtifactType> extends AtomIdentity {
2422

25-
private static final ConcurrentHashMap<String, List<ArtifactCollector<? extends ArtifactType>>> WATCHER_MAP;
26-
private static final Function<String, List<ArtifactCollector<? extends ArtifactType>>> NEW_INSTANCE;
23+
private static final ConcurrentHashMap<Integer, List<ArtifactCollector<? extends ArtifactType>>> WATCHER_MAP;
24+
private static final Function<Integer, List<ArtifactCollector<? extends ArtifactType>>> NEW_INSTANCE;
2725

2826
static {
2927
WATCHER_MAP = new ConcurrentHashMap<>();
30-
NEW_INSTANCE = new Function<String, List<ArtifactCollector<? extends ArtifactType>>>() {
28+
NEW_INSTANCE = new Function<Integer, List<ArtifactCollector<? extends ArtifactType>>>() {
3129
@Override
32-
public List<ArtifactCollector<? extends ArtifactType>> apply(String input) {
30+
public List<ArtifactCollector<? extends ArtifactType>> apply(Integer input) {
3331
return new ArrayList<>();
3432
}
3533
};
@@ -50,7 +48,7 @@ public ArtifactCollector(Object instance, T provider) {
5048
public void starting(Description description) {
5149
super.starting(description);
5250
List<ArtifactCollector<? extends ArtifactType>> watcherList =
53-
LifecycleHooks.computeIfAbsent(WATCHER_MAP, toMapKey(description), NEW_INSTANCE);
51+
LifecycleHooks.computeIfAbsent(WATCHER_MAP, description.hashCode(), NEW_INSTANCE);
5452
watcherList.add(this);
5553
}
5654

@@ -191,7 +189,7 @@ public T getArtifactProvider() {
191189
@SuppressWarnings("unchecked")
192190
public static <S extends ArtifactCollector<? extends ArtifactType>> Optional<S>
193191
getWatcher(Description description, Class<S> watcherType) {
194-
List<ArtifactCollector<? extends ArtifactType>> watcherList = WATCHER_MAP.get(toMapKey(description));
192+
List<ArtifactCollector<? extends ArtifactType>> watcherList = WATCHER_MAP.get(description.hashCode());
195193
if (watcherList != null) {
196194
for (ArtifactCollector<? extends ArtifactType> watcher : watcherList) {
197195
if (watcher.getClass() == watcherType) {
@@ -208,7 +206,7 @@ public T getArtifactProvider() {
208206
* @param description JUnit method description
209207
*/
210208
static void releaseWatchersOf(Description description) {
211-
WATCHER_MAP.remove(toMapKey(description));
209+
WATCHER_MAP.remove(description.hashCode());
212210
}
213211

214212
}

src/main/java/com/nordstrom/automation/junit/DescribeChild.java

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
import org.junit.experimental.theories.Theories.TheoryAnchor;
99
import org.junit.experimental.theories.internal.Assignments;
1010
import org.junit.runner.Description;
11+
import org.junit.runners.model.FrameworkMethod;
12+
1113
import net.bytebuddy.implementation.bind.annotation.Argument;
1214
import net.bytebuddy.implementation.bind.annotation.SuperCall;
1315
import net.bytebuddy.implementation.bind.annotation.This;
1416

17+
import junitparams.JUnitParamsRunner;
18+
1519
/**
1620
* This class declares the interceptor for the {@link org.junit.runners.ParentRunner#describeChild
1721
* describeChild} method.
@@ -45,16 +49,20 @@ public static Description intercept(@This final Object runner,
4549
@SuperCall final Callable<?> proxy,
4650
@Argument(0) final Object child) throws Exception {
4751

48-
Description description = (Description) LifecycleHooks.callProxy(proxy);
52+
Description description = LifecycleHooks.callProxy(proxy);
4953

50-
// if [uniqueId] can be overridden and is test
51-
if ((uniqueId != null) && description.isTest()) {
54+
// if running with JUnitParams
55+
if (runner instanceof JUnitParamsRunner) {
56+
// fix description, adding test class and annotations
57+
description = augmentDescription(child, description);
58+
// otherwise, if able to override [uniqueId] of test
59+
} else if ((uniqueId != null) && description.isTest()) {
5260
try {
53-
// get parent of test runner
61+
// get parent of test runner
5462
Object parent = LifecycleHooks.getFieldValue(runner, "this$0");
5563
// if child of TheoryAnchor statement
5664
if (parent instanceof TheoryAnchor) {
57-
// get assignments for this theory permutation
65+
// get assignments for this theory permutation
5866
Assignments assignments = LifecycleHooks.getFieldValue(runner, "val$complete");
5967
// inject permutation ID into description
6068
injectPermutationId(description, assignments);
@@ -72,19 +80,60 @@ public static Description intercept(@This final Object runner,
7280
* @param description description of "theory" method
7381
* @param assignments arguments for this permutation
7482
*/
75-
private static void injectPermutationId(final Description description, final Assignments assignments) {
76-
try {
77-
Object[] args = assignments.getMethodArguments();
78-
Object[] perm = new Object[args.length + 1];
79-
perm[0] = description.getDisplayName();
80-
System.arraycopy(args, 0, perm, 1, args.length);
81-
int permutationId = Arrays.hashCode(perm);
82-
String theoryId = String.format("theory-id: %08X", permutationId);
83-
uniqueId.set(description, theoryId);
84-
} catch (CouldNotGenerateValueException | SecurityException | IllegalArgumentException
85-
| IllegalAccessException eaten) {
86-
// nothing to do here
87-
}
88-
}
83+
private static void injectPermutationId(final Description description, final Assignments assignments) {
84+
try {
85+
Object[] args = assignments.getMethodArguments();
86+
Object[] perm = new Object[args.length + 1];
87+
perm[0] = description.getDisplayName();
88+
System.arraycopy(args, 0, perm, 1, args.length);
89+
int permutationId = Arrays.hashCode(perm);
90+
String theoryId = String.format("theory-id: %08X", permutationId);
91+
uniqueId.set(description, theoryId);
92+
} catch (CouldNotGenerateValueException | SecurityException |
93+
IllegalArgumentException | IllegalAccessException eaten) {
94+
// nothing to do here
95+
}
96+
}
97+
98+
/**
99+
* Make a childless copy of the specified description.
100+
*
101+
* @param description JUnit description
102+
* @return copy of the specified description, including unique ID
103+
*/
104+
static Description makeChildlessCopyOf(final Description description) {
105+
Description descripCopy = description.childlessCopy();
106+
if (uniqueId != null) {
107+
try {
108+
uniqueId.set(descripCopy, uniqueId.get(description));
109+
} catch (IllegalArgumentException | IllegalAccessException eaten) {
110+
// nothing to do here
111+
}
112+
}
113+
return descripCopy;
114+
}
115+
116+
/**
117+
* Augment incomplete description created by JUnitParams runner.
118+
* <p>
119+
* <b>NOTE</b>: The description built by JUnitParams lack the test class and annotations.
120+
*
121+
* @param child child object of the test runner
122+
* @param description JUnit description built by JUnitParams
123+
* @return new augmented description object; if augmentation fails, returns original description
124+
*/
125+
private static Description augmentDescription(final Object child, final Description description) {
126+
if ((child instanceof FrameworkMethod) && (uniqueId != null)) {
127+
Description augmented = Description.createTestDescription(description.getTestClass(),
128+
description.getMethodName(), ((FrameworkMethod) child).getAnnotations());
129+
try {
130+
uniqueId.set(augmented, uniqueId.get(description));
131+
return augmented;
132+
} catch (IllegalArgumentException | IllegalAccessException eaten) {
133+
// nothing to do here
134+
}
135+
}
136+
return description;
137+
}
89138

90139
}

src/main/java/com/nordstrom/automation/junit/GetTestRules.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ public class GetTestRules {
2727
@RuntimeType
2828
public static List<TestRule> intercept(@This final Object runner, @SuperCall final Callable<?> proxy,
2929
@Argument(0) final Object target) throws Exception {
30-
@SuppressWarnings("unchecked")
3130
// get list of test rules for target class runner
32-
List<TestRule> testRules = (List<TestRule>) LifecycleHooks.callProxy(proxy);
31+
List<TestRule> testRules = LifecycleHooks.callProxy(proxy);
3332
// get method associated with this test class instance
3433
FrameworkMethod method = CreateTest.getMethodFor(target);
3534
// apply rule-based global timeout

src/main/java/com/nordstrom/automation/junit/LifecycleHooks.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ public static ClassFileTransformer installTransformer(Instrumentation instrument
173173
final TypeDescription createTest = TypePool.Default.ofSystemLoader().describe("com.nordstrom.automation.junit.CreateTest").resolve();
174174
final TypeDescription getTestRules = TypePool.Default.ofSystemLoader().describe("com.nordstrom.automation.junit.GetTestRules").resolve();
175175
final TypeDescription runWithCompleteAssignment = TypePool.Default.ofSystemLoader().describe("com.nordstrom.automation.junit.RunWithCompleteAssignment").resolve();
176+
final TypeDescription nextCount = TypePool.Default.ofSystemLoader().describe("com.nordstrom.automation.junit.NextCount").resolve();
176177

177178
final TypeDescription runNotifier = TypePool.Default.ofSystemLoader().describe("org.junit.runner.notification.RunNotifier").resolve();
178179
final TypeDescription description = TypePool.Default.ofSystemLoader().describe("org.junit.runner.Description").resolve();
@@ -247,6 +248,15 @@ public Builder<?> transform(Builder<?> builder, TypeDescription type,
247248
.implement(Hooked.class);
248249
}
249250
})
251+
.type(hasSuperType(named("junitparams.internal.ParameterisedTestMethodRunner")))
252+
.transform(new Transformer() {
253+
@Override
254+
public Builder<?> transform(Builder<?> builder, TypeDescription type,
255+
ClassLoader classloader, JavaModule module) {
256+
return builder.method(named("nextCount")).intercept(MethodDelegation.to(nextCount))
257+
.implement(Hooked.class);
258+
}
259+
})
250260
.installOn(instrumentation);
251261
}
252262

@@ -483,9 +493,10 @@ public static <T> T getFieldValue(Object target, String name) throws IllegalAcce
483493
* @return {@code anything} - value returned by the intercepted method
484494
* @throws Exception {@code anything} (exception thrown by the intercepted method)
485495
*/
486-
static Object callProxy(final Callable<?> proxy) throws Exception {
496+
@SuppressWarnings("unchecked")
497+
static <T> T callProxy(final Callable<?> proxy) throws Exception {
487498
try {
488-
return proxy.call();
499+
return (T) proxy.call();
489500
} catch (InvocationTargetException e) {
490501
throw UncheckedThrow.throwUnchecked(e.getCause());
491502
}

src/main/java/com/nordstrom/automation/junit/MethodBlock.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public static Statement intercept(@This final Object runner, @SuperCall final Ca
5959
DepthGauge depthGauge = LifecycleHooks.computeIfAbsent(METHOD_DEPTH.get(), toMapKey(runner), NEW_INSTANCE);
6060
depthGauge.increaseDepth();
6161

62-
Statement statement = (Statement) LifecycleHooks.callProxy(proxy);
62+
Statement statement = LifecycleHooks.callProxy(proxy);
6363

6464
// if at ground level
6565
if (0 == depthGauge.decreaseDepth()) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.nordstrom.automation.junit;
2+
3+
import static com.nordstrom.automation.junit.LifecycleHooks.getFieldValue;
4+
import static com.nordstrom.automation.junit.LifecycleHooks.invoke;
5+
6+
import java.util.concurrent.Callable;
7+
8+
import junitparams.internal.ParameterisedTestMethodRunner;
9+
import junitparams.internal.TestMethod;
10+
import net.bytebuddy.implementation.bind.annotation.SuperCall;
11+
import net.bytebuddy.implementation.bind.annotation.This;
12+
13+
public class NextCount {
14+
15+
/**
16+
* Interceptor for the {@link junitparams.internal.ParameterisedTestMethodRunner#nextCount nextCount} method.
17+
* <p>
18+
* This interceptor is needed by the automatic retry feature to enable acquisition of a fresh "atomic test"
19+
* statement for each post-failure re-execution. By default, the JUnitParams implementation of the {@link
20+
* org.junit.runners.BlockJUnit4ClassRunner#methodBlock methodBlock} method increments its parameter set
21+
* index each time it's invoked, which always selects the next set of parameters (or exceeds the bounds of
22+
* the array). The handling provided by this interceptor returns the prior index if the target test method
23+
* if being retried so that the correct set of parameters is selected.
24+
*
25+
* @param runner current {@link junitparams.internal.ParameterisedTestMethodRunner ParameterisedTestMethodRunner}
26+
* @param proxy callable proxy for the intercepted method
27+
* @return if retrying the target test method, return prior parameter set index; otherwise, return next index
28+
* @throws Exception {@code anything} (exception thrown by the intercepted method)
29+
*/
30+
public static int intercept(
31+
@This final ParameterisedTestMethodRunner runner, @SuperCall final Callable<?> proxy) throws Exception {
32+
33+
// get reference to JUnitParams target test method
34+
TestMethod method = getFieldValue(runner, "method");
35+
// if this method is being retried
36+
if (RetryHandler.doRetryFor(method.frameworkMethod())) {
37+
// get current parameter set index
38+
int nextCount = invoke(runner, "count");
39+
// return prior index
40+
return nextCount - 1;
41+
// otherwise
42+
} else {
43+
// invoke default implementation
44+
return LifecycleHooks.callProxy(proxy);
45+
}
46+
}
47+
}

src/main/java/com/nordstrom/automation/junit/RetriedTest.java

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.lang.reflect.Field;
55
import org.junit.Ignore;
66
import org.junit.Test;
7+
import org.junit.experimental.theories.Theory;
78
import org.junit.runner.Description;
89

910
/**
@@ -49,11 +50,12 @@ public Throwable getThrown() {
4950
}
5051

5152
/**
52-
* Create a {@link Test &#64;Test} annotation proxy for the specified test description.
53+
* Create a {@link Test &#64;Test} or {@link Theory &#64;Theory} annotation proxy for the specified test
54+
* description.
5355
*
54-
* @param description test description to which {@code @Test} annotation proxy will be attached
56+
* @param description test description to which {@code @Test} or {@code @Theory} annotation proxy will be attached
5557
* @param thrown exception for this failed test
56-
* @return new Description object for retry attempt
58+
* @return new {@link Description} object for retry attempt
5759
*/
5860
public static Description proxyFor(Description description, Throwable thrown) {
5961
try {
@@ -62,12 +64,16 @@ public static Description proxyFor(Description description, Throwable thrown) {
6264
try {
6365
Annotation[] annotations = (Annotation[]) field.get(description);
6466
for (int i = 0; i < annotations.length; i++) {
67+
Annotation proxy = null;
6568
Annotation annotation = annotations[i];
6669
if (annotation instanceof Test) {
67-
Annotation[] originalAnnotations = annotations.clone();
68-
annotations[i] = new RetriedTest((Test) annotation, thrown);
69-
return Description.createTestDescription(
70-
description.getTestClass(), description.getMethodName(), originalAnnotations);
70+
proxy = new RetriedTest((Test) annotation, thrown);
71+
} else if (annotation instanceof Theory) {
72+
proxy = new RetriedTheory((Theory) annotation, thrown);
73+
}
74+
if (proxy != null) {
75+
annotations[i] = proxy;
76+
return DescribeChild.makeChildlessCopyOf(description);
7177
}
7278
}
7379
} catch (IllegalArgumentException | IllegalAccessException e) {
@@ -77,6 +83,17 @@ public static Description proxyFor(Description description, Throwable thrown) {
7783
throw new UnsupportedOperationException("Failed acquiring [" + ANNOTATIONS
7884
+ "] field of test method class", e);
7985
}
80-
throw new IllegalArgumentException("Specified method is not a JUnit @Test: " + description);
86+
throw new IllegalArgumentException("Specified method is not a JUnit @Test or @Theory: " + description);
87+
}
88+
89+
/**
90+
* Determine if the specified description is for a retried test or theory.
91+
*
92+
* @param description JUnit description
93+
* @return {@code true} if the specified description indicates a retried test; otherwise {@code false}
94+
*/
95+
public static boolean isRetriedTest(Description description) {
96+
return ((null != description.getAnnotation(RetriedTest.class)) ||
97+
(null != description.getAnnotation(RetriedTheory.class)));
8198
}
8299
}

0 commit comments

Comments
 (0)