Android UnitTest Technique Summary
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 likefinal
andCompanion
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_analytics
,AnalyticsReporter.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:
- Roboletric Introduction:
- Mockito Usecase Intro
- Truth API Summary
- AssertJ v.s. Truth comparison