Kiểm thử

Media3 cung cấp các cấu phần phần mềm kiểm thử chuyên dụng (media3-test-utils và media3-test-utils-robolectric) để giúp bạn kiểm thử quá trình phát nội dung nghe nhìn, chuyển đổi trạng thái trình phát, thành phần giao diện người dùng và nguồn nội dung nghe nhìn tuỳ chỉnh trong cả kiểm thử JVM cục bộ và kiểm thử đo lường thiết bị.

Trang này trình bày các phương pháp hay nhất được đề xuất để kiểm thử ứng dụng sử dụng ExoPlayer và cho biết cách sử dụng các tiện ích kiểm thử của Media3 trong từng môi trường thử nghiệm.

Đề xuất thử nghiệm

Khi viết kiểm thử tự động cho hoạt động phát nội dung nghe nhìn, hãy tuân theo các nguyên tắc cốt lõi sau:

  • Ưu tiên các thành phần thực hoặc thành phần giả lập Media3 hơn là mô phỏng: Tránh mô phỏng Player hoặc ExoPlayer bằng các khung như Mockito. Player có các bất biến trạng thái và hợp đồng trình nghe mà mô phỏng không thực thi, điều này có thể khiến các kiểm thử trở nên không ổn định hoặc ẩn lỗi. Thay vào đó, hãy sử dụng một thực thể ExoPlayer thực, FakePlayer hoặc các bản mô phỏng như FakeMediaSource và FakeDataSource. Dành riêng mô phỏng để xác minh lệnh gọi lại tuỳ chỉnh khi cần.
  • Chờ một cách tất định thay vì ở trạng thái ngủ: ExoPlayer thực hiện việc tải và phát nội dung nghe nhìn không đồng bộ trên một luồng nội bộ. Tránh các lệnh gọi Thread.sleep tuỳ ý vì chúng khiến các kiểm thử bị chậm và không ổn định. Sử dụng TestPlayerRunHelper trong các kiểm thử Robolectric và tạm ngưng các tiện ích Player hoặc PlayerFence trong các kiểm thử đo lường. Các trình trợ giúp này chờ các điều kiện rõ ràng của trình phát và không thành công ngay lập tức nếu xảy ra lỗi phát.
  • Truy cập vào trình phát trên luồng ứng dụng của trình phát: Bạn phải gọi các thực thể ExoPlayer và trình trợ giúp chờ trên luồng Looper của ứng dụng trình phát (thường là luồng chính). Để biết thêm thông tin, hãy xem các yêu cầu về luồng của ExoPlayer.

Chọn môi trường thử nghiệm phù hợp

Các lớp khác nhau trong quá trình tích hợp nội dung nghe nhìn sẽ được hưởng lợi từ các môi trường thử nghiệm khác nhau:

Mục tiêu thử nghiệm Môi trường đề xuất Các tiện ích chính của Media3
Thành phần giao diện người dùng (PlayerView, Giao diện người dùng Compose), ViewModel hoặc lệnh gọi lại MediaSession Kiểm thử Robolectric cục bộ (src/test) hoặc kiểm thử giao diện người dùng đo lường (src/androidTest) FakePlayer
Tích hợp ExoPlayer, chuyển đổi danh sách phát, số liệu phân tích và logic MediaSource hoặc DataSource tuỳ chỉnh Kiểm thử Robolectric cục bộ (src/test) TestExoPlayerBuilder, TestPlayerRunHelper, FakeMediaSource
Giải mã MediaCodec, kết xuất Surface, đầu ra âm thanh và DRM phần cứng thực tế Kiểm thử đo lường trên thiết bị thực (src/androidTest) ExoPlayer.Builder, Player.await* tiện ích, PlayerFence

Nếu có thể, hãy chạy kiểm thử phát lại đo lường trên thiết bị thực thay vì trình mô phỏng, vì việc triển khai ngăn xếp nội dung nghe nhìn của trình mô phỏng có thể khác nhau. Để biết thêm thông tin về việc hỗ trợ trình mô phỏng, hãy xem hướng dẫn về thiết bị được hỗ trợ.

