테스트

Media3는 로컬 JVM 테스트와 기기 계측 테스트 모두에서 미디어 재생, 플레이어 상태 전환, UI 구성요소, 맞춤 미디어 소스를 테스트하는 데 도움이 되는 전용 테스트 아티팩트(media3-test-utils 및 media3-test-utils-robolectric)를 제공합니다.

이 페이지에서는 ExoPlayer를 사용하는 앱을 테스트하기 위한 권장사항을 간략하게 설명하고 각 테스트 환경에서 Media3의 테스트 유틸리티를 사용하는 방법을 보여줍니다.

테스트 권장사항

미디어 재생을 위한 자동 테스트를 작성할 때는 다음 핵심 원칙을 따르세요.

  • 모의 객체보다 실제 구성요소 또는 Media3 가짜 객체 선호: Mockito와 같은 프레임워크를 사용하여 Player 또는 ExoPlayer를 모의하지 마세요. Player에는 모의 객체가 적용하지 않는 상태 불변성과 리스너 계약이 있으므로 테스트가 취약해지거나 버그가 숨겨질 수 있습니다. 대신 실제 ExoPlayer 인스턴스, FakePlayer 또는 가짜(예: FakeMediaSource 및 FakeDataSource)를 사용합니다. 필요한 경우 커스텀 콜백 호출을 확인하기 위해 모의를 예약합니다.
  • 절전 모드 대신 결정론적으로 대기: ExoPlayer는 내부 스레드에서 미디어 로드 및 재생을 비동기식으로 실행합니다. 임의의 Thread.sleep 호출은 테스트를 느리고 불안정하게 만들므로 피하세요. Robolectric 테스트에서 TestPlayerRunHelper를 사용하고 계측 테스트에서 Player 확장 프로그램 또는 PlayerFence를 정지합니다. 이러한 도우미는 명시적인 플레이어 조건을 기다리며 재생 오류가 발생하면 즉시 실패합니다.
  • 애플리케이션 스레드에서 플레이어에 액세스: ExoPlayer 인스턴스 및 대기 중인 도우미는 플레이어의 애플리케이션 Looper 스레드(일반적으로 기본 스레드)에서 호출해야 합니다. 자세한 내용은 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 테스트: 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 린트 문제 해결 참고사항을 참고하세요.

Robolectric을 사용한 로컬 테스트

로컬 Robolectric 테스트 실행은 실제 기기나 에뮬레이터 없이 개발 머신의 JVM에서 실행됩니다. Robolectric은 MediaCodec 구현을 제공하지 않으며 명시적으로 펌핑될 때까지 기본 Looper를 일시중지된 상태로 유지하므로 기본 ExoPlayer 인스턴스는 미디어를 디코딩하거나 상태 업데이트를 자체적으로 전송할 수 없습니다.

테스트 플레이어 빌드 및 재생 진행

TestExoPlayerBuilder를 사용하여 Robolectric 테스트용으로 구성된 ExoPlayer를 만듭니다. 기본적으로 TestExoPlayerBuilder은 다음 구성요소를 구성합니다.

  • ExoPlayer의 백그라운드 재생 스레드와 Robolectric의 일시중지된 기본 Looper 간의 시간 진행을 조정하는 자동 진행 FakeClock(isAutoAdvancing = true)입니다.
  • MediaCodec 디코더 없이 타임스탬프에 따라 미디어 샘플을 사용하는 가짜 동영상 및 오디오 렌더러(FakeVideoRenderer 및 FakeAudioRenderer)

기본 스레드에서 재생을 진행하고 결과를 어설션하려면(Robolectric의 기본값인 Looper.getMainLooper를 사용하는 플레이어 사용) 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)
  }
}

자바

@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()

자바

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. 동영상 트랙을 테스트할 때 MediaCodecVideoRenderer가 디코딩된 프레임을 건너뛰지 않고 렌더링하도록 player.setVideoSurface를 사용하여 Surface(예: Surface(SurfaceTexture(1)))를 연결합니다.
  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)
    }
}

자바

@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/androidTest/assets/가 아닌 src/debug/assets/ 또는 src/main/assets/)에 대해 확인됩니다.

일시적인 이벤트 및 맞춤 감독

media3-test-utils의 도우미는 현재 플레이어 상태를 확인하는 것 외에도(조건이 이미 충족된 경우 대기가 즉시 반환됨) 일회성 이벤트와 맞춤 리스너 조건을 지원합니다.

  • 첫 번째 렌더링된 프레임과 같은 일시적인 이벤트: 동영상 출력(예: Surface 또는 PlayerView)을 플레이어에 연결하고 player.prepare를 호출하기 전에 awaitFirstFrameRendered 또는 rendersFirstFrame로 대기를 시작하여 초기 일회성 프레임 이벤트를 놓치지 않도록 합니다.
  • 커스텀 조건: Kotlin의 player에서 awaitSupervised 내에 커스텀 리스너 또는 코루틴 대기를 래핑하거나 Java의 futureWhen(player).withSupervision(future)에 커스텀 ListenableFuture를 전달합니다. 심각하지 않은 오류로부터의 복구를 테스트할 때는 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)
  }
}

자바

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는 @RunWith(AndroidJUnit4::class)를 사용하는 Robolectric 테스트와 계측 테스트의 기본 스레드에서 자동으로 사용할 수 있습니다.

기본적으로 FakePlayer는 prepare 및 seekTo에서 100ms 버퍼링 지연을 시뮬레이션합니다. 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()

자바

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에는 다른 테스트 시나리오를 위한 특수 도우미가 포함되어 있습니다.

  • 기능 플래그 및 카나리아 모드: Media3FlagsRule를 @BindFlag 및 @BindCanaryMode와 함께 사용하여 각 테스트 후 플래그 재정의를 재설정하고 플래그 상태 전반에서 매개변수화된 테스트를 실행합니다. 자세한 내용은 기능 플래그로 테스트를 참고하세요.
  • 맞춤 DataSource 계약 테스트: DataSourceContractTest를 서브클래스로 분류하여 맞춤 DataSource 또는 HttpDataSource 구현이 범위 요청, 오류 처리, 전송 리스너 전반에서 Media3의 계약 기대치를 준수하는지 확인합니다. 자세한 내용은 다른 네트워크 스택 통합을 참고하세요.