為了方便講解我們寫(xiě)了一個(gè)小工具,支持把java的鏈?zhǔn)秸{(diào)用代碼入去執(zhí)行,它的核心調(diào)用邏輯如下:
invoker = Invoker()
invoker.addPrefix("context.", Invoker.ClassInstance(Context::class.java, context))
val path = invoker.invoke("context.getFilesDir().getAbsolutePath()") // 返回執(zhí)行`context.getFilesDir().getAbsolutePath()`代碼后的結(jié)果
假設(shè)我們我們實(shí)現(xiàn)上面三行代碼的功能,可以先寫(xiě)一個(gè)最簡(jiǎn)單的解析調(diào)用空參數(shù)列表方法的Invoker:
class Invoker {
private val prefixes = mutableMapOf<String, ClassInstance>()
fun addPrefix(prefix: String, classInstance: ClassInstance) {
prefixes[prefix] = classInstance
}
fun invoke(code: String): Any? {
val matches = prefixes.entries.find { code.startsWith(it.key) }
?: throw Exception("can't match prefix for $code")
Log.d(TAG, "invoke $code")
val parts = code
.substring(matches.key.length) // 刪除前綴,例如 "context.getFilesDir().getAbsolutePath()" 刪除 "context." 之后剩下 "getFilesDir().getAbsolutePath()"
.split(".") // 使用 "." 分割鏈?zhǔn)椒椒ㄕ{(diào)用,例如 "getFilesDir().getAbsolutePath()" 分割出 ["getFilesDir()", "getAbsolutePath()"]
.toList()
return invoke(parts, 0, matches.value)
}
private fun invoke(codes: List<String>, curIndex: Int, instance: ClassInstance): Any? {
if (curIndex >= codes.size) {
return instance.instance
}
val code = codes[curIndex]
val (methodName, params) = code
.substring(0, code.length - 1) // 刪除方法調(diào)用的右花括號(hào),例如 "getFilesDir()" 得到 "getFilesDir("
.split("(") // 使用方法調(diào)用的左花括號(hào)進(jìn)行分割方法名和參數(shù)列表,例如 "getFilesDir(" 得到 ["getFilesDir, ""]
instance.clazz.methods
.filter { it.name == methodName } // 遍歷類(lèi)的所有方法,找到 getFilesDir 這個(gè)名字的方法
.forEach { method ->
// 目前只先支持空參數(shù)列表的方法調(diào)用
if (method.parameterTypes.isEmpty()) {
// 反射調(diào)用 context.getFilesDir() 得到ret
val ret = ClassInstance(method.returnType, method.invoke(instance.instance))
// 將ret傳入下一層去執(zhí)行 "getAbsolutePath()"
return invoke(codes, curIndex + 1, ret)
}
}
throw Exception("no match method for $code in ${instance.clazz}")
}
data class ClassInstance(
val clazz: Class<*>,
val instance: Any?,
)
}
代碼寫(xiě)完之后需要如果確認(rèn)功能呢?是加個(gè)打印編譯運(yùn)行到真機(jī)或者模擬器上看看打印是否如預(yù)期?
但是這么做的話(huà)會(huì)有下面的問(wèn)題:
- 編譯運(yùn)行查看打印的耗時(shí)會(huì)比較久
- 每次修改bug或者新增功能(例如添加方法參數(shù)支持),可能會(huì)引入bug導(dǎo)致前面已經(jīng)測(cè)試通過(guò)的功能出現(xiàn)問(wèn)題
- 后面接手這個(gè)項(xiàng)目的人沒(méi)有辦法確認(rèn)目前已經(jīng)有哪些調(diào)用方式是已經(jīng)支持的
解決這些問(wèn)題最好的方式就是使用單元測(cè)試。
假設(shè)我們使用單元測(cè)試去測(cè)上面的三行代碼,就會(huì)遇到一個(gè)問(wèn)題:context如何獲取?有兩種方式:
一是使用androidTest在整機(jī)或者模擬器里面運(yùn)行單元測(cè)試然后使用"InstrumentationRegistry.getInstrumentation().targetContext"獲取。
二是使用mock技術(shù)mock出一個(gè)假的context在電腦上執(zhí)行單元測(cè)試。
這里我們只講第二種。
mock技術(shù)簡(jiǎn)單來(lái)講就是創(chuàng)建一個(gè)可以控制方法返回值的假對(duì)象,用于傳入需要測(cè)試的方法,去測(cè)試其代碼邏輯。java上可以使用PowerMock、mockito而kotlin則使用mockk,java的話(huà)之前早年間寫(xiě)過(guò)一篇博客,這里說(shuō)下mockk。
實(shí)際上mockk的官方文檔已經(jīng)蠻詳細(xì)的了,但是缺少了點(diǎn)安卓上場(chǎng)景化的使用方式,我這邊就用一個(gè)實(shí)際的例子去介紹。
mockk
導(dǎo)入mockk的方式很簡(jiǎn)單:
testImplementation "io.mockk:mockk:1.12.0"
然后就可以開(kāi)始測(cè)試了:
class InvokerTest {
private lateinit var invoker: Invoker
// 使用注解的方式聲明需要mock的對(duì)象
@MockK
private lateinit var context: Context
// 調(diào)用每個(gè)@Test測(cè)試用例前會(huì)調(diào)用@Before方法做初始化
@Before
fun setUp() {
// 遍歷this的所有@MockK成員變量,為他們創(chuàng)建實(shí)例
MockKAnnotations.init(this)
// 創(chuàng)建我們需要測(cè)試的對(duì)象
invoker = Invoker()
// 將我們mock出來(lái)的context傳入Invoker使用
invoker.addPrefix("context.", Invoker.ClassInstance(Context::class.java, context))
// mockk支持mock靜態(tài)方法
mockkStatic(Log::class)
// 調(diào)用Log.d傳入任意的參數(shù)都返回0
every { Log.d(any(), any()) } returns 0
}
// 調(diào)用每個(gè)@Test測(cè)試用例后會(huì)調(diào)用@After方法做清理動(dòng)作
@After
fun cleanUp() {
// 解除靜態(tài)方法的mock
unmockkStatic(Log::class)
}
@Test
fun testNoParamFun() {
// 配置調(diào)用context.getFilesDir()返回File("/data/user/0/me.linjw.demo/files")
every { context.filesDir } returns File("/data/user/0/me.linjw.demo/files")
// 實(shí)際調(diào)用我們需要測(cè)試的方法
val path = invoker.invoke("context.getFilesDir().getAbsolutePath()")
// 校驗(yàn)測(cè)試方法的返回值是否如預(yù)期
assertEquals("/data/user/0/me.linjw.demo/files", path)
}
}
除了使用注解"@MockK"注解之外,我們也可以用mockk方法去創(chuàng)建mock對(duì)象:
context = mockk()
mock靜態(tài)方法
Invoker.invoke里面調(diào)用到了Log.d,而它的具體實(shí)現(xiàn)在framework.jar里面,如果不運(yùn)行在安卓環(huán)境,直接在電腦上跑單元測(cè)試執(zhí)行到會(huì)報(bào)下面的問(wèn)題:
Method d in android.util.Log not mocked. See http://g.co/androidstudio/not-mocked for details.
java.lang.RuntimeException: Method d in android.util.Log not mocked. See http://g.co/androidstudio/not-mocked for details.
at android.util.Log.d(Log.java)
為了解決這個(gè)問(wèn)題我們可以直接mock Log.d,或者在build.gradle里面添加配置:
android {
...
testOptions {
unitTests.returnDefaultValues = true
}
}
或者如這里的例子用mockkStatic去mock Log,這樣調(diào)用到Log.d的時(shí)候就會(huì)執(zhí)行我們mock出來(lái)的Log的d靜態(tài)方法:
@Before
fun setUp() {
...
// mockk支持mock靜態(tài)方法
mockkStatic(Log::class)
// 調(diào)用Log.d傳入任意的參數(shù)都返回0
every { Log.d(any(), any()) } returns 0
}
@After
fun cleanUp() {
// 解除靜態(tài)方法的mock
unmockkStatic(Log::class)
}
PS: kotlin里面更多的是使用object,可以使用mockkObject和unmockkObject去mock object
方法調(diào)用次數(shù)
有時(shí)候會(huì)需要確認(rèn)mock對(duì)象方法被調(diào)用的次數(shù),可以使用verify方法去校驗(yàn):
@Test
fun testInvokeTime() {
// 配置context.getApplicationContext()返回context
every { context.applicationContext } returns context
// 執(zhí)行測(cè)試用例
invoker.invoke("context.getApplicationContext().getApplicationContext().getApplicationContext()")
// 校驗(yàn)Context.getApplicationContext()被調(diào)用了3次
verify(exactly = 3) { context.applicationContext }
}
可以用下面的參數(shù)去校驗(yàn)方法調(diào)用次數(shù):
- exactly : 具體的被調(diào)用次數(shù)
- atLeast : 最少被調(diào)用次數(shù)
- atMost : 最多被調(diào)用次數(shù)
- inverse : 為true表示方法沒(méi)有被執(zhí)行過(guò), 相當(dāng)于exactly=0
參數(shù)校驗(yàn)
有時(shí)候我們會(huì)需要校驗(yàn)傳入mock對(duì)象方法的參數(shù),可以用MockKMatcherScope的eq、any這些方法去匹配參數(shù),也可以直接把具體的參數(shù)值填入去匹配相等的參數(shù):
@Test
fun testTowParam() {
every { context.getDir(eq("dir1"), any()) } returns File("dir1")
every { context.getDir(eq("dir2"), any()) } returns File("dir2")
val dir1 = proxy.invoke("context.getDir(\"dir1\", 123).getName()") as String
val dir2 = proxy.invoke("context.getDir(\"dir2\", 456).getName()") as String
assertEquals("dir1", dir1)
assertEquals("dir2", dir2)
verify(exactly = 1) { context.getDir("dir1", 123) }
verify(exactly = 1) { context.getDir("dir2", 456) }
}
除了上面這樣兩條verify語(yǔ)句去校驗(yàn),我們也可以用下面的方式校驗(yàn)多條調(diào)用:
@Test
fun testTowParam() {
every { context.getDir(eq("dir1"), any()) } returns File("dir1")
every { context.getDir(eq("dir2"), any()) } returns File("dir2")
val dir1 = proxy.invoke("context.getDir(\"dir1\", 123).getName()") as String
val dir2 = proxy.invoke("context.getDir(\"dir2\", 456).getName()") as String
// verifyAll{ // 無(wú)視順序,只要context.getDir的所有調(diào)用都在里面即可
// verifySequence { // context.getDir的所有調(diào)用都在里面,且必須按順序執(zhí)行
verifyOrder { // 只要下面的兩條調(diào)用是按順序執(zhí)行的就行,中間或者前后可以插入其他參數(shù)調(diào)用
context.getDir("dir1", 123)
context.getDir("dir2", 456)
}
}
參數(shù)捕獲
有時(shí)候我們會(huì)需要捕獲傳給mock對(duì)象方法的參數(shù),例如拿到傳入的callback然后主動(dòng)調(diào)用callback,又例如拿到傳給線(xiàn)程池或者h(yuǎn)andler的Runnable去直接run。
或者參數(shù)的方式有兩種:
- 設(shè)置answer方法,調(diào)用到mock對(duì)象方法的時(shí)候會(huì)轉(zhuǎn)發(fā)給到設(shè)置的answer方法,可以在里面進(jìn)行保存
- 使用capture機(jī)制去獲取參數(shù)
@Test
fun testInterfaceParam() {
// 設(shè)置Log.d的answer處理函數(shù),用于獲取傳給Log.d的參數(shù)
var log: String? = null
every { Log.d(any(), any()) } answers {
log = it.invocation.args[1] as String
0
}
// 使用slot去獲取傳給Context.registerComponentCallbacks的參數(shù)
val slot = slot<ComponentCallbacks>()
every { context.registerComponentCallbacks(capture(slot)) } returns Unit
proxy.invoke("context.registerComponentCallbacks(new Proxy())")
verify(exactly = 1) { context.registerComponentCallbacks(any()) }
// 調(diào)用Context.registerComponentCallbacks設(shè)置的callback
slot.captured.onLowMemory()
// "new Proxy()"創(chuàng)建的代理里面會(huì)調(diào)用Log.d去打印,對(duì)比打印的值和預(yù)期值是否一致
assertEquals("callback --> ComponentCallbacks.onLowMemory()", log)
}
capture除了slot捕獲最后一次傳入的參數(shù)之外也可以傳入MutableList捕獲多次傳入的參數(shù):
@Test
fun testSleep() {
// 傳入MutableList去捕獲多次傳入Log.d的參數(shù)
val params = mutableListOf<String>()
every { Log.d(any(), capture(params)) } returns 0
proxy.addPrefix("Executors.", Invoker.ClassInstance(Executors::class.java, null))
proxy.invoke("Executors.newScheduledThreadPool(1).schedule(new Proxy(), 1, SECONDS)")
// 等待Log.d被執(zhí)行兩次,超時(shí)時(shí)間為2s
verify(exactly = 2, timeout = 2000) { Log.d(any(), any()) }
assertEquals("invoke Executors.newScheduledThreadPool(1).schedule(new Proxy(), 1, SECONDS)", params[0])
assertEquals("callback --> Runnable.run()", params[1])
}
mock構(gòu)造函數(shù)
類(lèi)似Handler很多情況下是在類(lèi)內(nèi)部直接new出來(lái)的:
class MyClass {
private val handler = Handler(Looper.getMainLooper())
fun post(r: Runnable) {
handler.post(r)
}
}
如果我們想捕獲傳給Handler.post的Runnable去主動(dòng)run,就需要mock在類(lèi)內(nèi)部new出來(lái)的的Handler。這種情況就可以使用mock類(lèi)構(gòu)造函數(shù)的方式去實(shí)現(xiàn)了:
@Test
fun testMockConstructed() {
// mock Looper.getMainLooper
mockkStatic(Looper::class)
every { Looper.getMainLooper() } returns null
// mock Handler的構(gòu)造函數(shù)
mockkConstructor(Handler::class)
every { anyConstructed<Handler>().post(any()) } returns true
val r = Runnable { }
val myClass = MyClass()
myClass.post(r)
// 驗(yàn)證MyClass.post內(nèi)部有調(diào)用Handler.post
verify(exactly = 1) { anyConstructed<Handler>().post(r) }
// 取消Looper和Handler的mock
unmockkStatic(Looper::class)
unmockkConstructor(Handler::class)
}
單元測(cè)試的作用
上面的幾個(gè)技巧已經(jīng)足夠我們使用mockk去編寫(xiě)測(cè)試用例了,其他更完整的用法可以直接看官方文檔
脫離復(fù)雜的運(yùn)行環(huán)境檢測(cè)代碼邏輯 - 有些功能依賴(lài)了比較復(fù)雜的外部輸入,比方說(shuō)http請(qǐng)求的返回,可以直接模擬出返回?cái)?shù)據(jù)進(jìn)行代碼邏輯的驗(yàn)證
監(jiān)控所有功能的可用性 - 對(duì)各個(gè)功能編寫(xiě)測(cè)試用例,一旦修改bug出現(xiàn)bug就能立馬發(fā)現(xiàn)
列舉所有的可用功能 - 用測(cè)試用例列舉所有可用的功能和調(diào)用方式
可測(cè)試性越高的代碼,可維護(hù)性也會(huì)越高 - 如果發(fā)現(xiàn)你寫(xiě)的代碼不知道怎么寫(xiě)測(cè)試用例,或者寫(xiě)測(cè)試用例需要mock一堆亂七八糟的構(gòu)造函數(shù)、私有方法就代表可能代碼的結(jié)構(gòu)就有問(wèn)題,可維護(hù)性不行,起碼代碼的解耦沒(méi)有做好
監(jiān)控出現(xiàn)過(guò)的bug - 將出現(xiàn)過(guò)的bug寫(xiě)成測(cè)試用例,確保以后修改代碼再次出現(xiàn)可以立馬發(fā)現(xiàn)