Thêm các phần phụ thuộc kiểm thử

Thêm cấu phần phần mềm kiểm thử Media3 vào khối dependencies của tệp build.gradle.kts hoặc build.gradle trong mô-đun bằng cách sử dụng cùng phiên bản với các mô-đun Media3 khác:

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"

Hai mô-đun này phân chia trách nhiệm theo môi trường thời gian chạy:

Đảm bảo mô-đun của bạn cũng bao gồm các phần phụ thuộc khung kiểm thử cho môi trường thử nghiệm:

  • Robolectric: org.robolectric:robolectric. Đặt android.testOptions.unitTests.isIncludeAndroidResources = true khi tải thành phần cục bộ.
  • Kiểm thử AndroidX: androidx.test:runner, androidx.test:core và androidx.test.ext:junit.
  • Coroutine của Kotlin trên luồng chính: org.jetbrains.kotlinx:kotlinx-coroutines-android.

Chọn sử dụng các API kiểm thử không ổn định

Các lớp và phương thức trong media3-test-utils và media3-test-utils-robolectric được chú thích bằng @UnstableApi. Mặc dù Media3 hướng đến việc duy trì các trình trợ giúp kiểm thử được sử dụng rộng rãi trong các bản phát hành, nhưng các tiện ích kiểm thử không tuân theo các quy tắc ổn định về khả năng tương thích nhị phân giống như các API phát hành công khai.

Trong nhóm tài nguyên kiểm thử, hãy chú thích các lớp hoặc phương thức kiểm thử bằng @androidx.annotation.OptIn, thêm lựa chọn sử dụng package-info cho các gói kiểm thử hoặc định cấu hình lint.xml cho các tài nguyên kiểm thử. Để biết thêm thông tin, hãy xem ghi chú khắc phục sự cố về tìm lỗi mã nguồn API không ổn định.

Kiểm thử cục bộ bằng Robolectric

Các chương trình chạy thử nghiệm Robolectric cục bộ chạy trên JVM của máy phát triển mà không cần thiết bị thực hoặc trình mô phỏng. Robolectric không cung cấp các phương thức triển khai MediaCodec và giữ cho Looper chính ở trạng thái tạm dừng cho đến khi được bơm rõ ràng, vì vậy, một thực thể ExoPlayer mặc định không thể tự giải mã nội dung nghe nhìn hoặc cung cấp thông tin cập nhật về trạng thái.

Tạo trình phát thử nghiệm và phát nâng cao

Sử dụng TestExoPlayerBuilder để tạo một ExoPlayer được định cấu hình cho các kiểm thử Robolectric. Theo mặc định, TestExoPlayerBuilder sẽ định cấu hình các thành phần sau:

  • FakeClock (isAutoAdvancing = true) tự động chuyển tiếp, điều phối tiến trình thời gian giữa luồng phát trong nền của ExoPlayer và Looper chính bị tạm dừng của Robolectric.
  • Trình kết xuất video và âm thanh giả (FakeVideoRenderer và FakeAudioRenderer) sử dụng các mẫu nội dung nghe nhìn theo dấu thời gian mà không cần bộ giải mã MediaCodec.

Để tiến hành phát và xác nhận kết quả trên luồng chính (với trình phát sử dụng Looper.getMainLooper, mặc định trong Robolectric), hãy sử dụng các phương thức advance(player) hoặc play(player) tĩnh trên TestPlayerRunHelper (play(player) gọi player.play trước khi chờ). Đối với hầu hết các điều kiện, trình trợ giúp sẽ bơm Looper chính của Robolectric cho đến khi đáp ứng điều kiện mục tiêu và gửi một ngoại lệ nếu xảy ra lỗi phát nghiêm trọng hoặc không nghiêm trọng:

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);
  }
}

Trong ví dụ này, asset:///media/mp4/sample.mp4 đề cập đến một tệp nội dung nghe nhìn mẫu được đặt trong thư mục src/test/assets/ (hoặc src/main/assets/) của mô-đun.

