测试

Media3 提供了专用的测试工件(media3-test-utils 和 media3-test-utils-robolectric),可帮助您在本地 JVM 测试和设备插桩测试中测试媒体播放、播放器状态转换、界面组件和自定义媒体来源。

本页概述了测试使用 ExoPlayer 的应用时的推荐做法,并介绍了如何在每个测试环境中使用 Media3 的测试实用程序。

测试建议

为媒体播放编写自动化测试时,请遵循以下核心原则:

  • 优先使用真实组件或 Media3 伪造对象,而不是模拟对象:避免使用 Mockito 等框架模拟 Player 或 ExoPlayer。Player 具有模拟对象不强制执行的状态不变量和监听器协定,这可能会使测试变得脆弱或隐藏 bug。请改用真实的 ExoPlayer 实例 FakePlayer,或 FakeMediaSource 和 FakeDataSource 等虚假实例。保留模拟对象,以便在需要时验证自定义回调调用。
  • 确定性地等待,而不是休眠:ExoPlayer 会在内部线程上异步执行媒体加载和播放。避免任意 Thread.sleep 调用,这会使测试变得缓慢且不稳定。在 Robolectric 测试中使用 TestPlayerRunHelper,在插桩测试中使用挂起 Player 扩展程序或 PlayerFence。这些辅助程序会等待明确的播放器条件,并在发生播放错误时立即失败。
  • 在播放器的应用线程上访问播放器:必须在播放器的应用 Looper 线程(通常为主线程)上调用 ExoPlayer 实例和等待辅助程序。如需了解详情,请参阅 ExoPlayer 线程处理要求。

选择合适的测试环境

媒体集成的不同层级受益于不同的测试环境:

测试目标 推荐的环境 关键 Media3 实用程序
界面组件(PlayerView、Compose 界面)、ViewModel 或 MediaSession 回调 本地 Robolectric 测试 (src/test) 或插桩界面测试 (src/androidTest) FakePlayer
ExoPlayer 集成、播放列表转换、分析以及自定义 MediaSource 或 DataSource 逻辑 本地 Robolectric 测试 (src/test) TestExoPlayerBuilder、TestPlayerRunHelper、FakeMediaSource
真实的硬件 MediaCodec 解码、Surface 渲染、音频输出和 DRM 在实体设备上进行插桩测试 (src/androidTest) ExoPlayer.Builder、Player.await* 个扩展程序、PlayerFence

请尽可能在实体设备(而非模拟器)上运行插桩播放测试,因为模拟器媒体堆栈实现会有所不同。如需详细了解模拟器支持,请参阅支持的设备指南。

添加测试依赖项

使用与其他 Media3 模块相同的版本,将 Media3 测试制品添加到模块的 build.gradle.kts 或 build.gradle 文件的 dependencies 代码块中:

Kotlin

testImplementation("androidx.media3:media3-test-utils:1.11.1")
testImplementation("androidx.media3:media3-test-utils-robolectric:1.11.1")
androidTestImplementation("androidx.media3:media3-test-utils:1.11.1")

Groovy

testImplementation "androidx.media3:media3-test-utils:1.11.1"
testImplementation "androidx.media3:media3-test-utils-robolectric:1.11.1"
androidTestImplementation "androidx.media3:media3-test-utils:1.11.1"

这两个模块按运行时环境划分职责:

确保您的模块还包含测试环境的测试框架依赖项:

  • Robolectric:org.robolectric:robolectric。加载本地资源时,请设置 android.testOptions.unitTests.isIncludeAndroidResources = true。
  • AndroidX 测试:androidx.test:runner、androidx.test:core 和 androidx.test.ext:junit。
  • 主线程上的 Kotlin 协程:org.jetbrains.kotlinx:kotlinx-coroutines-android。

选择启用不稳定的测试 API

media3-test-utils 和 media3-test-utils-robolectric 中的类和方法使用 @UnstableApi 进行注解。虽然 Media3 旨在让广泛使用的测试辅助工具在各个版本中保持易用性,但测试实用程序并不遵循与正式版播放 API 相同的二进制兼容性稳定性规则。

在测试源代码集中,使用 @androidx.annotation.OptIn 为测试类或方法添加注解,为测试软件包添加 package-info 选择启用,或为测试源代码配置 lint.xml。如需了解详情,请参阅不稳定的 API lint 问题排查说明。

使用 Robolectric 进行本地测试

本地 Robolectric 测试在开发机器的 JVM 上运行,无需实体设备或模拟器。Robolectric 不提供 MediaCodec 实现,并且会使主 Looper 保持暂停状态,直到明确泵送,因此默认 ExoPlayer 实例无法自行解码媒体或提供状态更新。

构建测试播放器并推进播放

使用 TestExoPlayerBuilder 创建为 Robolectric 测试配置的 ExoPlayer。默认情况下,TestExoPlayerBuilder 会配置以下组件:

  • 一个自动推进的 FakeClock (isAutoAdvancing = true),用于协调 ExoPlayer 的后台播放线程与 Robolectric 的已暂停主 Looper 之间的时间推进。
  • 虚假视频和音频渲染程序(FakeVideoRenderer 和 FakeAudioRenderer),它们根据时间戳使用媒体样本,而无需 MediaCodec 解码器。

如需在主线程上推进播放并断言结果(使用 Looper.getMainLooper 的播放器,Robolectric 中的默认设置),请在 TestPlayerRunHelper 上使用静态 advance(player) 或 play(player) 方法(play(player) 会在等待之前调用 player.play)。对于大多数条件,辅助程序会泵送 Robolectric 的主 Looper,直到满足目标条件,并在发生严重或非严重播放错误时抛出异常:

Kotlin

@RunWith(AndroidJUnit4::class)
@OptIn(UnstableApi::class)
class PlaybackRobolectricTest {

  private lateinit var player: ExoPlayer

  @After
  fun tearDown() {
    if (::player.isInitialized) {
      player.release()
    }
  }

  @Test
  fun play_sampleMediaItem_reachesEndedState() {
    val context = ApplicationProvider.getApplicationContext<Context>()
    player = TestExoPlayerBuilder(context).build()
    player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"))
    player.prepare()

    play(player).untilState(Player.STATE_ENDED)

    assertThat(player.playbackState).isEqualTo(Player.STATE_ENDED)
  }
}

Java

@RunWith(AndroidJUnit4.class)
@OptIn(markerClass = UnstableApi.class)
public static class PlaybackRobolectricTest {

  private @Nullable ExoPlayer player;

  @After
  public void tearDown() {
    if (player != null) {
      player.release();
    }
  }

  @Test
  public void play_sampleMediaItem_reachesEndedState() throws Exception {
    Context context = ApplicationProvider.getApplicationContext();
    player = new TestExoPlayerBuilder(context).build();
    player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
    player.prepare();

    play(player).untilState(Player.STATE_ENDED);

    assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED);
  }
}

在此示例中,asset:///media/mp4/sample.mp4 是指放置在模块的 src/test/assets/(或 src/main/assets/)目录中的示例媒体文件。

当传递 ExoPlayer 引用时,advance(player) 和 play(player) 会返回 ExoPlayerRunResult(或在传递 Player 时返回 PlayerRunResult)。这些结果类型提供了用于等待播放状态、位置、播放列表转换、渲染帧、待处理命令和预期播放错误的方法,或忽略非严重错误的方法。如需详细了解等待条件,请参阅 TestPlayerRunHelper API 参考文档。

使用虚构来源和模拟编解码器进行测试

许多集成测试会验证播放列表导航、曲目选择、时间轴更新或重试行为,不需要真实的媒体文件。您可以使用 FakeMediaSource、FakeTimeline、FakeDataSource 或 WebServerDispatcher(使用 OkHttp MockWebServer)将合成时间轴和数据馈送到 TestExoPlayerBuilder 播放器中:

Kotlin

val player = TestExoPlayerBuilder(context).build()
val fakeMediaSource = FakeMediaSource(FakeTimeline(/* windowCount= */ 2))
player.setMediaSource(fakeMediaSource)
player.prepare()

play(player).untilMediaItemIndex(/* mediaItemIndex= */ 1)

assertThat(player.currentMediaItemIndex).isEqualTo(1)
player.release()

Java

ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeMediaSource fakeMediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2));
player.setMediaSource(fakeMediaSource);
player.prepare();

play(player).untilMediaItemIndex(/* mediaItemIndex= */ 1);

assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1);
player.release();

TestExoPlayerBuilder 默认使用虚假渲染器。如果您的 Robolectric 测试需要 MediaCodec 交互,请按如下方式配置模拟编解码器和输出捕获:

  1. 从 ShadowMediaCodecConfig 添加 withAllDefaultSupportedCodecs 作为 JUnit @Rule,以注册 Robolectric 的模拟编解码器。
  2. 将使用构建器的 Clock (CapturingRenderersFactory(context, builder.getClock())) 构建的 CapturingRenderersFactory 传递给 builder.setRenderersFactory。
  3. 测试视频轨道时,请将 Surface(例如 Surface(SurfaceTexture(1)))与 player.setVideoSurface 关联,以便 MediaCodecVideoRenderer 呈现解码后的帧,而不是跳过它们。
  4. 如需验证渲染的输出,请在开始播放之前对 PlaybackOutput 调用 register(player, capturingRenderersFactory)。
  5. 播放结束后,释放播放器和 Surface,然后检查 Dumper (Dumper().add(playbackOutput).toString()) 的格式化输出。

