Android单元测试

概述

新建一个 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 等没有太大关系。一般适合进行本地单元测试的代码就是:

  1. MVP 结构中的 Presenter 或者 MVVM 结构中的 ViewModel
  2. Helper 或者 Utils 工具类
  3. 公共基础模块,比如网络库、数据库等

我们一直强调本地单元测试和 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:
Android单元测试_第1张图片
Testing library 选择 JUnit4,勾选 setUp/@Before 会生成一个带 @Before 注解的 空方法,tearDown/@After 则会生成一个带 @After 注解的空方法,点击 OK:
Android单元测试_第2张图片
选择测试用例保存的路径,我们现在使用本地单元测试,所以放到 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"))
    }
}

测试用例编写完成,然后就是运行测试用例,有几种方法:

  1. 运行单个测试方法:选中@Test注解或者方法名,右键选择 Run
  2. 运行一个测试类中的所有测试方法:打开类文件,在类的范围内右键选择 Run 或者直接选择类文件直接右键 Run
  3. 运行一个目录下的所有测试类:选择这个目录,右键 Run
  4. 使用 gradle 命令:./gradlew :mylibrary:test ,然后在 mylibrary/build/reports/tests 目录下查看测试的结果
  5. 使用 AS 快捷键,打开右上角的 Gradle Tab,mylibrary -> Tasks-> verification->点击 test

现在我们在 Utils 公共类增加一个“getMyString() ”的方法,这个方法需要一个 Context 对象:

 Utils/**
     * 获取 string
     * */
    fun getMyString(context: Context): String {
        return context.getString(R.string.mylibrary)
    }

这时候就轮到 Mocktio 出场:

  1. 在 mylibrary 的 build.gradle 文件中添加 Mockito 库的依赖
  2. 在单元测试类定义 UtilsTest 的开头,添加 @RunWith(MockitoJUnitRunner::class) 注释
  3. 要为 Android 依赖项创建模拟对象,在要模拟的对象前添加 @Mock 注释
  4. 使用 Mockito 的 when() 和 thenReturn() 方法指定条件并在满足条件时返回期望的值
  5. 调用 Utils.getMyString() 方法,看看它返回的值和我们期望的值是否一样

注意点: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

常用API

  1. verify().method Call,用来验证 mock 对象的方法是否被调用
  2. when(…​.).thenReturn(…​.),用来定义当条件满足时函数的返回值;对于无返回值的函数,我们可以使用 doReturn(…​).when(…​).method Call 来获得类似的效果
  3. doAnswer(…​).when(…​).method Call,用于有回调的函数,我们可以在 Answer 对象中拿到回调的对象,然后执行回调对象的方法
  4. 还有 doThrow() | doNothing() 等方法,可以参考 Mockito 的官方文档

缺陷

  1. Mockito cannot mock/spy because : — final class : Mockito 预设是无法 Mock final class,而在 Kotlin 里任何 Class预设都是 final(除非使用 open 关键字)
  2. java.lang.IllegalStateException: anyObject() must not be null :Mockito 的 any() 、eq()等方法都是可能回传 null 的,而 Kotlin 是“空安全”的,显然它不能接受这些方法的
  3. Mockito 的 when()方法要加上反引号才能使用,这是因为 when 在 Kotlin 中是保留字
  4. Argument(s) are different! Wanted:Mockito 不能很好的支持 Kotlin 的 suspend functions

第一条,可以依赖 mockito-inline 解决;第二条,可以依赖 mockito-kotlin 解决;第三条,只是语法问题还能接受;最后一条,要老命了,因为我们项目中大量使用了 Kotlin 的协程,Mockito 不能很好的支持挂起函数,那么项目中的异步操作就无法进行单元测试,怎么办,这就轮到另一款模拟框架 MockK 闪亮登场了。

三,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())
        }
    }
}
  1. 模拟 Context 对象,有两种方式@MockK 注解和 mockk() 方法,使用注解则必须在 @Before 方法中调用MockKAnnotations.init() 方法
  2. 使用 every(…).returns(…) 方法,定义当条件满足时函数的返回值,这个方法类似于 Mockito 的 when(…​.).thenReturn(…​.) 方法
  3. 调用 Utils.getMyString(context) 方法
  4. 使用 verify(…) 方法验证 Context 对象的 getString() 方法是否被调用

常用API

  1. verify(…)、coVerify(…),验证 mock 对象的方法是否被调用
  2. every(…)、coEvery(…),定义当条件满足时函数的返回值,后面可以跟 returns(…) answers(…) throws(…) 等方法,可以去参考文档
  3. 以 co 开头的方法是配合 Kotlin 协程使用的,suspend 函数可以在方法的闭包内使用
  4. 推荐 API 文章 Kotlin 测试利器—MockK

下面开始重头戏,项目实战走起,推荐一个很好的讲解 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 相同格式的测试报告。在这篇文章中,我们主要关注如何生成本地单元测试覆盖率报告。

  1. IntelliJ IDEA

参考上面讲的 “运行测试用例” 的几种方法,在 Run 命令下面,有一个 Run xxx with Coverage 命令,点击这个 Coverage 命令,就会生成覆盖率报告。
Android单元测试_第3张图片
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 ,点击执行,就会生成覆盖率报告。
Android单元测试_第4张图片

结束语

感谢大家的阅读,我这里只是分享了一些自己踩过的坑。
路漫漫其修远兮,吾将上下而求索,希望大家能共同探索、一起进步。

你可能感兴趣的:(android,单元测试,java)