概述
新建一個 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 等沒有太大關系,一般適合進行本地單元測驗的代碼就是:
- MVP 結構中的 Presenter 或者 MVVM 結構中的 ViewModel
- Helper 或者 Utils 工具類
- 公共基礎模塊,比如網路庫、資料庫等
我們一直強調本地單元測驗和 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("test@qq.com"))
}
@Test
fun isValidPhoneNumber() {
assertEquals(false, Utils.isValidPhoneNumber("123"))
assertEquals(true, Utils.isValidPhoneNumber("12345678911"))
}
}
測驗用例撰寫完成,然后就是運行測驗用例,有幾種方法:
- 運行單個測驗方法:選中@Test注解或者方法名,右鍵選擇 Run
- 運行一個測驗類中的所有測驗方法:打開類檔案,在類的范圍內右鍵選擇 Run 或者直接選擇類檔案直接右鍵 Run
- 運行一個目錄下的所有測驗類:選擇這個目錄,右鍵 Run
- 使用 gradle 命令:./gradlew :mylibrary:test ,然后在 mylibrary/build/reports/tests 目錄下查看測驗的結果
- 使用 AS 快捷鍵,打開右上角的 Gradle Tab,mylibrary -> Tasks-> verification->點擊 test
現在我們在 Utils 公共類增加一個“getMyString() ”的方法,這個方法需要一個 Context 物件:
Utils 類
/**
* 獲取 string
* */
fun getMyString(context: Context): String {
return context.getString(R.string.mylibrary)
}
這時候就輪到 Mocktio 出場:
- 在 mylibrary 的 build.gradle 檔案中添加 Mockito 庫的依賴
- 在單元測驗類定義 UtilsTest 的開頭,添加 @RunWith(MockitoJUnitRunner::class) 注釋
- 要為 Android 依賴項創建模擬物件,在要模擬的物件前添加 @Mock 注釋
- 使用 Mockito 的 when() 和 thenReturn() 方法指定條件并在滿足條件時回傳期望的值
- 呼叫 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
- verify().method Call,用來驗證 mock 物件的方法是否被呼叫
- when(…?.).thenReturn(…?.),用來定義當條件滿足時函式的回傳值;對于無回傳值的函式,我們可以使用 doReturn(…?).when(…?).method Call 來獲得類似的效果
- doAnswer(…?).when(…?).method Call,用于有回呼的函式,我們可以在 Answer 物件中拿到回呼的物件,然后執行回呼物件的方法
- 還有 doThrow() | doNothing() 等方法,可以參考 Mockito 的官方檔案
缺陷
- Mockito cannot mock/spy because : — final class : Mockito 預設是無法 Mock final class,而在 Kotlin 里任何 Class預設都是 final(除非使用 open 關鍵字)
- java.lang.IllegalStateException: anyObject() must not be null :Mockito 的 any() 、eq()等方法都是可能回傳 null 的,而 Kotlin 是“空安全”的,顯然它不能接受這些方法的
- Mockito 的 when()方法要加上反引號才能使用,這是因為 when 在 Kotlin 中是保留字
- 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())
}
}
}
- 模擬 Context 物件,有兩種方式@MockK 注解和 mockk() 方法,使用注解則必須在 @Before 方法中呼叫MockKAnnotations.init() 方法
- 使用 every(…).returns(…) 方法,定義當條件滿足時函式的回傳值,這個方法類似于 Mockito 的 when(…?.).thenReturn(…?.) 方法
- 呼叫 Utils.getMyString(context) 方法
- 使用 verify(…) 方法驗證 Context 物件的 getString() 方法是否被呼叫
常用API
- verify(…)、coVerify(…),驗證 mock 物件的方法是否被呼叫
- every(…)、coEvery(…),定義當條件滿足時函式的回傳值,后面可以跟 returns(…) answers(…) throws(…) 等方法,可以去參考檔案
- 以 co 開頭的方法是配合 Kotlin 協程使用的,suspend 函式可以在方法的閉包內使用
- 推薦 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):
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)
}
}
}
五,測驗代碼覆寫率
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 相同格式的測驗報告,在這篇文章中,我們主要關注如何生成本地單元測驗覆寫率報告,
- 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 ,點擊執行,就會生成覆寫率報告,

結束語
感謝大家的閱讀,我這里只是分享了一些自己踩過的坑,
路漫漫其修遠兮,吾將上下而求索,希望大家能共同探索、一起進步,
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/377111.html
標籤:其他
