Skip to content

ExoPlayer no longer Mockable. Unit test passes with 1.8.0 but fails with 1.9.0 #2985

Closed

Description

@blundell

Version

Media3 1.9.0

This jnm junit test fails when I update from 1.8.0 to 1.9.0:

import androidx.media3.exoplayer.ExoPlayer
import io.mockk.mockk
import org.junit.Test

/**
 * Test to demonstrate MockK compatibility issue with Media3 1.9.0
 *
 * PASSES with Media3 1.8.0
 * FAILS with Media3 1.9.0
 */
class MockKMedia3IssueTest {

    @Test
    fun testExoPlayerMocking() {
        val mock = mockk<ExoPlayer>()
        println("Mock created successfully: ${mock::class.java}")
    }
}

The stack trace:

java.lang.ExceptionInInitializerError
	at jdk.internal.reflect.GeneratedSerializationConstructorAccessor16.newInstance(Unknown Source)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486)
	at org.objenesis.instantiator.sun.SunReflectionFactoryInstantiator.newInstance(SunReflectionFactoryInstantiator.java:48)
	at io.mockk.proxy.jvm.ObjenesisInstantiator.instanceViaObjenesis(ObjenesisInstantiator.kt:76)
	at io.mockk.proxy.jvm.ObjenesisInstantiator.instantiateViaProxy(ObjenesisInstantiator.kt:67)
	at io.mockk.proxy.jvm.ObjenesisInstantiator.instance(ObjenesisInstantiator.kt:30)
	at io.mockk.proxy.jvm.ProxyMaker.instantiate(ProxyMaker.kt:77)
	at io.mockk.proxy.jvm.ProxyMaker.proxy(ProxyMaker.kt:46)
	at io.mockk.impl.instantiation.JvmMockFactory.newProxy(JvmMockFactory.kt:34)
	at io.mockk.impl.instantiation.AbstractMockFactory.newProxy$default(AbstractMockFactory.kt:24)
	at io.mockk.impl.instantiation.AbstractMockFactory.mockk(AbstractMockFactory.kt:59)
	at com.roku.mobile.videoplayer.impl.MockKMedia3IssueTest.testExoPlayerMocking(MockKMedia3IssueTest.kt:39)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
Caused by: java.lang.NullPointerException: Cannot invoke "String.length()" because "string" is null
	at com.google.common.base.Ascii.toLowerCase(Ascii.java:409)
	at androidx.media3.common.util.Util.isRunningOnEmulator(Util.java:474)
	at androidx.media3.exoplayer.ExoPlayer.<clinit>(ExoPlayer.java:1283)
	... 14 more

Devices that reproduce the issue

JVM Junit Tests

Devices that do not reproduce the issue

No response

Reproducible in the demo app?

Yes

Reproduction steps

Run the above test

Expected result

The test passes

Actual result

The test fails

Media

n/a

Bug Report

Activity

  1. blundell commented on Jan 5, 2026

    @blundell
    Author

    Seems like the line:

      @UnstableApi
      int DEFAULT_STUCK_PLAYING_DETECTION_TIMEOUT_MS = isRunningOnEmulator() ? 30_000 : 10_000;
    

    Was added to media3-exoplayer-1.9.0-sources.jar!/androidx/media3/exoplayer/ExoPlayer.java

    ( It does not exist in media3-exoplayer-1.8.0-sources.jar!/androidx/media3/exoplayer/ExoPlayer.java. )

    and isRunningOnEmulator() makes the assumption of its either going to run on an Emulator or a Real Android Device, never as a JVM unit test:

    public static boolean isRunningOnEmulator() {
        String deviceName = Ascii.toLowerCase(Build.DEVICE);
        return deviceName.contains("emulator")
            || deviceName.contains("emu64a")
            || deviceName.contains("emu64x")
            || deviceName.contains("generic");
      }
    

    It could be fixed by returning false if running on a JVM.

    Your choice of:

    Option 1: Null Check (Simplest)

      public static boolean isRunningOnEmulator() {
          String deviceName = Build.DEVICE;
          if (deviceName == null) {
              return false; // JVM tests aren't emulators
          }
          deviceName = Ascii.toLowerCase(deviceName);
          return deviceName.contains("emulator")
              || deviceName.contains("emu64a")
              || deviceName.contains("emu64x")
              || deviceName.contains("generic");
      }
    

    Option 2: Try-Catch (More Defensive)

      public static boolean isRunningOnEmulator() {
          try {
              String deviceName = Ascii.toLowerCase(Build.DEVICE);
              return deviceName.contains("emulator")
                  || deviceName.contains("emu64a")
                  || deviceName.contains("emu64x")
                  || deviceName.contains("generic");
          } catch (Exception e) {
              // JVM tests or other environments without Android framework
              return false;
          }
      }
    

    Option 3: Check Android Environment First

      public static boolean isRunningOnEmulator() {
          // Check if we're running in Android environment
          if (!isAndroidEnvironment()) {
              return false;
          }
    
          String deviceName = Ascii.toLowerCase(Build.DEVICE);
          return deviceName.contains("emulator")
              || deviceName.contains("emu64a")
              || deviceName.contains("emu64x")
              || deviceName.contains("generic");
      }
    
      private static boolean isAndroidEnvironment() {
          try {
              Class.forName("android.os.Build");
              return Build.DEVICE != null;
          } catch (ClassNotFoundException | NoClassDefFoundError) {
              return false;
          }
      }
    
  2. changed the title [-]Unit test passes with 1.8.0 but fails with 1.9.0[/-] [+]ExoPlayer no longer Mockable. Unit test passes with 1.8.0 but fails with 1.9.0[/+] on Jan 5, 2026
  3. marcbaechinger commented on Jan 5, 2026

    @marcbaechinger
    Contributor

    Thanks for your report.

    String deviceName = Ascii.toLowerCase(Build.DEVICE);
    

    From the stack trace it says that Build.DEVICE is null.

    I think the tests should actually run with Roboletcric because ExoPlayer is designed to run on Android. I don't think it can be expected this works on any JVM/Platform.

  4. icbaker commented on Jan 5, 2026

    @icbaker
    Collaborator

    Build.DEVICE is guaranteed to be non-null via CTS, so I agree with @marcbaechinger that assuming it is non-null is reasonable in a test and you should be running tests either in Robolectric, on an emulator/real device, or some other 'realistic enough' Android environment (which shouldn't violate CTS guarantees).


    Also: Please don't mock ExoPlayer (or any Player implementation). You almost certainly have not correctly implemented the enormous & fiddly Player implementation correctly in your mock - which means your tests are not realistic, and therefore likely fairly useless for catching issues before they occur in production.

    Please either use:

    • A real ExoPlayer with a fake clock and TestPlayerRunHelper
      • This is the most realistic option - you can see lots of examples in our code base.
    • If you need to create specific scenarios that are hard to recreate with a real player (e.g. errors or edge cases) you can use FakePlayer
  5. blundell commented on Jan 5, 2026

    @blundell
    Author

    Thanks for your report.

    String deviceName = Ascii.toLowerCase(Build.DEVICE);
    

    From the stack trace it says that Build.DEVICE is null.

    I think the tests should actually run with Roboletcric because ExoPlayer is designed to run on Android. I don't think it can be expected this works on any JVM/Platform.

    Thanks for the quick response, Marc. I completely understand that ExoPlayer is fundamentally an Android library.

    However, I wanted to raise that this specific change seems to conflict with the broader Android testing evolution that the platform team has been driving. The issue isn't that we expect full JVM compatibility, but rather that this breaks existing unit test patterns that were working fine with Media3 1.8.0.

    The community migration away from Robolectric-first testing has been significant - our CI runs thousands of JUnit tests in seconds vs the minutes Robolectric would require. While we certainly use Robolectric for integration tests, this particular case seems like it could be addressed with a simple defensive check.

    Since isRunningOnEmulator() is marked @UnstableApi, would you consider a null-safe implementation?

      String deviceName = Build.DEVICE;
      if (deviceName == null) return false; // Not Android environment
      deviceName = Ascii.toLowerCase(deviceName);

    This preserves the intended functionality while maintaining compatibility with existing test suites. I recognize you have deep insight into the Android ecosystem's direction - just wanted to share the impact we're seeing.

    References (in case helpful for the cause):
    - Official guide prioritizing JUnit over Robolectric https://developer.android.com/training/testing/local-tests
    - Where Google acknowledged the need for faster unit tests https://www.youtube.com/watch?v=4m2yYSTdvIg
    - Multiple posts about moving away from Robolectric-first approach https://android-developers.googleblog.com/search/label/testing

    Thanks for considering it!

  6. icbaker commented on Jan 5, 2026

    @icbaker
    Collaborator

    Please can you quote the part of this page that discourages Robolectric?

    The page seems to be encouraging JVM tests over instrumentation ones. Robolectric tests run on the JVM (rather than a real/emulated android device). So I read the page as encouraging Robolectric.

    This link has no results, it just says No results for "testing" (which starts to make me suspect it was hallucinated by an LLM).

    Where Google acknowledged the need for faster unit tests https://www.youtube.com/watch?v=4m2yYSTdvIg

    I didn't watch this video. Please give me a timestamp (since I'm slightly suspicious of these citations at this point).

  7. blundell commented on Jan 5, 2026

    @blundell
    Author

    @icbaker you know what, they where lazy references (thanks AI!). Sorry about that.

    Please can you quote the part of this page that discourages Robolectric?

    It's not that Robolectric is discouraged. It's that JVM unit testing should be possible without the Android framework getting in the way. (such as the creation of unitTests.returnDefaultValues from the reference https://developer.android.com/training/testing/local-tests#error)

    The YT video explains the testing pyramid and testing cycle, it doesn't explicitly call out moving away from Robolectic, just calls out fast feedback and the fastest tests you can write are better.

    We have a suite of integration tests, we use Espresso for on device acceptance tests also.

    This report was giving the simplest reproducer possible just to show the reproduction, not to show what I am testing, or the range or ways to test.

    The actual case is analytics/performance monitoring where we get certain fields off of ExoPlayer and manipulate them. Therefore these tests are not about ExoPlayer itself and thats why I only wanted to mock the ExoPlayer interface.

    I'm only talking from my own memories of historical Android that this used to happen a lot more often, and Android was very very hard to unit test and we had to use Robolectric. Now we don't have to use Robolectic but I got the same surprise when going to mock ExoPlayer.

    The solution can just be the same as it used to be, I wrap ExoPlayer in something I control so I don't have to worry about it. (The alternative to using Robolectric.)

    But I just wanted to raise it because I don't believe it's good practice to have expectations of static fields existence, and unmockable static initializers, I thought it was a good opportunity for change, being an unstable api.

    lol:

    Image

    🙏

  8. Pirksy commented on Jan 5, 2026

    @Pirksy

    Hi @blundell , just put

    mockkStatic(Util::class)
    every { Util.isRunningOnEmulator() } returns true
    

    before
    val mock = mockk<ExoPlayer>()

  9. icbaker commented on Jan 5, 2026

    @icbaker
    Collaborator

    Thanks for the extra detail - I can see how tests with only incidental use of ExoPlayer and similar interfaces can benefit from avoiding the bloat of the full Robolectric. I wasn't really aware of this bit of magic:

    When you execute local unit tests, the Android Gradle Plug-in includes a library that contains all the APIs of the Android framework, correct to the version used in your project. The library holds all the public methods and classes of those APIs, but the code inside the methods has been removed. If any of the methods are accessed, the test throws an exception.

    Since nearly all our tests do require Robolectric as we're that bit closer to the platform - I assumed that Android stubs were always provided as part of using Robolectric.


    I discussed this a bit with Marc. Neither of us like adding a null check for Build.VERSION that should "never be null" - since it pollutes the production code with 'unecessary' checks that only exist due to testing limitations.

    I have instead taken a different approach: Move these constants from ExoPlayer to ExoPlayer.Builder. I believe this is where they belong anyway, because they are default values used if you don't call a setter on the builder (and this is supported by them only being used from inside ExoPlayer.Builder). This means they are no longer resolved when mocking ExoPlayer (since the ExoPlayer.Builder class is not loaded) and so mocking works again. I've sent this change for internal review, including a test that checks ExoPlayer (and Player) can be mocked without using Robolectric - so we shouldn't backslide on this in future.

    Since this change will break the compilation of anyone using the ExoPlayer.DEFAULT_STUCK_PLAYING_DETECTION_TIMEOUT_MS constants we won't include it in a 1.9.1 bugfix but in 1.10.0 instead.

  10. blundell commented on Jan 5, 2026

    @blundell
    Author

    Great, thanks for listening and thinking it through 💯


    An unrelated feature enhancement could be to somehow expose isRunningOnEmulator in the ExoPlayer builder, or as an injectable, so that clients could pass a flag/class that could override the library code for figuring it out.

    I saw Pirsky's response, which would also be a valid work around, but we don't want to mockStatic's as they can bleed into other tests and its also mocking internals (which can change).

  11. locked and limited conversation to collaborators on Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions