測試

Media3 提供專屬測試構件 (media3-test-utils 和 media3-test-utils-robolectric),協助您在本機 JVM 測試和裝置檢測設備測試中,測試媒體播放、播放器狀態轉換、UI 元件和自訂媒體來源。

本頁面概述測試使用 ExoPlayer 的應用程式時,建議採取的做法,並說明如何在各個測試環境中使用 Media3 的測試公用程式。

測試建議

編寫媒體播放的自動化測試時,請遵循下列核心原則:

  • 建議使用實際元件或 Media3 假元件,而非模擬元件:請避免使用 Mockito 等架構模擬 Player 或 ExoPlayer。Player 具有狀態不變量和事件監聽器合約,模擬物件不會強制執行這些合約,這可能會導致測試不穩定或隱藏錯誤。請改用實際的 ExoPlayer 執行個體、FakePlayer,或 FakeMediaSource 和 FakeDataSource 等假資料。視需要保留模擬物件,用於驗證自訂回呼叫用。
  • 以確定性等待取代休眠:ExoPlayer 會在內部執行緒上,以非同步方式載入及播放媒體。避免任意呼叫 Thread.sleep,以免測試速度變慢且不穩定。在 Robolectric 測試中使用 TestPlayerRunHelper,並在檢測設備測試中暫停 Player 擴充功能或 PlayerFence。這些輔助程式會等待明確的播放器條件,並在發生播放錯誤時立即失敗。
  • 在應用程式執行緒上存取播放器:必須在播放器的應用程式 Looper 執行緒 (通常是主執行緒) 上呼叫 ExoPlayer 執行個體和等待輔助程式。詳情請參閱 ExoPlayer 執行緒需求。

選擇合適的測試環境

媒體整合的不同層級適用於不同的測試環境:

測試目標 建議環境 重要 Media3 公用程式
UI 元件 (PlayerView、Compose UI)、ViewModel 或 MediaSession 回呼 本機 Robolectric 測試 (src/test) 或檢測設備 UI 測試 (src/androidTest) FakePlayer
ExoPlayer 整合、播放清單轉換、數據分析,以及自訂 MediaSource 或 DataSource 邏輯 本機 Robolectric 測試 (src/test) TestExoPlayerBuilder、TestPlayerRunHelper、FakeMediaSource
實體硬體 MediaCodec 解碼、Surface 轉譯、音訊輸出和 DRM 在實體裝置上進行檢測設備測試 (src/androidTest) ExoPlayer.Builder,Player.await* 擴充功能,PlayerFence

請盡可能在實體裝置上執行檢測設備播放測試,而非模擬器,因為模擬器媒體堆疊實作方式不盡相同。如要進一步瞭解模擬器支援,請參閱支援的裝置指南。

新增測試依附元件

將 Media3 測試構件新增至模組 build.gradle.kts 或 build.gradle 檔案的 dependencies 區塊,並使用與其他 Media3 模組相同的版本:

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 Test: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. 測試影片軌時,請使用 player.setVideoSurface 附加 Surface (例如 Surface(SurfaceTexture(1))),讓 MediaCodecVideoRenderer 轉譯解碼的影格,而不是略過。
  4. 如要驗證算繪輸出內容,請在開始播放前,對 PlaybackOutput 呼叫 register(player, capturingRenderersFactory)。
  5. 播放結束後,釋出播放器和介面,然後檢查 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 測試 UI 和應用程式元件

測試 PlayerView 或 media3-ui-compose 可組合項、ViewModel 或 MediaSession 回呼等 UI 元件時,通常不需要建立完整的 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 包含其他測試情境的專用輔助程式: