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:
在这里插入图片描述
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"))
    }
}

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

  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<String>
                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 命令,就会生成覆盖率报告。
在这里插入图片描述
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 ,点击执行,就会生成覆盖率报告。
在这里插入图片描述

结束语

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

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>