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"
这两个模块按运行时环境划分职责:
media3-test-utils:适用于本地测试和插桩测试的跨环境实用程序,包括TestExoPlayerBuilder、PlayerFence(和 KotlinPlayer.await*扩展程序)、FakePlayer、FakeClock、FakeMediaSource、FakeDataSource、DataSourceContractTest和Media3FlagsRule。media3-test-utils-robolectric:用于在本地 JVM 上进行 Robolectric 测试的实用程序,包括TestPlayerRunHelper、RobolectricUtil、ShadowMediaCodecConfig、CapturingRenderersFactory和PlaybackOutput。
确保您的模块还包含测试环境的测试框架依赖项:
- 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 交互,请按如下方式配置模拟编解码器和输出捕获:
- 从
ShadowMediaCodecConfig添加withAllDefaultSupportedCodecs作为 JUnit@Rule,以注册 Robolectric 的模拟编解码器。 - 将使用构建器的
Clock(CapturingRenderersFactory(context, builder.getClock())) 构建的CapturingRenderersFactory传递给builder.setRenderersFactory。 - 测试视频轨道时,请将
Surface(例如Surface(SurfaceTexture(1)))与player.setVideoSurface关联,以便MediaCodecVideoRenderer呈现解码后的帧,而不是跳过它们。 - 如需验证渲染的输出,请在开始播放之前对
PlaybackOutput调用register(player, capturingRenderersFactory)。 - 播放结束后,释放播放器和 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 提供了以下辅助程序:
- Kotlin:
Player上的挂起扩展函数(例如awaitPlaybackState、awaitIsPlaying、awaitFirstFrameRendered和awaitContentPositionAtLeast)。 - Java:
PlayerFence上的静态futureWhen(player)方法,该方法会返回一个PlayerFence,其条件方法(例如entersPlaybackState和isPlaying)必须在播放器的Looper线程上调用,并返回一个测试线程可以使用get阻塞的ListenableFuture。
这两个 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 包含适用于其他测试场景的专用辅助程序:
- 功能标志和 Canary 模式:将
Media3FlagsRule与@BindFlag和@BindCanaryMode结合使用,可在每次测试后重置标志替换,并跨标志状态运行参数化测试。如需了解详情,请参阅使用功能标志进行测试。 - 自定义
DataSource协定测试:子类DataSourceContractTest,用于验证自定义DataSource或HttpDataSource实现是否符合 Media3 在范围请求、错误处理和传输监听器方面的协定预期。如需了解详情,请参阅集成其他网络栈。