Khi được truyền một tệp đối chiếu ExoPlayer, advance(player) và play(player) sẽ trả về một ExoPlayerRunResult (hoặc PlayerRunResult khi được truyền một Player). Các loại kết quả này cung cấp các phương thức để chờ trạng thái phát lại, vị trí, chuyển đổi danh sách phát, khung hình được kết xuất, lệnh đang chờ xử lý và lỗi phát lại dự kiến hoặc bỏ qua lỗi không nghiêm trọng. Để biết thêm thông tin về điều kiện chờ, hãy xem tài liệu tham khảo API TestPlayerRunHelper.

Kiểm thử bằng nguồn giả và bộ mã hoá và giải mã mô phỏng

Nhiều kiểm thử tích hợp xác minh việc điều hướng danh sách phát, lựa chọn bản nhạc, cập nhật dòng thời gian hoặc hành vi thử lại và không cần tệp nội dung nghe nhìn thực. Bạn có thể cung cấp các dòng thời gian và dữ liệu tổng hợp vào trình phát TestExoPlayerBuilder bằng cách sử dụng FakeMediaSource, FakeTimeline, FakeDataSource hoặc WebServerDispatcher (với 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 sử dụng trình kết xuất giả theo mặc định. Nếu chương trình kiểm thử Robolectric của bạn cần tương tác với MediaCodec, hãy định cấu hình bộ mã hoá và giải mã mô phỏng cũng như thu thập kết quả như sau:

  1. Thêm withAllDefaultSupportedCodecs từ ShadowMediaCodecConfig dưới dạng @Rule JUnit để đăng ký bộ mã hoá và giải mã mô phỏng của Robolectric.
  2. Truyền CapturingRenderersFactory được tạo bằng Clock (CapturingRenderersFactory(context, builder.getClock())) của trình tạo đến builder.setRenderersFactory.
  3. Khi kiểm thử các bản video, hãy đính kèm Surface (chẳng hạn như Surface(SurfaceTexture(1))) với player.setVideoSurface để MediaCodecVideoRenderer kết xuất các khung hình đã giải mã thay vì bỏ qua.
  4. Để xác minh kết quả hiển thị, hãy gọi register(player, capturingRenderersFactory) trên PlaybackOutput trước khi bắt đầu phát.
  5. Sau khi phát xong, hãy giải phóng trình phát và nền tảng, sau đó kiểm tra kết quả được định dạng từ Dumper (Dumper().add(playbackOutput).toString()).

Kiểm thử đo lường trên thiết bị

Các hoạt động kiểm thử Robolectric cục bộ không thực thi bộ giải mã nền tảng thực, bản âm thanh, phiên DRM hoặc kết xuất Surface. Để xác minh hành vi phát từ đầu đến cuối trên các thiết bị chạy Android, hãy viết kiểm thử đo lường trong src/androidTest bằng cách sử dụng ExoPlayer thực được tạo bằng ExoPlayer.Builder.

Trong kiểm thử đo lường JUnit, các phương thức @Test chạy trên một luồng đo lường thay vì luồng chính của ứng dụng. Luôn tạo, kiểm soát và phát hành ExoPlayer trên luồng ứng dụng Looper, ví dụ: bằng cách sử dụng runBlocking(Dispatchers.Main) trong Kotlin hoặc InstrumentationRegistry.getInstrumentation().runOnMainSync trong Java.

Chờ trạng thái của người chơi bằng PlayerFence và coroutine

Trong kiểm thử đo lường, Looper chính chạy theo thời gian thực trên thiết bị. Đừng đưa Robolectric hoặc media3-test-utils-robolectric vào các phần phụ thuộc androidTestImplementation. Để chờ trạng thái và sự kiện phát, media3-test-utils cung cấp các trình trợ giúp sau:

  • Kotlin: Tạm ngưng các hàm tiện ích trên Player (chẳng hạn như awaitPlaybackState, awaitIsPlaying, awaitFirstFrameRendered và awaitContentPositionAtLeast).
  • Java: Phương thức futureWhen(player) tĩnh trên PlayerFence, trả về một PlayerFence có các phương thức điều kiện (chẳng hạn như entersPlaybackState và isPlaying) phải được gọi trên luồng Looper của trình phát và trả về một ListenableFuture mà luồng kiểm thử có thể chặn bằng get.

Cả hai API đều giám sát người chơi trong khi chờ đợi. Nếu xảy ra lỗi phát nghiêm trọng (hoặc theo mặc định là lỗi không nghiêm trọng) hoặc nếu thời gian chờ vượt quá thời gian chờ (mặc định là 10 giây hoặc 30 giây trên trình mô phỏng), thì lệnh gọi sẽ không thành công ngay lập tức thay vì bị treo:

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));
  }
}

