In the Android world, development is increasingly using unit testing. Checking the correct operation of individual modules of the application helps to identify and eliminate errors in the code at an early stage. Coupled with assembly automation, component and integration tests, unit tests allow you to make a quality product, regardless of the size of your development team.
Under the cut, I’ll tell you about the internal device framework for unit-testing Android applications - Robolectric.
First we will try to answer the question - why test the code in the field of integration with the Android framework?
Resources - it is worth testing the correctness of using certain string or any other application resources, since they are an integral part of business requirements.
Parcelable - regardless of whether you use the automatic generation of Parcelable or write the implementation manually, you should test the correctness of restoring objects from their serialized representation.
SQLite - data migration testing, schema changes, adding new tables, query execution correctness.
Intent / Bundle - for some scenarios it is important to check the correctness of filling the Intent, flags with which the next Activity or Service will be launched.
This is only part of the scenarios in which code testing in the field of integration with Android becomes an urgent task.
When trying to solve this problem head on you may encounter the following problems:
RuntimeException
with the cause - method not mocked
when trying to run a test code of a framework calling some method. And if you use the following option in Gradle -
testOptions { unitTests.returnDefaultValues = true }
then, RuntimeException
will not be thrown. This behavior can lead to heavily detectable errors in the tests.
Another problem of testing are final
classes and a great many static
methods of the framework, which further complicates testing the code that uses it.
For all the above problems, there are certain solutions:
Use primitive testable wrappers over the places where your code integrates with the framework. In your tests, you wash the wrapper and test its interaction with your code. Testing the wrapper in mind its simple implementation omit. Although in reality this wrapper should be tested, but it will remain primitive for a short time. In the end, you’ll get tired of duplicating the implementation of the Android framework for testing. Do not forget about the increase in the number of methods in your APK, to which this approach will lead.
Instrumented unit tests - the most accurate test option. Tests are performed on a real device or emulator in a real environment. But you will have to pay for this by a long compilation, packaging the APK, and slow execution of the tests.
static
methods and final
classes. In this case, you will have to partially repeat the behavior of some Android classes, which can lead to swelling of the code responsible for preparing mocks in your tests and complicate their support in the future.There is another solution to the problem of Unit-testing Android applications - Robolectrics. Robolectric is a framework developed by PivotalLabs in 2010. He is in an intermediate position between the “bare” JUnit tests and instrumented tests run on the device, simulating the real Android environment. The framework is a compiled android.jar with a bundle of utilities for running tests and simplifying testing. It supports resource loading, a primitive view blowing implementation, provides local SQLite ( sqlite4java ), easily customizable and extensible.
Suppose we are developing a library for third-party developers and want to make sure that our library prints some important information in Logcat.
Implement the following interface - Logger
, with one method for displaying messages of the “Info” level.
interface Logger { fun info(tag: String, message: String, throwable: Throwable? = null) }
Let's write the AndroidLogger
implementation - which will use android.util.Log
.
class AndroidLogger: Logger { override fun info(tag: String, message: String, throwable: Throwable?) { Log.i(tag, message, throwable) } }
android.util.Log
Let's write a Junit test using Robolectric and make sure that the info
method of our AndroidLogger
implementation actually prints the messages in Logcat with the info level.
@RunWith(RobolectricTestRunner::class) @Config(constants = BuildConfig::class, sdk = intArrayOf(23)) class RobolectricAndroidLoggerTest { private val logger: Logger = AndroidLogger() @Test fun `info - should log to logcat with info level`() { val throwable = Throwable() logger.info("Tag", "Message", throwable) val logInfo: LogInfo = ShadowLog.getLogs().last() assertThat(logInfo.type, Is(Log.INFO)) assertThat(logInfo.tag, Is("Tag")) assertThat(logInfo.msg, Is("Message")) assertThat(logInfo.throwable, Is(throwable)) } }
Annotation @RunWith
we indicate that we will run the test using RobolectricTestRunner
. In the parameters for the @Config
annotation, we pass the BuildConfig
class and specify the version of the Android SDK that Robolectric will simulate.
In the test, we call the info
method on the AndroidLogger
object. With the help of the ShadowLog
class, ShadowLog
retrieve the last message recorded in the log and make assert by its contents.
The internal structure of the Robolectric can be divided into 3 parts: Shadow classes, RobolectricTestRunner
and InstrumentingClassLoader
.
The creators of Robolectric introduce a new type of “test twins” (test double) - Shadow. According to the official website, the Shadows are “... not quite Proxies, not quite Fakes, not quite Mocks or Stubs."
A shadow object exists parallel to a real object and can intercept calls to methods and constructors, thereby changing the behavior of the real object.
The @Implements
indicates the class for which a particular Shadow class is intended.
@Implements(className = ContextImpl.class) public class ShadowContextImpl { ... }
In the annotation of the @Config
test, you can specify Shadow-classes that are not included in the standard Robolectric package.
@Config(..., shadows = {CustomShadow.class}, ...) public class CustomTest { ... }
The overridden method in the Shadow class is annotated with @Implementation, it is important to preserve the signature of the original method.
@Implementation public Object getSystemService(String name) { ... }
When overriding the native method, the native code word is omitted.
private static native long nativeReadLong(long nativePtr);
@Implementation public static long nativeReadLong(long nativePtr) { return ... }
To override the constructor in the Shadow class, the __constructor__
constructor __constructor__
method is implemented with the same arguments.
public Canvas(@NonNull Bitmap bitmap) { ... }
public void __constructor__(Bitmap bitmap) { this.targetBitmap = bitmap; }
To get a reference to a real object in the Shadow class, it is enough to declare a field with the type of “shaded” object marked with the @RealObject
annotation:
@RealObject private Context realObject;
Robolectric provides the ability to call a real implementation of the method, bypassing the Shadow implementation, using Shadow.directlyOn
.
Shadow.directlyOn(realObject, "android.app.ContextImpl", "getDatabasesDir");
Writing your own Shadow-class is not a big problem, even for a third-party library that is not included in the standard distribution with Android.
Let's write a class that gets a user's token using GoogleAuthUtil
.
class GoogleAuthInteractor { fun getToken(context: Context, account: Account): String { return GoogleAuthUtil.getToken(context, account, null) } }
Implement the Shadow class for GoogleAuthUtil
that allows you to override the token
for a specific Account
:
@Implements(GoogleAuthUtil::class) object ShadowGoogleAuthUtil { private val tokens = ArrayMap<Account, String>() @Implementation @JvmStatic fun getToken(context: Context, account: Account, scope: String?): String { return tokens[account].orEmpty() } fun setToken(account: Account, token: String?) { tokens.put(account, token) } }
Let's write a test for GoogleAuthInteractor
using Robolectric. In the configuration for the test, we indicate that we want to use ShadowGoogleAuthUtil
and ShadowGoogleAuthUtil
classes from the com.google.android.gms.auth
package.
@RunWith(RobolectricTestRunner::class) @Config(shadows = arrayOf(ShadowGoogleAuthUtil::class), instrumentedPackages = arrayOf("com.google.android.gms.auth")) class GoogleAuthInteractorTest { private val context = RuntimeEnvironment.application private val interactor = GoogleAuthInteractor() @Test fun `provide token - provides token for correct account`() { val account = Account("name", "type") ShadowGoogleAuthUtil.setToken(account, "token") val token = interactor.getToken(context, account) assertThat(token, Is("token")) } }
From the Shadow classes, let's move on to RobolectricTestRunner
- this is the first part of the Robolectric that your tests communicate with. Runner is responsible for dynamic dependency loading (Shadow-classes and android.jar for the specified SDK version) during the execution of tests.
Robolectric is configured by the @Config
annotation, with which you can change the parameters of the simulated environment for the test class and for each test separately. The configuration for running tests will be collected sequentially across the entire hierarchy of the test class from parent to heir, and finally to the method being tested itself. Configuration allows you to configure:
Before running the tests, the RobolectricTestRunner
replaces the system ClassLoader
with the InstrumentingClassLoader
.
InstrumentingClassLoader
provides the connection of real objects with Shadow-classes, the substitution of certain classes for fake classes and the proxying of calls to certain methods to Shadow-classes directly.
Robolectric does not tool classes from the java.*
Package, so method calls that are missing in an ordinary JVM, but added to the Android SDK, are proxied directly to Shadow at the call site.
In the framework, there are two options for instructing loadable classes. The original implementation generates a bytecode that uses the internal interface of the ClassHandler
and implements its ShadowWrangler
class, which essentially wraps each method call through a Shadow class into a separate Runnable
similar object and calls it. In April 2015, a second version of the bytecode modification was added to the project, using the JVM instruction invokeDynamic
.
During the instrumentation, Robolectric adds a ShadowedObject
interface to each class loaded with one single method, $$robo$getData()
, in which the real object returns its Shadow.
public interface ShadowedObject { Object $$robo$getData(); }
For each constructor, the InstrumentingClassLoader
creates a private $$robo$$__constructor__
method with preserving its signature and instructions (except for calling super
).
public Size(int width, int height) { super(width, height); ... }
private void $$robo$$__constructor__(int width, int height) { mWidth = width; mHeight = height; }
In turn, the body of the original constructor will consist of:
super
(if the class is an heir)$$robo$init
private method, which initializes the __robo_data__
private field __robo_data__
corresponding Shadow object$$robo$$__constructor__
) on the Shadow object, if the Shadow object exists and the corresponding constructor is redefined, otherwise the real implementation will be called ( $$robo$$__constructor__
).Constructor modified using invokeDynamic
instructions:
public Size(int width, int height) { this.$$robo$init(); InvokeDynamicSupport.bootstrap($$robo$$__constructor__(int int), this, width, height); }
Constructor modified using ClassHandler:
public Size(int width, int height) { this.$$robo$init(); ClassHandler.Plan plan = RobolectricInternals.methodInvoked("android/util/Size/__constructor__(II)V", false, Size.class); if (plan != null) { try { plan.run(this, $$robo$getData(), new Object[]{new Integer(width), new Integer(height)}); return; } catch (Throwable throwable) { throw RobolectricInternals.cleanStackTrace(throwable); } } try { this.$$robo$$__constructor__(width, height); } catch (Throwable throwable) { throw RobolectricInternals.cleanStackTrace(throwable); } }
To instrument the methods, Robolectric uses a similar mechanism, the present method code is allocated to a private method with the $$robo$$
prefix and the method call is delegated to the Shadow object.
Method modified using invokeDynamic
:
public int getWidth() { return (int)InvokeDynamicSupport.bootstrap($$robo$$getWidth(),this); }
For native
methods, Robolectric omits the corresponding modifier and returns the default value if this method is not overridden in the Shadow class.
Robolectric is far from the most productive framework. Running an empty test on the RobolectricTestRunner
takes about 2 seconds. Compared to “clean” JUnit tests, 2 seconds is a significant delay.
Profiling tests on Robolectric shows that the framework spends most of the time instructing loadable classes.
Below are the results of Robolectric profiling and the PowerMock + Mockito bundles for the android.util.Log
test described above.
Robolectric ~ 2400 ms.:
Method | ms |
---|---|
java.lang.ClassLoader.loadClass(String) | 913 |
org.robolectric.internal.bytecode.InstrumentingClassLoader. | 767 |
org.objectweb.asm.tree.ClassNode.accept(ClassVisitor) | 407 |
org.objectweb.asm.tree.MethodNode.accept(ClassVisitor) | 367 |
org.robolectric.internal.bytecode.InstrumentingClassLoader | 298 |
org.objectweb.asm.ClassReader.accept(ClassVisitor, Attribute[], int) | 277 |
org.robolectric.shadows.ShadowResources.getSystem() | 268 |
PowerMock + Mockito ~ 200 ms:
Method | ms |
---|---|
org.powermock.api.extension.proxyframework.ProxyFrameworkImpl.isProxy(Class) | 304 |
org.powermock.api.mockito.repackaged.cglib.core.KeyFactory$Generator | 131 |
sun.launcher.LauncherHelper.checkAndLoadMain(boolean, int, String) | 103 |
javassist.bytecode.MethodInfo.rebuildStackMap(ClassPool) | 85 |
java.lang.Class.getResource(String) | 84 |
org.mockito.internal.MockitoCore.<init>() | 67 |
Currently, there are more than 3000 Unit tests in our project, about half of which use Robolectric.
Faced with the performance issues of the framework, it was decided to use the Robolectric only to test a limited set of cases:
For all other cases, we wrap Android dependencies in easily tested wrappers, or use unmock-plugin for Gradle.
Video with my report on the same topic at the MBLTdev 16 conference
Source: https://habr.com/ru/post/320898/
All Articles