在设备上进行插桩测试

本地 Robolectric 测试不会执行实际的平台解码器、音轨、DRM 会话或 Surface 渲染。如需在搭载 Android 的设备上验证端到端播放行为,请使用通过 ExoPlayer.Builder 构建的真实 ExoPlayer 在 src/androidTest 中编写插桩测试。

在 JUnit 插桩测试中,@Test 方法在插桩线程(而非应用的主线程)上运行。始终在其应用 Looper 线程上创建、控制和释放 ExoPlayer,例如在 Kotlin 中使用 runBlocking(Dispatchers.Main) 或在 Java 中使用 InstrumentationRegistry.getInstrumentation().runOnMainSync。

使用 PlayerFence 和协程等待玩家状态

在插桩测试中,主 Looper 会在设备上实时运行。 请勿在 androidTestImplementation 依赖项中添加 Robolectric 或 media3-test-utils-robolectric。为了等待播放状态和事件,media3-test-utils 提供了以下辅助程序:

这两个 API 都会在等待期间监督播放器。如果发生严重播放错误(或默认情况下发生非严重错误),或者等待时间超过超时时间(默认情况下为 10 秒,在模拟器上为 30 秒),调用会立即失败,而不是挂起:

Kotlin

@RunWith(AndroidJUnit4::class)
@OptIn(UnstableApi::class)
class PlaybackInstrumentationTest {

  private lateinit var player: ExoPlayer

  @After
  fun tearDown() =
    runBlocking(Dispatchers.Main) {
      if (::player.isInitialized) {
        player.release()
      }
    }

  @Test
  fun play_sampleMediaItem_advancesAndEnds() =
    runBlocking(Dispatchers.Main) {
      val context = ApplicationProvider.getApplicationContext<Context>()
      player = ExoPlayer.Builder(context).build()
      player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"))
      player.prepare()

      player.play()
      player.awaitIsPlaying(true)
      player.awaitContentPositionAtLeast(500)
      player.awaitPlaybackState(Player.STATE_ENDED)

      assertThat(player.playbackState).isEqualTo(Player.STATE_ENDED)
    }
}

Java

@RunWith(AndroidJUnit4.class)
@OptIn(markerClass = UnstableApi.class)
public static class PlaybackInstrumentationTest {

  private @Nullable ExoPlayer player;

  @After
  public void tearDown() {
    InstrumentationRegistry.getInstrumentation()
        .runOnMainSync(() -> {
          if (player != null) {
            player.release();
          }
        });
  }

  @Test
  public void play_sampleMediaItem_advancesAndEnds() throws Exception {
    SettableFuture<Void> playbackStarted = SettableFuture.create();
    SettableFuture<Void> positionPassed = SettableFuture.create();
    SettableFuture<Void> playbackEnded = SettableFuture.create();
    InstrumentationRegistry.getInstrumentation()
        .runOnMainSync(() -> {
          Context context = ApplicationProvider.getApplicationContext();
          player = new ExoPlayer.Builder(context).build();
          player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
          playbackStarted.setFuture(futureWhen(player).isPlaying(true));
          positionPassed.setFuture(futureWhen(player).passesContentPosition(500));
          playbackEnded.setFuture(futureWhen(player).entersPlaybackState(Player.STATE_ENDED));
          player.prepare();
          player.play();
        });

    playbackStarted.get();
    positionPassed.get();
    playbackEnded.get();

    InstrumentationRegistry.getInstrumentation()
        .runOnMainSync(() -> assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED));
  }
}

在应用模块 (com.android.application) 中使用 ApplicationProvider.getApplicationContext 构建 ExoPlayer 时,asset:/// URI 会根据目标应用的资源(例如 src/debug/assets/ 或 src/main/assets/,而不是 src/androidTest/assets/)进行解析。

瞬态事件和自定义监督

除了检查当前播放器状态(如果条件已满足,等待会立即返回)之外,media3-test-utils 中的辅助程序还支持一次性事件和自定义监听器条件:

  • 瞬态事件,例如首个渲染帧:将视频输出(例如 Surface 或 PlayerView)附加到播放器,并在调用 player.prepare 之前开始使用 awaitFirstFrameRendered 或 rendersFirstFrame 等待,以免错过早期的一次性帧事件。
  • 自定义条件:在 Kotlin 中,将自定义监听器或协程等待封装在 player 上的 awaitSupervised 中;在 Java 中,将自定义 ListenableFuture 传递给 futureWhen(player).withSupervision(future)。测试从非严重错误中恢复时,请传递 failOnNonFatalErrors = false (Kotlin) 或调用 ignoringNonFatalErrors (Java),并使用 timeout 或 withTimeoutMs(timeoutMs) 自定义超时。