Khi tạo ExoPlayer bằng ApplicationProvider.getApplicationContext trong một mô-đun ứng dụng (com.android.application), URI asset:/// sẽ phân giải dựa trên các thành phần của ứng dụng mục tiêu (chẳng hạn như src/debug/assets/ hoặc src/main/assets/, thay vì src/androidTest/assets/).

Sự kiện tạm thời và hoạt động giám sát tuỳ chỉnh

Ngoài việc kiểm tra trạng thái hiện tại của trình phát (trong đó các lệnh chờ sẽ trả về ngay lập tức nếu điều kiện đã được đáp ứng), các trình trợ giúp trong media3-test-utils còn hỗ trợ các sự kiện một lần và điều kiện trình nghe tuỳ chỉnh:

  • Sự kiện tạm thời, chẳng hạn như khung hình được kết xuất đầu tiên: Đính kèm đầu ra video (chẳng hạn như Surface hoặc PlayerView) vào trình phát và bắt đầu chờ bằng awaitFirstFrameRendered hoặc rendersFirstFrame trước khi gọi player.prepare để không bỏ lỡ sự kiện khung hình một lần sớm.
  • Điều kiện tuỳ chỉnh: Gói trình nghe tuỳ chỉnh hoặc coroutine chờ bên trong awaitSupervised trên player trong Kotlin hoặc truyền ListenableFuture tuỳ chỉnh đến futureWhen(player).withSupervision(future) trong Java. Khi kiểm thử khả năng khôi phục sau lỗi không nghiêm trọng, hãy truyền failOnNonFatalErrors = false (Kotlin) hoặc gọi ignoringNonFatalErrors (Java) và tuỳ chỉnh thời gian chờ bằng timeout hoặc withTimeoutMs(timeoutMs).

Ví dụ sau đây chờ một sự kiện khung hình đầu tiên tạm thời và giám sát một trình nghe siêu dữ liệu tuỳ chỉnh:

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);
      });
}

Kiểm thử giao diện người dùng và các thành phần của ứng dụng bằng FakePlayer

Khi kiểm thử các thành phần giao diện người dùng như thành phần kết hợp PlayerView hoặc media3-ui-compose, ViewModel hoặc lệnh gọi lại MediaSession, bạn thường không cần tạo ExoPlayer đầy đủ. FakePlayer trong media3-test-utils mở rộng SimpleBasePlayer để mô phỏng danh sách phát, trạng thái phát, vị trí, kích thước video, tín hiệu và lỗi trên Looper của luồng gọi. Looper này tự động có sẵn trong các kiểm thử Robolectric có @RunWith(AndroidJUnit4::class) và trên luồng chính trong kiểm thử đo lường.

Theo mặc định, FakePlayer mô phỏng độ trễ lưu vào vùng đệm 100 mili giây trên prepare và seekTo; truyền bufferingDelayMs = 0 trong Kotlin hoặc đặt trạng thái trực tiếp bằng 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();

Các tiện ích kiểm thử khác

media3-test-utils bao gồm các trình trợ giúp chuyên biệt cho các trường hợp kiểm thử khác: