Search code examples
androidandroid-sourcerobolectric

Robolectric can't find new changes to Framework


I've added a new method to a system service in Android 9

There is a system library, which provides an API for developers and uses modified framework. Library works OK and successfully accesses the new method.

I want to test the library using Robolectric.

Declaration

#########################
# Robolectric test target
#########################

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

LOCAL_SRC_FILES := $(call all-java-files-under, src)

LOCAL_STATIC_JAVA_LIBRARIES := \
    android-support-test \
    mockito-robolectric-prebuilt \
    truth-prebuilt

LOCAL_JAVA_LIBRARIES := \
    junit \
    platform-robolectric-3.6.1-prebuilt

LOCAL_JAVA_LIBRARIES += com.mars.thelibrary
LOCAL_INSTRUMENTATION_FOR := TheService
LOCAL_MODULE := TheServiceRoboTests
LOCAL_MODULE_TAGS := optional

include $(BUILD_STATIC_JAVA_LIBRARY)

###########################
# Robolectric runner target
###########################
include $(CLEAR_VARS)

LOCAL_MODULE := RunTheServiceRoboTests
LOCAL_SDK_VERSION := current
LOCAL_STATIC_JAVA_LIBRARIES := TheServiceRoboTests
LOCAL_TEST_PACKAGE := TheService
LOCAL_JAVA_LIBRARIES += com.mars.thelibrary

include prebuilts/misc/common/robolectric/3.6.1/run_robotests.mk

Test

// TestConfig.java
public class TestConfig {
    public static final int SDK_VERSION = 26;
    public static final String MANIFEST_PATH = "TheAwesomeLibrary/tests/robotests/AndroidManifest.xml";
}

// PackageUtilitiesTest.java
@RunWith(RobolectricTestRunner.class)
@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
public class PackageUtilitiesTest {

    private Installer installer; // Modified System Service
    private PackageUtilities utilities; // Library Class

    @Before
    public void setUp() throws InstallerException {
        installer = mock(Installer.class);
        utilities = new PackageUtilities(installer);
    }

    @Test
    public void disableApp_shouldSuspendCache() throws InstallerException {
        // Prepare
        doNothing()
            .when(installer)
            .suspendCache(anyString(), anyInt());

        // Do
        utilities.disableApp("com.mars.betrayer", true);

        // Check
        verify(installer, times(1)).suspendCache(anyString(), anyInt());
    }
}

On Prepare stage it fails with an error

java.lang.NoSuchMethodError: com.android.server.pm.Insteller.suspendCache(Ljava/lang/String;I)V

But I'm pretty sure that the method exists, because the library works correctly on a device.

In all other Robolectric tests the library works with unmodified framework parts, so all other tests work and pass as expected.

How to write robolectric tests properly? How to make them work with the modified framework?


Solution

  • tl;dr

    Two reasons:

    1. Robolectric from prebuilts
    2. SDK version is not current

    For now, sdk version should be the same as the platform's version. E.g. for Android 9:

    @Config(sdk = Build.VERSION_CODES.P)
    

    (still relevant for android-12.0.0_r4)

    BUT

    This won't work, and you'll see a new error:

    java.lang.UnsupportedOperationException: Robolectric does not support API level 28.
    

    To avoid that, you have to use robolectric from external/robolectric-shadows rather than prebuilts/misc/common/robolectric Change

    include prebuilts/misc/common/robolectric/3.6.1/run_robotests.mk
    

    To

    include external/robolectric-shadows/run_robotests.mk
    

    And change dependencies like this (mind the LOCAL_JAVA_LIBRARIES declarations):

    #########################
    # Robolectric test target
    #########################
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    
    LOCAL_SRC_FILES := $(call all-java-files-under, src)
    
    LOCAL_JAVA_LIBRARIES := \
        mockito-robolectric-prebuilt \
        junit \
        Robolectric_all-target \
    
    LOCAL_INSTRUMENTATION_FOR := TheService
    LOCAL_MODULE := TheServiceRoboTests
    LOCAL_MODULE_TAGS := optional
    
    include $(BUILD_STATIC_JAVA_LIBRARY)
    
    ###########################
    # Robolectric runner target
    ###########################
    include $(CLEAR_VARS)
    
    LOCAL_MODULE := RunTheServiceRoboTests
    LOCAL_SDK_VERSION := current
    LOCAL_STATIC_JAVA_LIBRARIES := TheServiceRoboTests
    LOCAL_TEST_PACKAGE := TheService
    
    LOCAL_JAVA_LIBRARIES := \
        TheServiceRoboTests \
        guava \
        Robolectric_all-target \
        robolectric_android-all-stub \
        mockito-robolectric-prebuilt \
    
    include external/robolectric-shadows/run_robotests.mk
    

    In future there will be another way, which will help not to specify the exact version every release anymore:

    @Config(sdk = Build.VERSION_CODES.CUR_DEVELOPMENT)
    

    See 1819020: Makes a special entry for tree-built Robolectric


    How does it work

    When you set the SDK version @Config(sdk = 26) Then robolectric will try to find it in a map of supported SDKs in robolectric: SdkConfig.java

    new HashMap<Integer, SdkVersion>() {
    {
      addSdk(Build.VERSION_CODES.JELLY_BEAN, "4.1.2_r1", "r1", "REL");
      addSdk(Build.VERSION_CODES.JELLY_BEAN_MR1, "4.2.2_r1.2", "r1", "REL");
      addSdk(Build.VERSION_CODES.JELLY_BEAN_MR2, "4.3_r2", "r1", "REL");
      addSdk(Build.VERSION_CODES.KITKAT, "4.4_r1", "r2", "REL");
      addSdk(Build.VERSION_CODES.LOLLIPOP, "5.0.2_r3", "r0", "REL");
      addSdk(Build.VERSION_CODES.LOLLIPOP_MR1, "5.1.1_r9", "r2", "REL");
      addSdk(Build.VERSION_CODES.M, "6.0.1_r3", "r1", "REL");
      addSdk(Build.VERSION_CODES.N, "7.0.0_r1", "r1", "REL");
      addSdk(Build.VERSION_CODES.N_MR1, "7.1.0_r7", "r1", "REL");
      addSdk(Build.VERSION_CODES.O, "8.0.0_r4", "r1", "REL");
      addSdk(Build.VERSION_CODES.O_MR1, "8.1.0", "4611349", "REL");
      addSdk(Build.VERSION_CODES.P, "9", "4913185-2", "REL");
      addSdk(Build.VERSION_CODES.Q, "10", "5803371", "REL");
      // BEGIN-INTERNAL
      // TODO: Update jar with final R release.
      addSdk(Build.VERSION_CODES.R, "R-beta2", "6625208", "REL");
      addSdk(Build.VERSION_CODES.S, "S", "r0", "S");
      // END-INTERNAL
    }
    

    In AOSP, Robolectric loads the Framework as a .jar file. When Runner is starting the test, it then looks up for an appropriate supported jar. It matches 28 with Build.VERSION_CODES.O, takes the Sdk(Build.VERSION_CODES.O, "8.0.0_r4", "r1", "REL"), and uses it to build a name of the jar

    public DependencyJar getAndroidSdkDependency() {
        return createDependency("org.robolectric", "android-all", getSdkVersion().getAndroidVersion() + "-robolectric-" + getSdkVersion().getRobolectricVersion(), null);
    }
    

    For example, for Android 8 (26; Build.VERSION_CODES.O) it will try to use android-all-8.0.0_r4-robolectric-r1.jar of already released android version.

    So, when the test is launched by run_robotests.mk, here it copies all existing jar files from prebuilts/misc/common/robolectric/android-all and adds it to its place inside the out/ directory for future usage.

    This jar is already have all classes and methods, and it won't contain anything new

    To get all latest changes that you've made to the framework, you have to pass your current sdk verion. If your AOSP is Android 12, then you have to declare your test like this

    @Config(sdk = Build.VERSION_CODES.S)
    

    When it gets the latest version, it then uses Sdk(Build.VERSION_CODES.S, "S", "r0", "S");, searches for android-all-s-robolectric-r0.jar, copies and uses it.

    Unlike other versions, this jar will be built from the current sources tree, and thus the new changes will be seen in Robolectric tests.