Skip to content

Commit 08fe97d

Browse files
Refactor timeout handling in AndroidJUnit4ClassRunner.
This change replaces the use of JUnit's FailOnTimeout with a custom implementation that ensures @before, @test, and @after methods run on the same thread. The previous FailOnTimeout would execute only the @test method in a separate thread, potentially causing issues with thread-local state. The new implementation wraps the entire test block (including Before/After) in a separate thread when a timeout is active, preserving thread locality within the test lifecycle. PiperOrigin-RevId: 903547390
1 parent 1144878 commit 08fe97d

3 files changed

Lines changed: 133 additions & 17 deletions

File tree

runner/android_junit_runner/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
**Bug Fixes**
88

9+
* Ensure @Before and @Test run on the same thread in AndroidJUnit4ClassRunner.
10+
911
**New Features**
1012

1113
* Make perfetto trace sections for tests more identifiable by prefixing with "test:" and using fully qualified class name. (b/204992764)

runner/android_junit_runner/java/androidx/test/internal/runner/junit4/AndroidJUnit4ClassRunner.java

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,24 @@
1616
package androidx.test.internal.runner.junit4;
1717

1818
import static androidx.test.platform.app.InstrumentationRegistry.getArguments;
19+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
1920

2021
import androidx.test.internal.runner.RunnerArgs;
2122
import androidx.test.internal.runner.junit4.statement.RunAfters;
2223
import androidx.test.internal.runner.junit4.statement.RunBefores;
2324
import androidx.test.internal.runner.junit4.statement.UiThreadStatement;
2425
import androidx.test.internal.util.AndroidRunnerParams;
2526
import java.util.List;
27+
import java.util.concurrent.CountDownLatch;
28+
import java.util.concurrent.atomic.AtomicReference;
2629
import org.junit.After;
2730
import org.junit.Before;
2831
import org.junit.Test;
29-
import org.junit.internal.runners.statements.FailOnTimeout;
3032
import org.junit.runners.BlockJUnit4ClassRunner;
3133
import org.junit.runners.model.FrameworkMethod;
3234
import org.junit.runners.model.InitializationError;
3335
import org.junit.runners.model.Statement;
36+
import org.junit.runners.model.TestTimedOutException;
3437

3538
/** A specialized {@link BlockJUnit4ClassRunner} that can handle timeouts */
3639
public class AndroidJUnit4ClassRunner extends BlockJUnit4ClassRunner {
@@ -55,13 +58,35 @@ public AndroidJUnit4ClassRunner(Class<?> klass) throws InitializationError {
5558
this(klass, RunnerArgs.parseTestTimeout(getArguments()));
5659
}
5760

61+
private static final ThreadLocal<CountDownLatch> currentTestStartedLatch = new ThreadLocal<>();
62+
private static final ThreadLocal<CountDownLatch> currentTestFinishedLatch = new ThreadLocal<>();
63+
5864
/** Returns a {@link Statement} that invokes {@code method} on {@code test} */
5965
@Override
6066
protected Statement methodInvoker(FrameworkMethod method, Object test) {
67+
final Statement invoker;
6168
if (UiThreadStatement.shouldRunOnUiThread(method)) {
62-
return new UiThreadStatement(super.methodInvoker(method, test), true);
69+
invoker = new UiThreadStatement(super.methodInvoker(method, test), true);
70+
} else {
71+
invoker = super.methodInvoker(method, test);
6372
}
64-
return super.methodInvoker(method, test);
73+
return new Statement() {
74+
@Override
75+
public void evaluate() throws Throwable {
76+
CountDownLatch startLatch = currentTestStartedLatch.get();
77+
if (startLatch != null) {
78+
startLatch.countDown();
79+
}
80+
try {
81+
invoker.evaluate();
82+
} finally {
83+
CountDownLatch finishLatch = currentTestFinishedLatch.get();
84+
if (finishLatch != null) {
85+
finishLatch.countDown();
86+
}
87+
}
88+
}
89+
};
6590
}
6691

6792
@Override
@@ -76,28 +101,76 @@ protected Statement withAfters(FrameworkMethod method, Object target, Statement
76101
return afters.isEmpty() ? statement : new RunAfters(method, statement, afters, target);
77102
}
78103

79-
/**
80-
* Default to {@link org.junit.Test#timeout()} level timeout if set. Otherwise, set the timeout
81-
* that was passed to the instrumentation via argument.
82-
*/
83104
@Override
84-
protected Statement withPotentialTimeout(FrameworkMethod method, Object test, Statement next) {
85-
// test level timeout i.e @Test(timeout = 123)
86-
long timeout = getTimeout(method.getAnnotation(Test.class));
105+
protected Statement methodBlock(FrameworkMethod method) {
106+
final Statement statement = super.methodBlock(method);
87107

88-
// use runner arg timeout if test level timeout is not present
108+
long timeout = getTimeout(method.getAnnotation(Test.class));
89109
if (timeout <= 0 && perTestTimeout > 0) {
90110
timeout = perTestTimeout;
91111
}
112+
final long finalTimeout = timeout;
92113

93-
if (timeout <= 0) {
94-
// no timeout was set
95-
return next;
114+
if (finalTimeout <= 0) {
115+
return statement;
96116
}
97117

98-
// Cannot switch to use builder as that is not supported in JUnit 4.10 which is what is
99-
// available in AOSP.
100-
return new FailOnTimeout(next, timeout);
118+
return new Statement() {
119+
@Override
120+
@SuppressWarnings("Interruption") // We want to interrupt the thread to stop the test.
121+
public void evaluate() throws Throwable {
122+
final AtomicReference<Throwable> failure = new AtomicReference<>();
123+
final CountDownLatch testStartedLatch = new CountDownLatch(1);
124+
final CountDownLatch testFinishedLatch = new CountDownLatch(1);
125+
final CountDownLatch doneLatch = new CountDownLatch(1);
126+
127+
Thread thread =
128+
new Thread(
129+
new Runnable() {
130+
@Override
131+
public void run() {
132+
currentTestStartedLatch.set(testStartedLatch);
133+
currentTestFinishedLatch.set(testFinishedLatch);
134+
try {
135+
statement.evaluate();
136+
} catch (Throwable t) {
137+
failure.set(t);
138+
} finally {
139+
testStartedLatch.countDown();
140+
testFinishedLatch.countDown();
141+
doneLatch.countDown();
142+
currentTestStartedLatch.remove();
143+
currentTestFinishedLatch.remove();
144+
}
145+
}
146+
},
147+
"Time-limited test");
148+
thread.setDaemon(true);
149+
thread.start();
150+
151+
testStartedLatch.await();
152+
boolean finishedInTime = testFinishedLatch.await(finalTimeout, MILLISECONDS);
153+
154+
if (!finishedInTime) {
155+
thread.interrupt();
156+
throw new TestTimedOutException(finalTimeout, MILLISECONDS);
157+
}
158+
159+
doneLatch.await();
160+
if (failure.get() != null) {
161+
throw failure.get();
162+
}
163+
}
164+
};
165+
}
166+
167+
/**
168+
* Default to {@link org.junit.Test#timeout()} level timeout if set. Otherwise, set the timeout
169+
* that was passed to the instrumentation via argument.
170+
*/
171+
@Override
172+
protected Statement withPotentialTimeout(FrameworkMethod method, Object test, Statement next) {
173+
return next;
101174
}
102175

103176
private long getTimeout(Test annotation) {

runner/android_junit_runner/javatests/androidx/test/internal/runner/junit4/AndroidAnnotatedBuilderTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@
2626
import java.lang.reflect.InvocationTargetException;
2727
import java.util.Arrays;
2828
import java.util.Collection;
29+
import org.junit.After;
2930
import org.junit.Assert;
3031
import org.junit.Before;
3132
import org.junit.Test;
33+
import org.junit.runner.JUnitCore;
34+
import org.junit.runner.Result;
3235
import org.junit.runner.RunWith;
3336
import org.junit.runner.Runner;
3437
import org.junit.runners.JUnit4;
3538
import org.junit.runners.Parameterized;
39+
import org.junit.runners.model.InitializationError;
3640
import org.junit.runners.model.RunnerBuilder;
3741
import org.mockito.Mock;
3842
import org.mockito.MockitoAnnotations;
@@ -133,4 +137,41 @@ public Runner buildAndroidRunner(Class<? extends Runner> runnerClass, Class<?> t
133137
// attempt to create a runner for a class with no @RunWith annotation
134138
ab.runnerForClass(NoRunWithClass.class);
135139
}
140+
141+
@SuppressWarnings("NonFinalStaticField") // Static fields are needed to check thread assignment.
142+
public static class TimeoutTestClass {
143+
static Thread beforeThread;
144+
static Thread testThread;
145+
static Thread afterThread;
146+
147+
@Before
148+
public void before() {
149+
beforeThread = Thread.currentThread();
150+
}
151+
152+
@Test(timeout = 5000)
153+
public void testWithTimeout() {
154+
testThread = Thread.currentThread();
155+
}
156+
157+
@After
158+
public void after() {
159+
afterThread = Thread.currentThread();
160+
}
161+
}
162+
163+
@Test
164+
public void testThreadsSameWithTimeout() throws InitializationError {
165+
TimeoutTestClass.beforeThread = null;
166+
TimeoutTestClass.testThread = null;
167+
TimeoutTestClass.afterThread = null;
168+
169+
AndroidJUnit4ClassRunner runner = new AndroidJUnit4ClassRunner(TimeoutTestClass.class, 0);
170+
Result result = new JUnitCore().run(runner);
171+
172+
assertEquals(0, result.getFailureCount());
173+
Assert.assertNotNull(TimeoutTestClass.beforeThread);
174+
assertEquals(TimeoutTestClass.beforeThread, TimeoutTestClass.testThread);
175+
assertEquals(TimeoutTestClass.testThread, TimeoutTestClass.afterThread);
176+
}
136177
}

0 commit comments

Comments
 (0)