From 2ac5d8f1afb6be7e532bcbda81f30b5de2035518 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 20 Jun 2023 09:34:09 +0100 Subject: [PATCH] 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 --- RELEASENOTES.md | 3 ++ constants.gradle | 1 + libraries/test_utils/build.gradle | 1 + .../androidx/media3/test/utils/FakeClock.java | 43 +++++++++++++++++++ .../media3/test/utils/FakeClockTest.java | 42 ++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ee3e6d1c72..2757b9d681 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 diff --git a/constants.gradle b/constants.gradle index c09b0d3034..cd7a130e02 100644 --- a/constants.gradle +++ b/constants.gradle @@ -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' diff --git a/libraries/test_utils/build.gradle b/libraries/test_utils/build.gradle index 663864ed31..d888ff2c5d 100644 --- a/libraries/test_utils/build.gradle +++ b/libraries/test_utils/build.gradle @@ -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 } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java index db37f66405..c962d87bcf 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java @@ -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 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 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, HandlerWrapper.Message { diff --git a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java index a63d27bbbd..8bef1456e1 100644 --- a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java +++ b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java @@ -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 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]);