Media3 provides dedicated testing artifacts (media3-test-utils and
media3-test-utils-robolectric) to help you test media playback, player state
transitions, UI components, and custom media sources in both local JVM tests
and device instrumentation tests.
This page outlines recommended practices for testing apps that use ExoPlayer
and shows how to use Media3's testing utilities in each test environment.
Testing recommendations
When writing automated tests for media playback, follow these core principles:
- Prefer real components or Media3 fakes over mocks: Avoid mocking
PlayerorExoPlayerwith frameworks such as Mockito.Playerhas state invariants and listener contracts that mocks don't enforce, which can make tests brittle or hide bugs. Instead, use a realExoPlayerinstance,FakePlayer, or fakes such asFakeMediaSourceandFakeDataSource. Reserve mocks for verifying custom callback invocations when needed. - Wait deterministically instead of sleeping:
ExoPlayerperforms media loading and playback asynchronously on an internal thread. Avoid arbitraryThread.sleepcalls, which make tests slow and flaky. UseTestPlayerRunHelperin Robolectric tests and suspendingPlayerextensions orPlayerFencein instrumentation tests. These helpers wait for explicit player conditions and fail immediately if a playback error occurs. - Access the player on its application thread:
ExoPlayerinstances and waiting helpers must be called on the player's applicationLooperthread (typically the main thread). For more information, see the ExoPlayer threading requirements.
Choose the right test environment
Different layers of your media integration benefit from different test environments:
| Test target | Recommended environment | Key Media3 utilities |
|---|---|---|
UI components (PlayerView, Compose UI), ViewModels, or MediaSession callbacks |
Local Robolectric tests (src/test) or instrumented UI tests (src/androidTest) |
FakePlayer |
ExoPlayer integration, playlist transitions, analytics, and custom MediaSource or DataSource logic |
Local Robolectric tests (src/test) |
TestExoPlayerBuilder, TestPlayerRunHelper, FakeMediaSource |
Real hardware MediaCodec decoding, Surface rendering, audio output, and DRM |
Instrumentation tests on physical devices (src/androidTest) |
ExoPlayer.Builder, Player.await* extensions, PlayerFence |
Where possible, run instrumentation playback tests on physical devices rather than emulators, because emulator media stack implementations vary. For more information about emulator support, see the supported devices guide.
Add testing dependencies
Add the Media3 test artifacts to the dependencies block of your module's
build.gradle.kts or build.gradle file using the same version as your other
Media3 modules:
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"
The two modules divide responsibilities by runtime environment:
media3-test-utils: Cross-environment utilities for both local and instrumented tests, includingTestExoPlayerBuilder,PlayerFence(and KotlinPlayer.await*extensions),FakePlayer,FakeClock,FakeMediaSource,FakeDataSource,DataSourceContractTest, andMedia3FlagsRule.media3-test-utils-robolectric: Utilities for Robolectric tests on the local JVM, includingTestPlayerRunHelper,RobolectricUtil,ShadowMediaCodecConfig,CapturingRenderersFactory, andPlaybackOutput.
Make sure your module also includes the test framework dependencies for your test environments:
- Robolectric:
org.robolectric:robolectric. Setandroid.testOptions.unitTests.isIncludeAndroidResources = truewhen loading local assets. - AndroidX Test:
androidx.test:runner,androidx.test:core, andandroidx.test.ext:junit. - Kotlin coroutines on the main thread:
org.jetbrains.kotlinx:kotlinx-coroutines-android.
Opt in to unstable testing APIs
Classes and methods in media3-test-utils and media3-test-utils-robolectric
are annotated with @UnstableApi. Although Media3 aims to keep
widely used test helpers ergonomic across releases, test utilities don't follow
the same binary compatibility stability rules as production playback APIs.
In your test source sets, annotate test classes or methods with
@androidx.annotation.OptIn, add a package-info opt-in for your test
packages, or configure lint.xml for test sources. For more information, see
the unstable API lint troubleshooting notes.
Local tests with Robolectric
Local Robolectric tests run on your development machine's JVM
without a physical device or emulator. Robolectric doesn't provide
MediaCodec implementations, and it keeps the main Looper paused until
explicitly pumped, so a default ExoPlayer instance can't decode media or
deliver state updates on its own.
Build a test player and advance playback
Use TestExoPlayerBuilder to create an ExoPlayer
configured for Robolectric tests. By default, TestExoPlayerBuilder configures
the following components:
- An auto-advancing
FakeClock(isAutoAdvancing = true) that coordinates time progression betweenExoPlayer's background playback thread and Robolectric's paused mainLooper. - Fake video and audio renderers (
FakeVideoRendererandFakeAudioRenderer) that consume media samples according to their timestamps without requiringMediaCodecdecoders.
To progress playback and assert outcomes on the main thread (with a player using
Looper.getMainLooper, the default in Robolectric), use the static
advance(player) or play(player) methods on
TestPlayerRunHelper (play(player) calls
player.play before waiting). For most conditions, the helper pumps
Robolectric's main Looper until the target condition is met and throws an
exception if a fatal or nonfatal playback error occurs:
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); } }
In this example, asset:///media/mp4/sample.mp4 refers to a sample media file
placed in your module's src/test/assets/ (or src/main/assets/) directory.
When passed an ExoPlayer reference, advance(player) and play(player)
return an ExoPlayerRunResult (or
PlayerRunResult when passed a Player). These result
types provide methods for waiting on playback states, positions, playlist
transitions, rendered frames, pending commands, and expected playback errors, or
ignoring nonfatal errors. For more information about waiting conditions, see the
TestPlayerRunHelper API reference.
Test with fake sources and simulated codecs
Many integration tests verify playlist navigation, track selection, timeline
updates, or retry behavior and don't need real media files. You can feed
synthetic timelines and data into a TestExoPlayerBuilder player using
FakeMediaSource, FakeTimeline,
FakeDataSource, or
WebServerDispatcher (with OkHttp MockWebServer):
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 uses fake renderers by default. If your Robolectric test
needs MediaCodec interactions instead, configure simulated codecs and output
capture as follows:
- Add
withAllDefaultSupportedCodecsfromShadowMediaCodecConfigas a JUnit@Ruleto register Robolectric's simulated codecs. - Pass a
CapturingRenderersFactorybuilt with the builder'sClock(CapturingRenderersFactory(context, builder.getClock())) tobuilder.setRenderersFactory. - When testing video tracks, attach a
Surface(such asSurface(SurfaceTexture(1))) withplayer.setVideoSurfacesoMediaCodecVideoRendererrenders decoded frames instead of skipping them. - To verify rendered output, call
register(player, capturingRenderersFactory)onPlaybackOutputbefore starting playback. - After playback finishes, release the player and surface, and then inspect
the formatted output from
Dumper(Dumper().add(playbackOutput).toString()).
Instrumentation tests on devices
Local Robolectric tests don't execute real platform decoders, audio tracks, DRM
sessions, or Surface rendering. To verify end-to-end playback behavior on
devices that run Android, write instrumented tests in
src/androidTest using a real ExoPlayer built with ExoPlayer.Builder.
In JUnit instrumentation tests, @Test methods run on an instrumentation
thread rather than the application's main thread. Always create, control, and
release ExoPlayer on its application Looper thread, for example by using
runBlocking(Dispatchers.Main) in Kotlin or
InstrumentationRegistry.getInstrumentation().runOnMainSync in Java.
Wait for player states with PlayerFence and coroutines
In instrumentation tests, the main Looper runs in real time on the device.
Don't include Robolectric or media3-test-utils-robolectric in
androidTestImplementation dependencies. To wait for playback states and
events, media3-test-utils provides the following helpers:
- Kotlin: Suspending extension functions on
Player(such asawaitPlaybackState,awaitIsPlaying,awaitFirstFrameRendered, andawaitContentPositionAtLeast). - Java: The static
futureWhen(player)method onPlayerFence, which returns aPlayerFencewhose condition methods (such asentersPlaybackStateandisPlaying) must be called on the player'sLooperthread and return aListenableFuturethat the test thread can block on withget.
Both APIs supervise the player while waiting. If a fatal playback error (or, by default, a nonfatal error) occurs, or if the wait exceeds its timeout (10 seconds by default, or 30 seconds on an emulator), the call fails immediately instead of hanging:
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)); } }
When building ExoPlayer with ApplicationProvider.getApplicationContext in
an app module (com.android.application), asset:/// URIs resolve against the
target app's assets (such as src/debug/assets/ or src/main/assets/, rather
than src/androidTest/assets/).
Transient events and custom supervision
In addition to checking current player state (where waits return immediately if
the condition is already met), the helpers in media3-test-utils support
one-off events and custom listener conditions:
- Transient events such as first rendered frame: Attach a video output
(such as a
SurfaceorPlayerView) to the player, and start waiting withawaitFirstFrameRenderedorrendersFirstFramebefore callingplayer.prepareso an early one-off frame event isn't missed. - Custom conditions: Wrap custom listener or coroutine waits inside
awaitSupervisedonplayerin Kotlin, or pass a customListenableFuturetofutureWhen(player).withSupervision(future)in Java. When testing recovery from nonfatal errors, passfailOnNonFatalErrors = false(Kotlin) or callignoringNonFatalErrors(Java), and customize timeouts withtimeoutorwithTimeoutMs(timeoutMs).
The following example waits for a transient first-frame event and supervises a custom metadata listener:
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); }); }
Test UI and app components with FakePlayer
When testing UI components such as PlayerView or media3-ui-compose
composables, ViewModels, or MediaSession callbacks, creating a full
ExoPlayer is often unnecessary. FakePlayer in
media3-test-utils extends SimpleBasePlayer to
simulate playlists, playback states, positions, video sizes, cues, and errors on
the calling thread's Looper. This Looper is available automatically in
Robolectric tests with @RunWith(AndroidJUnit4::class) and on the main thread
in instrumented tests.
By default, FakePlayer simulates a 100 ms buffering delay on prepare and
seekTo; pass bufferingDelayMs = 0 in Kotlin or set states directly with
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();
Additional testing utilities
media3-test-utils includes specialized helpers for other testing scenarios:
- Feature flags and Canary mode: Use
Media3FlagsRuletogether with@BindFlagand@BindCanaryModeto reset flag overrides after each test and run parameterized tests across flag states. For more information, see testing with feature flags. - Custom
DataSourcecontract tests: SubclassDataSourceContractTestto verify that customDataSourceorHttpDataSourceimplementations conform to Media3's contract expectations across range requests, error handling, and transfer listeners. For more information, see integrating other network stacks.