以下示例会等待瞬态首帧事件,并监督自定义元数据监听器:

Kotlin

// Start awaiting transient events (with a video surface attached) before prepare().
coroutineScope {
  val firstFrameDeferred =
    async(start = CoroutineStart.UNDISPATCHED) { player.awaitFirstFrameRendered() }
  player.setMediaItem(mediaItem)
  player.prepare()
  player.play()
  firstFrameDeferred.await()
}

// Supervise a custom listener condition while still failing fast on player errors.
val metadataTitle = player.awaitSupervised {
  player.mediaMetadata.title?.let {
    return@awaitSupervised it
  }
  val titleDeferred = CompletableDeferred<CharSequence>()
  val listener =
    object : Player.Listener {
      override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
        mediaMetadata.title?.let { titleDeferred.complete(it) }
      }
    }
  player.addListener(listener)
  try {
    titleDeferred.await()
  } finally {
    player.removeListener(listener)
  }
}

Java

SettableFuture<Void> firstFrameFuture = SettableFuture.create();
SettableFuture<CharSequence> metadataTitleFuture = SettableFuture.create();
SettableFuture<CharSequence> supervisedTitleFuture = SettableFuture.create();
Player.Listener metadataListener =
    new Player.Listener() {
      @Override
      public void onMediaMetadataChanged(MediaMetadata mediaMetadata) {
        if (mediaMetadata.title != null) {
          metadataTitleFuture.set(mediaMetadata.title);
        }
      }
    };

try {
  InstrumentationRegistry.getInstrumentation()
      .runOnMainSync(() -> {
        // Register transient event fences (with a video surface attached) before prepare().
        firstFrameFuture.setFuture(futureWhen(player).rendersFirstFrame());

        // Supervise a custom ListenableFuture so player errors fail fast.
        player.addListener(metadataListener);
        supervisedTitleFuture.setFuture(
            futureWhen(player).withSupervision(metadataTitleFuture));

        player.setMediaItem(mediaItem);
        if (player.getMediaMetadata().title != null) {
          metadataTitleFuture.set(player.getMediaMetadata().title);
        }
        player.prepare();
        player.play();
      });

  firstFrameFuture.get();
  CharSequence title = supervisedTitleFuture.get();
} finally {
  InstrumentationRegistry.getInstrumentation()
      .runOnMainSync(() -> {
        player.removeListener(metadataListener);
        firstFrameFuture.cancel(/* mayInterruptIfRunning= */ true);
        supervisedTitleFuture.cancel(/* mayInterruptIfRunning= */ true);
      });
}

使用 FakePlayer 测试界面和应用组件

在测试 PlayerView 或 media3-ui-compose 可组合项、ViewModel 或 MediaSession 回调等界面组件时,通常无需创建完整的 ExoPlayer。media3-test-utils 中的 FakePlayer 扩展了 SimpleBasePlayer,以在调用线程的 Looper 上模拟播放列表、播放状态、位置、视频大小、提示和错误。此 Looper 会自动在 Robolectric 测试中与 @RunWith(AndroidJUnit4::class) 一起使用,并在插桩测试的主线程中使用。

默认情况下,FakePlayer 会在 prepare 和 seekTo 上模拟 100 毫秒的缓冲延迟;在 Kotlin 中传递 bufferingDelayMs = 0,或使用 setPlaybackState 直接设置状态:

Kotlin

val fakePlayer = FakePlayer(bufferingDelayMs = 0)
fakePlayer.setMediaItem(MediaItem.fromUri("https://example.com/media.mp4"))
fakePlayer.prepare()
fakePlayer.play()

assertThat(fakePlayer.isPlaying).isTrue()

// Drive state transitions directly to test UI or session listeners.
fakePlayer.setPosition(30_000)
fakePlayer.setPlaybackState(Player.STATE_ENDED)
fakePlayer.release()

Java

FakePlayer fakePlayer = new FakePlayer();
fakePlayer.setMediaItem(MediaItem.fromUri("https://example.com/media.mp4"));
// Drive state transitions directly (prepare() simulates a 100 ms buffering delay by default).
fakePlayer.setPlaybackState(Player.STATE_READY);
fakePlayer.play();

assertThat(fakePlayer.isPlaying()).isTrue();

fakePlayer.setPosition(30_000);
fakePlayer.setPlaybackState(Player.STATE_ENDED);
fakePlayer.release();

其他测试实用程序

media3-test-utils 包含适用于其他测试场景的专用辅助程序: