Make FakeClock Espresso and Compose UI test compatible
FakeClock currently doesn't work well with Espresso and Compose UI tests because view interactions in both frameworks intentionally idle the main looper to handle pending UI effects. However, this also advances playback progress even though we want to deterministically trigger progress from the test itself. To solve this problem, we can detect the idling Robolectric call and postpone any further updates until we leave this state. PiperOrigin-RevId: 541831050
This commit is contained in:
parent
abd1c006fc
commit
2ac5d8f1af
@ -85,6 +85,9 @@
|
||||
standard MIDI files using the Jsyn library to synthesize audio.
|
||||
* Cast Extension:
|
||||
* Test Utilities:
|
||||
* Make `TestExoPlayerBuilder` and `FakeClock` compatible with Espresso UI
|
||||
tests and Compose UI tests. This fixes a bug where playback advances
|
||||
non-deterministically during Espresso or Compose view interactions.
|
||||
* Remove deprecated symbols:
|
||||
|
||||
## 1.1
|
||||
|
@ -52,6 +52,7 @@ project.ext {
|
||||
androidxRecyclerViewVersion = '1.3.0'
|
||||
androidxMaterialVersion = '1.8.0'
|
||||
androidxTestCoreVersion = '1.5.0'
|
||||
androidxTestEspressoVersion = '3.5.1'
|
||||
androidxTestJUnitVersion = '1.1.5'
|
||||
androidxTestRunnerVersion = '1.5.2'
|
||||
androidxTestRulesVersion = '1.5.0'
|
||||
|
@ -36,6 +36,7 @@ dependencies {
|
||||
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
|
||||
implementation 'com.squareup.okhttp3:mockwebserver:' + okhttpVersion
|
||||
implementation project(modulePrefix + 'lib-exoplayer')
|
||||
testImplementation 'androidx.test.espresso:espresso-core:' + androidxTestEspressoVersion
|
||||
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ import androidx.media3.common.util.Clock;
|
||||
import androidx.media3.common.util.HandlerWrapper;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import com.google.common.collect.ComparisonChain;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Ordering;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@ -52,10 +53,20 @@ import java.util.Set;
|
||||
@UnstableApi
|
||||
public class FakeClock implements Clock {
|
||||
|
||||
private static final ImmutableSet<String> UI_INTERACTION_TEST_CLASSES =
|
||||
ImmutableSet.of(
|
||||
"org.robolectric.android.internal.LocalControlledLooper",
|
||||
"androidx.test.core.app.ActivityScenario",
|
||||
"org.robolectric.android.controller.ActivityController");
|
||||
private static final String ROBOLECTRIC_SHADOW_LOOPER_CLASS =
|
||||
"org.robolectric.shadows.ShadowPausedLooper";
|
||||
private static final String ROBOLECTRIC_SHADOW_LOOPER_IDLE_METHOD = "idle";
|
||||
|
||||
private static long messageIdProvider = 0;
|
||||
|
||||
private final boolean isRobolectric;
|
||||
private final boolean isAutoAdvancing;
|
||||
private final Handler mainHandler;
|
||||
|
||||
@GuardedBy("this")
|
||||
private final List<HandlerMessage> handlerMessages;
|
||||
@ -121,6 +132,7 @@ public class FakeClock implements Clock {
|
||||
this.isAutoAdvancing = isAutoAdvancing;
|
||||
this.handlerMessages = new ArrayList<>();
|
||||
this.busyLoopers = new HashSet<>();
|
||||
this.mainHandler = new Handler(Looper.getMainLooper());
|
||||
this.isRobolectric = "robolectric".equals(Build.FINGERPRINT);
|
||||
if (isRobolectric) {
|
||||
SystemClock.setCurrentTimeMillis(initialTimeMs);
|
||||
@ -235,6 +247,18 @@ public class FakeClock implements Clock {
|
||||
}
|
||||
message = handlerMessages.get(messageIndex);
|
||||
}
|
||||
if (message.handler.getLooper() == Looper.getMainLooper() && isIdlingInUiInteraction()) {
|
||||
// UI interaction tests idle the main looper and may trigger almost infinite progress in the
|
||||
// player. Avoid this situation by postponing any further updates on the main looper to after
|
||||
// the UI interaction.
|
||||
Looper.myQueue()
|
||||
.addIdleHandler(
|
||||
() -> {
|
||||
mainHandler.postDelayed(this::maybeTriggerMessage, /* delayMillis= */ 1);
|
||||
return false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.timeMs > timeSinceBootMs) {
|
||||
if (isAutoAdvancing) {
|
||||
advanceTimeInternal(message.timeMs - timeSinceBootMs);
|
||||
@ -276,6 +300,25 @@ public class FakeClock implements Clock {
|
||||
return messageIdProvider++;
|
||||
}
|
||||
|
||||
private static boolean isIdlingInUiInteraction() {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
return false;
|
||||
}
|
||||
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
|
||||
boolean isIdling = false;
|
||||
boolean isInUiInteraction = false;
|
||||
for (StackTraceElement element : stackTrace) {
|
||||
if (UI_INTERACTION_TEST_CLASSES.contains(element.getClassName())) {
|
||||
isInUiInteraction = true;
|
||||
}
|
||||
if (element.getClassName().equals(ROBOLECTRIC_SHADOW_LOOPER_CLASS)
|
||||
&& element.getMethodName().equals(ROBOLECTRIC_SHADOW_LOOPER_IDLE_METHOD)) {
|
||||
isIdling = true;
|
||||
}
|
||||
}
|
||||
return isIdling && isInUiInteraction;
|
||||
}
|
||||
|
||||
/** Message data saved to send messages or execute runnables at a later time on a Handler. */
|
||||
protected final class HandlerMessage
|
||||
implements Comparable<HandlerMessage>, HandlerWrapper.Message {
|
||||
|
@ -15,13 +15,20 @@
|
||||
*/
|
||||
package androidx.media3.test.utils;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.robolectric.Shadows.shadowOf;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.os.ConditionVariable;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.widget.Button;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.util.HandlerWrapper;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
@ -32,6 +39,8 @@ import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.android.controller.ActivityController;
|
||||
import org.robolectric.shadows.ShadowLooper;
|
||||
|
||||
/** Unit test for {@link FakeClock}. */
|
||||
@ -416,6 +425,39 @@ public final class FakeClockTest {
|
||||
assertThat(messageOnDeadThreadExecuted.get()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void espressoViewInteraction_doesNotHandleDelayedPendingMessages() {
|
||||
try (ActivityController<TestActivity> activityController =
|
||||
Robolectric.buildActivity(TestActivity.class)) {
|
||||
TestActivity activity = activityController.setup().get();
|
||||
FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 0, /* isAutoAdvancing= */ true);
|
||||
AtomicBoolean delayedChange = new AtomicBoolean();
|
||||
fakeClock
|
||||
.createHandler(Looper.myLooper(), /* callback= */ null)
|
||||
.postDelayed(() -> delayedChange.set(true), /* delayMs= */ 50);
|
||||
|
||||
onView(equalTo(activity.button)).perform(click());
|
||||
|
||||
assertThat(delayedChange.get()).isFalse();
|
||||
|
||||
// Verify test setup that the delayed message gets executed with manually triggered progress.
|
||||
ShadowLooper.runMainLooperToNextTask();
|
||||
assertThat(delayedChange.get()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static class TestActivity extends Activity {
|
||||
|
||||
public Button button;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
button = new Button(this);
|
||||
setContentView(button);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertTestRunnableStates(boolean[] states, TestRunnable[] testRunnables) {
|
||||
for (int i = 0; i < testRunnables.length; i++) {
|
||||
assertThat(testRunnables[i].hasRun).isEqualTo(states[i]);
|
||||
|
Loading…
x
Reference in New Issue
Block a user