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:
tonihei 2023-06-20 09:34:09 +01:00 committed by Ian Baker
parent abd1c006fc
commit 2ac5d8f1af
5 changed files with 90 additions and 0 deletions

View File

@ -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

View File

@ -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'

View File

@ -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
}

View File

@ -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 {

View File

@ -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]);