Source Code Analysis of Android Unit Testing Framework (2) Brief Analysis of Robolectric

Keywords: Java Junit Android

In the previous chapter, we briefly analyzed the framework structure and operation principle of Mockito. We can find that although Mockito is an Android testing framework, the implementation method basically does not use Android related libraries. That is to say, I can also use Mockito directly in JAVA unit testing, and it can be used in real time.

But that's the case. If we need to simulate the Android startup environment in the testing process, we need to simulate all the classes related to Android startup, but this is too much work. If a framework has done a good job of simulating the Android environment, we can directly call the Android Library in the framework to use, which undoubtedly saves a lot of time.

Robolectric is the open source Android unit testing framework that was born in this environment. Robolectric itself implements Android startup libraries, such as Application, Acticity, etc. We can start an activity through activityController.create(). In addition, Robolectric also implements controls such as TextView. We can actively operate the control to judge the response for testing.

Here I will not give a detailed introduction to the use of Robolectric, but mainly analyze the implementation of Robolectric framework.

The way Mockito works that I didn't introduce in the previous chapter is mainly because Mockito runs directly following Junit's framework. Robolectric was started by inheriting Junit's Runer. Java class. When we click on the unit test button of Android studio, it is not the run() method of Runner.java class in Robolectric that runs first, but the Junit framework is started through the runtime library in Android studio. The specific operation mode can be printed by acquiring stack elements.

Where you run a unit test, you can add the following statement to print the call flow for the entire unit test

for(StackTraceElement stackTraceElement:Thread.currentThread().getStackTrace())
{
    System.out.println(stackTraceElement.getClassName()+"     "+stackTraceElement.getFileName()+"    "+stackTraceElement.getMethodName());
}


After running the unit test, it shows as follows:

java.lang.Thread     Thread.java    getStackTrace
com.business.RedBizTest     RedBizTest.java    testRedBiz_initData  
//Call test cases
sun.reflect.NativeMethodAccessorImpl     NativeMethodAccessorImpl.java    invoke0
sun.reflect.NativeMethodAccessorImpl     NativeMethodAccessorImpl.java    invoke
sun.reflect.DelegatingMethodAccessorImpl     DelegatingMethodAccessorImpl.java    invoke
java.lang.reflect.Method     Method.java    invoke
org.junit.runners.model.FrameworkMethod$1     FrameworkMethod.java    runReflectiveCall
org.junit.internal.runners.model.ReflectiveCallable     ReflectiveCallable.java    run
org.junit.runners.model.FrameworkMethod     FrameworkMethod.java    invokeExplosively
org.junit.internal.runners.statements.InvokeMethod     InvokeMethod.java    evaluate
org.robolectric.RobolectricTestRunner$HelperTestRunner$1     RobolectricTestRunner.java    evaluate
org.junit.internal.runners.statements.RunBefores     RunBefores.java    evaluate
org.robolectric.RobolectricTestRunner$2     RobolectricTestRunner.java    evaluate
org.robolectric.RobolectricTestRunner     RobolectricTestRunner.java    runChild
org.robolectric.RobolectricTestRunner     RobolectricTestRunner.java    runChild
org.junit.runners.ParentRunner$3     ParentRunner.java    run
org.junit.runners.ParentRunner$1     ParentRunner.java    schedule
org.junit.runners.ParentRunner     ParentRunner.java    runChildren
org.junit.runners.ParentRunner     ParentRunner.java    access$000
org.junit.runners.ParentRunner$2     ParentRunner.java    evaluate
org.robolectric.RobolectricTestRunner$1     RobolectricTestRunner.java    evaluate
//Enter the Robolectric framework
org.junit.runners.ParentRunner     ParentRunner.java    run
org.junit.runner.JUnitCore     JUnitCore.java    run
com.intellij.junit4.JUnit4IdeaTestRunner     JUnit4IdeaTestRunner.java    startRunnerWithArgs
com.intellij.rt.execution.junit.JUnitStarter     JUnitStarter.java    prepareStreamsAndStart
com.intellij.rt.execution.junit.JUnitStarter     JUnitStarter.java    main
sun.reflect.NativeMethodAccessorImpl     NativeMethodAccessorImpl.java    invoke0
sun.reflect.NativeMethodAccessorImpl     NativeMethodAccessorImpl.java    invoke
sun.reflect.DelegatingMethodAccessorImpl     DelegatingMethodAccessorImpl.java    invoke
java.lang.reflect.Method     Method.java    invoke
com.intellij.rt.execution.application.AppMain     AppMain.java    main 
//Call the Android Studio runtime, which is the entry, and the log displays from bottom to top


Android studio first runs the AppMain method in Android Studiolibidea_rt.jar, then gradually calls the evaluate method of RobolectricTestRunner.java, thus calling the Robolectric framework from the Junit framework.

The specific invocation process is as follows

    ParentRunner.run()

    RobolectricTestRunner.classBlock()

    RobolectricTestRunner.runChild()

    RobolectricTestRunner.methodBlock()

These four lines of code basically superficially represent Robolectric's main invocation framework. TestSuit test methods are also collected when classBlock is called. TestSuit tests are distributed to test cases. The method of obtaining test cases is:

   

    /**
     * Returns the methods that run tests. Default implementation returns all
     * methods annotated with {@code @Test} on this class and superclasses that
     * are not overridden.
     */
    protected List<FrameworkMethod> computeTestMethods() {
        return getTestClass().getAnnotatedMethods(Test.class);//Identify test cases through the @Test annotation
    }

The last way to run test cases is methodBlock(), which is a long one. Let's analyze it slowly.

   

return new Statement() {
      @Override
      public void evaluate() throws Throwable {
        // Configure shadows *BEFORE* setting the ClassLoader. This is necessary because
        // creating the ShadowMap loads all ShadowProviders via ServiceLoader and this is
        // not available once we install the Robolectric class loader.
        configureShadows(sdkEnvironment, config);

        Thread.currentThread().setContextClassLoader(sdkEnvironment.getRobolectricClassLoader());

        Class bootstrappedTestClass = sdkEnvironment.bootstrappedClass(getTestClass().getJavaClass());
        HelperTestRunner helperTestRunner = getHelperTestRunner(bootstrappedTestClass);

        final Method bootstrappedMethod;
        try {
          //noinspection unchecked
          bootstrappedMethod = bootstrappedTestClass.getMethod(method.getName());
        } catch (NoSuchMethodException e) {
          throw new RuntimeException(e);
        }

        parallelUniverseInterface = getHooksInterface(sdkEnvironment);
        try {
          try {
            // Only invoke @BeforeClass once per class
            if (!loadedTestClasses.contains(bootstrappedTestClass)) {
              invokeBeforeClass(bootstrappedTestClass);
            }
            assureTestLifecycle(sdkEnvironment);

            parallelUniverseInterface.resetStaticState(config);
            parallelUniverseInterface.setSdkConfig(sdkEnvironment.getSdkConfig());

            int sdkVersion = pickSdkVersion(config, appManifest);
            ReflectionHelpers.setStaticField(sdkEnvironment.bootstrappedClass(Build.VERSION.class),
                "SDK_INT", sdkVersion);
            SdkConfig sdkConfig = new SdkConfig(sdkVersion);
            ReflectionHelpers.setStaticField(sdkEnvironment.bootstrappedClass(Build.VERSION.class),
                "RELEASE", sdkConfig.getAndroidVersion());

            ResourceLoader systemResourceLoader = sdkEnvironment.getSystemResourceLoader(getJarResolver());
            setUpApplicationState(bootstrappedMethod, parallelUniverseInterface, systemResourceLoader, appManifest, config);
            testLifecycle.beforeTest(bootstrappedMethod);
          } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
          }

          final Statement statement = helperTestRunner.methodBlock(new FrameworkMethod(bootstrappedMethod));

          // todo: this try/finally probably isn't right -- should mimic RunAfters? [xw]
          try {
            statement.evaluate();
          } finally {
            try {
              parallelUniverseInterface.tearDownApplication();
            } finally {
              try {
                internalAfterTest(bootstrappedMethod);
              } finally {
                parallelUniverseInterface.resetStaticState(config); // afterward too, so stuff doesn't hold on to classes?
                // todo: is this really needed?
                Thread.currentThread().setContextClassLoader(RobolectricTestRunner.class.getClassLoader());
              }
            }
          }
        } finally {
          parallelUniverseInterface = null;
        }
      }
    };


You can see an interesting object, Parallel Universe Interface, literally called Parallel World Interface, which is actually the Android Environment Interface.
Inside this object is a Runtime Environment object that collects the relevant parameters for testing App, such as the Sdkconfig parameter passed in, to get the Android Manifest parameter.

These are easy to understand, but there is one area where there may be some doubt about how simulated classes are loaded into the running environment together with non-simulated classes. If you are familiar with Robolectric, you may find that Robolectric implements many Android controls internally. These implementation classes are similar to ShadowTextView, ShadowButton and so on. Similarly, if we want to simulate a class, such as I want to implement a simulation class of Time, to realize that Time returns when calling getCurrentTime, not the current time, but the other one. At a specified time, I will do so.

@Implements(Time.class)
public class ShadowTime
{
    @RealObject
    private Time _timeRealObject;

    private static Time _time;

    @Implementation
    public void setToNow()
    {
        _timeRealObject.set(_time);
    }

    public static void mockTime(Time time)
    {
        _time =time;
    }
}

This enables you to call mockTime at runtime to specify setToNow() setting time. But this class has only one annotation that indicates its relationship with the actual class Time.java. We don't know how the specific class Time is modified through this class.

To solve these problems, we still need to analyze the runChild() method in the above four lines of code.

  @Override
  protected void runChild(FrameworkMethod method, RunNotifier notifier) {
    Description description = describeChild(method);
    EachTestNotifier eachNotifier = new EachTestNotifier(notifier, description);

    final Config config = getConfig(method.getMethod());
    if (shouldIgnore(method, config)) {
      eachNotifier.fireTestIgnored();
    } else if(shouldRunApiVersion(config)) {
      eachNotifier.fireTestStarted();
      try {
        AndroidManifest appManifest = getAppManifest(config);
        InstrumentingClassLoaderFactory instrumentingClassLoaderFactory = new InstrumentingClassLoaderFactory(createClassLoaderConfig(config), getJarResolver());
        SdkEnvironment sdkEnvironment = instrumentingClassLoaderFactory.getSdkEnvironment(new SdkConfig(pickSdkVersion(config, appManifest)));
        methodBlock(method, config, appManifest, sdkEnvironment).evaluate();
      } catch (AssumptionViolatedException e) {
        eachNotifier.addFailedAssumption(e);
      } catch (Throwable e) {
        eachNotifier.addFailure(e);
      } finally {
        eachNotifier.fireTestFinished();
      }
    }
  }
In this broken code, you can see that the SdkEnvironment class is passed into a config object and the specific SdkEnvironment implementation code is found:

   

public synchronized SdkEnvironment getSdkEnvironment(SdkConfig sdkConfig) {

    Pair<InstrumentationConfiguration, SdkConfig> key = Pair.create(instrumentationConfig, sdkConfig);

    SdkEnvironment sdkEnvironment = sdkToEnvironment.get(key);
    if (sdkEnvironment == null) {
      URL[] urls = dependencyResolver.getLocalArtifactUrls(
          sdkConfig.getAndroidSdkDependency(),
          sdkConfig.getCoreShadowsDependency());

      ClassLoader robolectricClassLoader = new InstrumentingClassLoader(instrumentationConfig, urls);
      sdkEnvironment = new SdkEnvironment(sdkConfig, robolectricClassLoader);
      sdkToEnvironment.put(key, sdkEnvironment);
    }
    return sdkEnvironment;
  }
}
Implementing SdkEnvironment and implementing a Instrumenting Class Loader custom class loader, find the implementation part of this class loader, we will be open-minded, look at the findclass method

    

@Override
  protected Class<?> findClass(final String className) throws ClassNotFoundException {
    if (config.shouldAcquire(className)) {
      final byte[] origClassBytes = getByteCode(className);

      ClassNode classNode = new ClassNode(Opcodes.ASM4) {
        @Override
        public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
          desc = remapParamType(desc);
          return super.visitField(access, name, desc, signature, value);
        }

        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
          MethodVisitor methodVisitor = super.visitMethod(access, name, remapParams(desc), signature, exceptions);
          return new JSRInlinerAdapter(methodVisitor, access, name, desc, signature, exceptions);
        }
      };

      final ClassReader classReader = new ClassReader(origClassBytes);
      classReader.accept(classNode, 0);

      classNode.interfaces.add(Type.getInternalName(ShadowedObject.class));

      try {
        byte[] bytes;
        ClassInfo classInfo = new ClassInfo(className, classNode);
        if (config.shouldInstrument(classInfo)) {
          bytes = getInstrumentedBytes(classNode, config.containsStubs(classInfo));
        } else {
          bytes = origClassBytes;
        }
        ensurePackage(className);
        return defineClass(className, bytes, 0, bytes.length);
      } catch (Exception e) {
        throw new ClassNotFoundException("couldn't load " + className, e);
      } catch (OutOfMemoryError e) {
        System.err.println("[ERROR] couldn't load " + className + " in " + this);
        throw e;
      }
    } else {
      throw new IllegalStateException("how did we get here? " + className);
    }
  }

The config method holds classes that we need to emulate and which we don't need to pay attention to, including both the classes we specify and the default classes of the framework. After judging these objects, dynamic bytecode modification is made to these classes which need to be simulated through ASM framework, so that both non-simulated classes and simulated classes can exist at the same time.

   

Finally, the overall operation steps of Robolectric are summarized.

1. Specify SdkConfig in TestSuit and custom Shadow in SdkConfig

2. Run the test case and call the run() method of the chain to RobolectricTestRunner

3. The run() method will analyze the number of test forces through the @Test annotation, and then run each test case through reflection.

4. When running each test case, the method needed to load the test case will be loaded through a custom class loader.

5. The class loader analyses the Shadow class in SdkConfig if the class to be loaded is the class specified in SdkConfig.
Then the bytecode is dynamically modified by ASM, so that the modification operation of Shadow class is applied to the actual class.

   

Posted by TNIDBMNG on Sat, 30 Mar 2019 22:15:30 -0700