Android UnitTest Technique Summary

2021, Oct 20    

The concept of unit and ui automation testing is referred from android jetpack unit testing. By comparing the list of popular Android testing frameworks from awesome-android-testing, the following testing frameworks are shortlisted and recommended to be adopted in the current Android workspace: Roboletric/Mockito/Truth. The following framework has been experimented with existing unit tests. A further breakdown of core capabilities provided each of the frameworks and the role they played in the whole picture.

1. Use Cases

  • Roboletric is used to provide the fundamental Android framework environment support. The underlying mechanism is to scan and manipulate the Android API relevant classes/subclass during class loading process via the java-bytecode-instrumentation technique by ASM/Jassist. The Android API related classes are modified and stubbed based on the ShadowMap which denotes the mapping between Android API class and their corresponding shadow classes. (You can browse through the shadow classes on Roboletrics API documents)
  • Mockito can be used to provide a more friendly way of conducting stubbing and verify method invocation. The mock can be both done via MokitoTestRunner or mock API, in consideration of using Roboletric, RoboletricTestRunner is used, hence it is recommended to use mock API directly. (It seems MockK provides a better integration experiences in Kotlin mock test integration, which mitigate those problems like final and Companion object)
  • Truth can be used for the ease of readability and better development experience. (chaining/better auto completion)

2. Demo:

2.1 Integration:

Introduce Dependencies

dependencies {
    testImplementation 'junit:junit:4.13.2'        
    testImplementation 'org.robolectric:robolectric:4.6'
    testImplementation "org.mockito:mockito-core:4.1.0"
    testImplementation "org.mockito:mockito-inline:4.1.0"
    testImplementation "com.google.truth:truth:1.1.3"
}

For modules which internally declared Combo API, can add the classpath of the compiled project to the test case, for example:

// adding the following line of code to introduce dependency: 
// `AnalyticsReporter$Dispatcher`
testImplementation "ad_analytics:6.6.6"

2.2 Examples:

Roboletric

For classes/business logics which directly relies on Android instances such as activity/context/application, Roboletric provides the reliable sandbox environment to access those instance in java runtime.

// config the test runner to be roboletricTestRunner
// this enables loading and replacing Android classes with shadows
@RunWith(value = RobolectricTestRunner::class)
class AdvertiseModuleTest {

    @Before
    fun prepare() {
        // the activity instance here is manipulated with ShadowActivity's method
        // to offset runtime exceptions
        SDKConfig.instance.init(Activity()) { funcName, result -> println("mock") }
    }
    
    ....
}

Mockito

Case Study in package ad_analyticsAnalyticsReporter.ktl provides the routing protocol implementation and the incoming call will be redirected to PluginIml.ktl which implements protocol IAdReport, the unit test cases is focusing on AnalyticsReporter.ktl because there are serialisation and initialisation logic aggregated inside the class.

The traditional way is to implement our own mock PluginImpl and inject it to AnalyticsReporter.ktl, since we only want to test AnalyticsReporter.ktl. The test package’s mock PluginImpl class serves as the following purposes:

  • receiving calls and cache the input params from AnalyticsReporter
  • provide the cached params to test cases to verify the invocation via AnalyticsReporter.ktl is correct.
/**
The original way: 
we might have a mock PluginImpl class like this. 
before adopting mockito
*/
class PluginImpl: IAdReport {

    companion object {

        var currentEnabled: Boolean = false
        var currentUserId: String? = null
        var currentEventParameters: Bundle? = null
        var currentUserProperties: MutableMap<String, Any> = mutableMapOf()

        var lastInvocationKey: String? = null
        var lastInvocationEventBundle: Bundle? = null
    }

    /* --- protocol impl --- */

    override fun initAnalysis(activity: Activity?) {
        println("class init")
    }

    override fun logEvent(key: String?, bundle: Bundle?) {
        lastInvocationKey = key
        lastInvocationEventBundle = bundle
    }

    ...
}


... somewhere in the test cases
/**
In the class: AdvertiseModuleTest we will inject the instance 
to our AdvertiseModule instance: 
*/ 
AnalyticsReporter.instance = PluginImpl()

With the help of the mockito, there is no longer need to manually implement the mocking class PluginImpl, instead, the mock function will create the instance for you.

AnalyticsReporter.instance = mock(IAdReport::class.java)

All the invocations to the mock instance from your code will be recorded and can be verified later:


// ---- the old way ----
// manually write your own mock class and verify the status.

assert(PluginImpl.Companion.lastInvocationKey == "test_event_name")
assert(PluginImpl.Companion.lastInvocationEventBundle?.get("key2") == "value2")

// ---- the new way ---- 

/**
* simply verify the invocation happens exactly once
* and the following method has been called.
* */
verify(AnalyticsReporter.instance, times(1))!!
.logEvent(any(String::class.java), any(Bundle::class.java))

At the same time, you can also configure default return values for some critical state machines.

val mocked = mock(SDKConfig::class.java)
        
// intercept function execution with direct return
`when`(mocked.activity).thenReturn(Activity())

// intercept function execution with answer hook
`when`(mocked.callback(anyString(), anyString())).then{
    val args: Array<Any> = it.getArguments()
    val mock: Any = it.getMock()
    println("called with arguments: " + Arrays.toString(args))
}

To test complex invocation parameters, you can also use the argMatcher to verify the detailed parameters:

fun testConfigDefaultParams() {
    // test input valid
    val input = JSONObject()
    input.putOpt("key1", "value1")
    input.putOpt("key2", null)
    input.putOpt("key3", 4)

    AnalyticsReporter.configDefaultParams(input.toString())

    /**
    * for complex data types, you can use argMatcher,
    * to verify the invocation details.
    * */
    verify(AnalyticsReporter.instance, times(1))!!
    .setDefaultEventParameters(argThat {
        it.get("key1") == "value1"
    })

    /**
    * original ways:
    * */
   // assert(PluginImpl.Companion.currentEventParameters?.get("key2") == "value2")
}

Trust (Optional)

Trust can be used together with spy capability of Mokito (without creating a mocket object, but just inspect the method invocation). trust is a lightweight grammar-sugar for assert. The overall syntax of using Trust can be summarised as:

{
assertThat(SubjectSource)|
assertTrue(Condition)|
assertWithMessage(Message).that(SubjectSource)
} 
+ 
{
comparexxx |
isxxx |
hasxxx |
containsxxx |
}

With kotlin, the invocation can also be chained with apply

assertThat(listOf("RED", "GREEN", "YELLOW")).apply { 
    contains("RED")
    hasSize(3)
    containsAtLeast("GREEN", "YELLOW")
}

3. Limitations:

When trying to integrate jacoco, it is found that there is a conflict in applying jacoco and roboletrics at the same time, refer to this issue. Alternative code coverage plugins needs to be further explored or we need to provide a workaround based on jacoco.

4. Reference:

TOC