为了方便讲解我们写了一个小工具,支持把java的链式调用代码入去执行,它的核心调用逻辑如下:
1 2 3 invoker = Invoker() invoker.addPrefix("context." , Invoker.ClassInstance(Context::class .java, context)) val path = invoker.invoke("context.getFilesDir().getAbsolutePath()" )
假设我们我们实现上面三行代码的功能,可以先写一个最简单的解析调用空参数列表方法的Invoker:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 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) .split("." ) .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 ) .split("(" ) instance.clazz.methods .filter { it.name == methodName } .forEach { method -> if (method.parameterTypes.isEmpty()) { val ret = ClassInstance(method.returnType, method.invoke(instance.instance)) 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?, ) }
代码写完之后需要如果确认功能呢?是加个打印编译运行到真机或者模拟器上看看打印是否如预期?
但是这么做的话会有下面的问题:
编译运行查看打印的耗时会比较久
每次修改bug或者新增功能(例如添加方法参数支持),可能会引入bug导致前面已经测试通过的功能出现问题
后面接手这个项目的人没有办法确认目前已经有哪些调用方式是已经支持的
解决这些问题最好的方式就是使用单元测试。
假设我们使用单元测试去测上面的三行代码,就会遇到一个问题:context如何获取?有两种方式:
一是使用androidTest在整机或者模拟器里面运行单元测试然后使用”InstrumentationRegistry.getInstrumentation().targetContext”获取。 二是使用mock技术mock出一个假的context在电脑上执行单元测试。
这里我们只讲第二种。
mock技术 简单来讲就是创建一个可以控制方法返回值的假对象,用于传入需要测试的方法,去测试其代码逻辑。java上可以使用PowerMock 、mockito 而kotlin则使用mockk ,java的话之前早年间写过一篇博客 ,这里说下mockk。
实际上mockk的官方文档 已经蛮详细的了,但是缺少了点安卓上场景化的使用方式,我这边就用一个实际的例子去介绍。
mockk 导入mockk的方式很简单:
testImplementation “io.mockk:mockk:1.12.0”
然后就可以开始测试了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 class InvokerTest { private lateinit var invoker: Invoker @MockK private lateinit var context: Context @Before fun setUp () { MockKAnnotations.init (this ) invoker = Invoker() invoker.addPrefix("context." , Invoker.ClassInstance(Context::class .java, context)) mockkStatic(Log::class ) every { Log.d(any(), any()) } returns 0 } @After fun cleanUp () { unmockkStatic(Log::class ) } @Test fun testNoParamFun () { every { context.filesDir } returns File("/data/user/0/me.linjw.demo/files" ) val path = invoker.invoke("context.getFilesDir().getAbsolutePath()" ) assertEquals("/data/user/0/me.linjw.demo/files" , path) } }
除了使用注解”@MockK”注解之外,我们也可以用mockk方法去创建mock对象:
mock静态方法 Invoker.invoke里面调用到了Log.d,而它的具体实现在framework.jar里面,如果不运行在安卓环境,直接在电脑上跑单元测试执行到会报下面的问题:
1 2 3 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)
为了解决这个问题我们可以直接mock Log.d,或者在build.gradle里面添加配置:
1 2 3 4 5 6 android { ... testOptions { unitTests.returnDefaultValues = true } }
或者如这里的例子用mockkStatic去mock Log,这样调用到Log.d的时候就会执行我们mock出来的Log的d静态方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Before fun setUp () { ... mockkStatic(Log::class ) every { Log.d(any(), any()) } returns 0 } @After fun cleanUp () { unmockkStatic(Log::class ) }
PS: kotlin里面更多的是使用object,可以使用mockkObject和unmockkObject去mock object
方法调用次数 有时候会需要确认mock对象方法被调用的次数,可以使用verify方法去校验:
1 2 3 4 5 6 7 8 9 10 11 @Test fun testInvokeTime () { every { context.applicationContext } returns context invoker.invoke("context.getApplicationContext().getApplicationContext().getApplicationContext()" ) verify(exactly = 3 ) { context.applicationContext } }
可以用下面的参数去校验方法调用次数:
exactly : 具体的被调用次数
atLeast : 最少被调用次数
atMost : 最多被调用次数
inverse : 为true表示方法没有被执行过, 相当于exactly=0
参数校验 有时候我们会需要校验传入mock对象方法的参数,可以用MockKMatcherScope的eq、any这些方法去匹配参数,也可以直接把具体的参数值填入去匹配相等的参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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语句去校验,我们也可以用下面的方式校验多条调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @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 verifyOrder { context.getDir("dir1" , 123 ) context.getDir("dir2" , 456 ) } }
参数捕获 有时候我们会需要捕获传给mock对象方法的参数,例如拿到传入的callback然后主动调用callback,又例如拿到传给线程池或者handler的Runnable去直接run。
或者参数的方式有两种:
设置answer方法,调用到mock对象方法的时候会转发给到设置的answer方法,可以在里面进行保存
使用capture机制去获取参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Test fun testInterfaceParam () { var log: String? = null every { Log.d(any(), any()) } answers { log = it.invocation.args[1 ] as String 0 } val slot = slot<ComponentCallbacks>() every { context.registerComponentCallbacks(capture(slot)) } returns Unit proxy.invoke("context.registerComponentCallbacks(new Proxy())" ) verify(exactly = 1 ) { context.registerComponentCallbacks(any()) } slot.captured.onLowMemory() assertEquals("callback --> ComponentCallbacks.onLowMemory()" , log) }
capture除了slot捕获最后一次传入的参数之外也可以传入MutableList捕获多次传入的参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Test fun testSleep () { 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)" ) 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构造函数 类似Handler很多情况下是在类内部直接new出来的:
1 2 3 4 5 6 7 class MyClass { private val handler = Handler(Looper.getMainLooper()) fun post (r: Runnable ) { handler.post(r) } }
如果我们想捕获传给Handler.post的Runnable去主动run,就需要mock在类内部new出来的的Handler。这种情况就可以使用mock类构造函数的方式去实现了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Test fun testMockConstructed () { mockkStatic(Looper::class ) every { Looper.getMainLooper() } returns null mockkConstructor(Handler::class ) every { anyConstructed<Handler>().post(any()) } returns true val r = Runnable { } val myClass = MyClass() myClass.post(r) verify(exactly = 1 ) { anyConstructed<Handler>().post(r) } unmockkStatic(Looper::class ) unmockkConstructor(Handler::class ) }
单元测试的作用 上面的几个技巧已经足够我们使用mockk去编写测试用例了,其他更完整的用法可以直接看官方文档
脱离复杂的运行环境检测代码逻辑 - 有些功能依赖了比较复杂的外部输入,比方说http请求的返回,可以直接模拟出返回数据进行代码逻辑的验证
监控所有功能的可用性 - 对各个功能编写测试用例,一旦修改bug出现bug就能立马发现
列举所有的可用功能 - 用测试用例列举所有可用的功能和调用方式
可测试性越高的代码,可维护性也会越高 - 如果发现你写的代码不知道怎么写测试用例,或者写测试用例需要mock一堆乱七八糟的构造函数、私有方法就代表可能代码的结构就有问题,可维护性不行,起码代码的解耦没有做好
监控出现过的bug - 将出现过的bug写成测试用例,确保以后修改代码再次出现可以立马发现