新建一个 module 的时候,Android Studio 自动帮我们生成了 test 和 androidTest 两个 sourceSet。这两个 sourceSet 对应了不同的单元测试类型,同时两个 sourceSet 声明依赖的命令也有区别,前者是 testImplementation 后者是 androidTestImplementation,在这篇文章中,我们主要讲本地单元测试。
app/src
├── androidTestjava (Instrument单元测试、UI测试)
├── main/java (业务代码)
└── test/java (本地单元测试)
顾名思义和 Android 无关,这种测试是和原生的 Java 测试一样,不依赖 Android 框架或者只有非常少的依赖,直接运行在你本地的JVM上,而不需要运行在一个 Android 设备或者 Android 模拟器上,所以这种测试方式是非常高效的,因此我们建议如果可以,就是用这种方法测试,比如业务逻辑代码,它们可能和 Android Activity 等没有太大关系。一般适合进行本地单元测试的代码就是:
我们一直强调本地单元测试和 Android 框架没有关系,但是有时候还是不可避免地会依赖到 Android 框架,比如某些 Utils 工具类需要 Context,针对这种情况,我们只能使用模拟对象的框架了,1,如果使用 Java 语言开发推荐使用 Mocktio,如果使用 Kotlin 语言开发推荐使用 MockK;2,如果使用 Java 语言开发推荐使用 Mocktio,如果使用 Kotlin 语言开发推荐使用 MockK;3,如果使用 Java 语言开发推荐使用 Mocktio,如果使用 Kotlin 语言开发推荐使用 MockK。(重要的事情说三遍,都是血泪的经验)
dependencies {
// Required -- JUnit 4 framework
testImplementation 'junit:junit:4.12'
// Optional -- Mockito framework(可选,用于模拟一些依赖对象,以达到隔离依赖的效果)
testImplementation "org.mockito:mockito-core:1.10.19"
}
下面看例子,新建一个名为 mylibrary 的Android Module,Android Studio 会自动帮我们在 src 目录下创建 test、androidTest、main 三个目录,该 module 的 build.gradle 默认配置如下,这里我们使用的是本地测试单元,所以先把 androidTestImplementation 的依赖注释掉:
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
testImplementation 'junit:junit:4.12'
//androidTestImplementation 'androidx.test.ext:junit:1.1.3'
//androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
然后在 main 目录下 java 中定义一个 Utils 工具类,这个类有两个方法:
package com.jdd.smart.mylibrary.util
import java.util.regex.Pattern
object Utils {
/**
* 是否有效的邮箱
* */
fun isValidEmail(email: String?): Boolean {
if (email == null)
return false
val regEx1 =
"^([a-z0-9A-Z]+[-|\\.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\\.)+[a-zA-Z]{2,}$"
val p = Pattern.compile(regEx1)
val m = p.matcher(email)
return m.matches()
}
/**
* 是否有效的手机号,只判断位数
* */
fun isValidPhoneNumber(phone: String?): Boolean {
if (phone == null)
return false
return phone.length == 11
}
}
现在我们编写一个 Utils 类单元测试用例,这里可以使用AS的快捷键,选择对应的类->将光标停留在类上->按下右键>在弹出的弹窗中选择Generate->选择Test:
Testing library 选择 JUnit4,勾选 setUp/@Before 会生成一个带 @Before 注解的 空方法,tearDown/@After 则会生成一个带 @After 注解的空方法,点击 OK:
选择测试用例保存的路径,我们现在使用本地单元测试,所以放到 src/test/java 目录下,点击 OK ,然后测试用例就创建完成,UtilsTest 类中的方法一开始都是空方法,我们编写自己的测试代码:
package com.jdd.smart.mylibrary.util
import org.junit.Test
import org.junit.Assert.*
class UtilsTest {
@Test
fun isValidEmail() {
assertEquals(false, Utils.isValidEmail("test"))
assertEquals(true, Utils.isValidEmail("[email protected]"))
}
@Test
fun isValidPhoneNumber() {
assertEquals(false, Utils.isValidPhoneNumber("123"))
assertEquals(true, Utils.isValidPhoneNumber("12345678911"))
}
}
测试用例编写完成,然后就是运行测试用例,有几种方法:
现在我们在 Utils 公共类增加一个“getMyString() ”的方法,这个方法需要一个 Context 对象:
Utils 类
/**
* 获取 string
* */
fun getMyString(context: Context): String {
return context.getString(R.string.mylibrary)
}
这时候就轮到 Mocktio 出场:
注意点:mock 出来的对象是一个虚假的对象,在测试环境中,用来替换掉真实的对象,以达到验证对象方法调用情况,或是指定这个对象的某些方法返回特定的值等。
@RunWith(MockitoJUnitRunner::class)
class UtilsTest {
@Mock
lateinit var mContext: Context
private val FAKE_STRING = "Hello"
@Test
fun getMyString() {
Mockito.`when`(mContext.getString(R.string.mylibrary)).thenReturn(FAKE_STRING)
val myString = Utils.getMyString(mContext)
assertEquals(FAKE_STRING, myString)
}
}
我们注意到,在上面的测试用例 UtilsTest 中,我们使用了 when(….).thenReturn(….) API ,来定义当条件满足时函数的返回值,其实 Mockito 还提供了很多其他 API,接下来,我们介绍下Mockito。
第一条,可以依赖 mockito-inline 解决;第二条,可以依赖 mockito-kotlin 解决;第三条,只是语法问题还能接受;最后一条,要老命了,因为我们项目中大量使用了 Kotlin 的协程,Mockito 不能很好的支持挂起函数,那么项目中的异步操作就无法进行单元测试,怎么办,这就轮到另一款模拟框架 MockK 闪亮登场了。
MockK(mocking library for Kotlin),专为 Kotlin 而生 ,官方文档。MockK 其实跟 Mockito 的思路很像,只是语法稍有不同而已。
我们还是用上面的 Utils 公共类举例,首先,依赖 MockK 库
dependencies {
testImplementation 'junit:junit:4.12'
testImplementation "io.mockk:mockk:1.12.1"
}
然后,编写 getMyString() 方法的测试用例
class UtilsTest {
@MockK
private lateinit var context: Context
private val FAKE_STRING = "Hello"
@Before
fun setup() {
MockKAnnotations.init(this)
//另外一种 mock 对象的方法
//context = mockk()
}
@Test
fun getMyString() {
every {
context.getString(any())
}.returns(FAKE_STRING)
assertEquals(FAKE_STRING, Utils.getMyString(context))
verify {
context.getString(any())
}
}
}
下面开始重头戏,项目实战走起,推荐一个很好的讲解 MockK 的系列。
我们项目使用的 Kotlin 协程 + MVVM,上面有提到,适合用本地单元测试的代码是 MVVM 结构中的 ViewModel,那么现在我们就为 ViewModel 编写测试用例。
首先,我们要 在 build.gradle 中,添加单元测试需要的依赖:
dependencies {
testImplementation 'junit:junit:4.12'
testImplementation "io.mockk:mockk:1.12.1"
//对于runBlockingTest, CoroutineDispatcher等
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2'
//对于InstantTaskExecutorRule
testImplementation 'androidx.arch.core:core-testing:2.1.0'
}
//org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2 是用来测试 Kotlin 协程的
//androidx.arch.core:core-testing:2.1.0 是用来测试 LiveData 的
然后在 test/java 目录下,新增一个类,这个类很重要(Replace Dispatcher.Main with TestCoroutineDispatcher),为什么这么做?参考 Kotlin 的文章
package com.jdd.smart.mylibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@ExperimentalCoroutinesApi
class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()):
TestWatcher(),
TestCoroutineScope by TestCoroutineScope(dispatcher) {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
最后编写测试用例:
class ProductViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@ExperimentalCoroutinesApi
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
private lateinit var params: Params
private lateinit var repository: ProductRepository
private lateinit var viewModel: ProductViewModel
@Before
fun setup() {
repository = mockk()
params = mockk()
viewModel = ProductViewModel(repository)
}
@ExperimentalCoroutinesApi
@Test
fun getList_SuccessTest() {
// 注意这里使用 runBlockingTest
mainCoroutineRule.runBlockingTest {
val result = Result.Success("hhhh")
//定义条件和满足条件的返回值
coEvery {
// getList 是挂起函数,返回值是 Result
repository.getList(any())
}.returns(result)
viewModel.getList(params)
//验证函数是否被调用
coVerify {
// getList 是挂起函数
repository.getList(any())
}
//liveData 是 MutableLiveData ,验证 liveData 是否赋值成功
Assert.assertEquals("hhhh", viewModel.liveData.value)
}
}
}
上面的例子是 MVVM 架构的项目,这篇文章是 MVP 架构的项目。
Android Studio 支持的 Code Coverage Tool : jacoco、IntelliJ IDEA。上面有提到,当新建一个 module 时,Android Studio 自动帮我们生成了 test 和 androidTest 两个 sourceSet,在Android Studio中,在 androidTest 包下的单元测试代码,默认使用 jacoco 插件生成包含代码覆盖率的测试报告;而 test 包下的单元测试代码,则直接使用 IntelliJ IDEA 生成覆盖率报告,也可以通过自定义 gradle task 使用 jacoco 插件生成与 androidTest 相同格式的测试报告。在这篇文章中,我们主要关注如何生成本地单元测试覆盖率报告。
参考上面讲的 “运行测试用例” 的几种方法,在 Run 命令下面,有一个 Run xxx with Coverage 命令,点击这个 Coverage 命令,就会生成覆盖率报告。
2. jacoco
需要自定义 gradle task 。
首先,新建一个 jacoco.gradle 文件,内容如下:
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.6" //指定jacoco的版本
}
//依赖于testDebugUnitTest任务
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "reporting"指定task的分组
description = "Generate Jacoco coverage reports"指定task的描述
reports {
xml.enabled = true
html.enabled = true
csv.enabled = false
}
//设置需要检测覆盖率的目录
def mainSrc = "${projectDir}/src/main/java"
sourceDirectories.from = files([mainSrc])
// exclude auto-generated classes and tests
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', 'android/**/*.*']
//定义检测覆盖率的class所在目录,注意:不同 gradle 版本可能不一样,需要自行替换
def debugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debug", excludes: fileFilter)
classDirectories.from = files([debugTree])
executionData.from = fileTree(dir: project.projectDir, includes: ['**/*.exec', '**/*.ec'])
}
注意:debugTree 配置不同 gradle 版本可能不一样
然后,在 module 的 build.gradle 文件里依赖 jacoco.gradle 即可:
apply from: 'jacoco.gradle'
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
Syns 完成后,在右上角的 Gradle Tab 会生成一个 task ,mylibrary -> Tasks-> reporting -> jacocoTestReport ,点击执行,就会生成覆盖率报告。
感谢大家的阅读,我这里只是分享了一些自己踩过的坑。
路漫漫其修远兮,吾将上下而求索,希望大家能共同探索、一起进步。