16、扩展函数与高阶函数在测试中的应用:MockK的DSL语法、测试中的扩展函数、高阶函数模拟依赖、单元测试与UI测试的简化
测试这件事,说实话,很多Android开发者一开始都不太重视。我以前也是这样——项目赶进度,先写功能再说,测试?有空再补。结果呢?改一个逻辑,崩三个地方,修bug修到怀疑人生。
后来我痛定思痛,开始认真写测试。但写测试本身也挺痛苦的,尤其是Mock框架的使用,各种样板代码看得人头疼。直到我遇到了MockK,配合Kotlin的扩展函数和高阶函数,测试代码一下子清爽了很多。
今天我们就聊聊,怎么用Kotlin的这些特性,让测试变得简单、优雅、可维护。
MockK的DSL语法:测试代码也能像诗一样
MockK是Kotlin专用的Mock框架。它最大的特点就是——用DSL风格写Mock。你想想看,以前用Mockito的时候,要写一堆when(...).thenReturn(...),代码又长又啰嗦。MockK直接让你用Lambda表达,简洁到不行。
核心思路:用高阶函数构建Mock行为,用扩展函数提供流畅的API。
来看个例子。假设我们有一个UserRepository,需要Mock它的getUser方法:
// 传统Mockito写法
val mockRepo = mock(UserRepository::class.java)
`when`(mockRepo.getUser(1)).thenReturn(User("Alice"))
// MockK的DSL写法
val mockRepo = mockk<UserRepository>()
every { mockRepo.getUser(1) } returns User("Alice")
看到区别了吗?every { ... } returns ... 这本身就是个高阶函数。every接收一个Lambda,returns也是个扩展函数。整个读起来就像在说:“每次调用getUser(1),都返回Alice”。
我个人习惯用MockK的coEvery来处理挂起函数,也很自然:
coEvery { mockRepo.getUserAsync(1) } returns User("Bob")
嗯,这里要注意:coEvery是专门为协程设计的,内部帮你处理了runBlocking的细节。我在项目中遇到过好几次因为忘记加co前缀导致测试挂起的坑,后来就养成习惯了——看到suspend函数,直接用coEvery。
测试中的扩展函数:让断言更自然
扩展函数在测试中的妙用,说白了就是——给测试框架“加点料”。比如AssertJ或者Truth已经很好用了,但有时候我们还是需要一些自定义的断言逻辑。
举个例子,我们经常要验证一个列表是否包含某个特定属性的对象:
// 自定义扩展函数
fun <T> List<T>.shouldContainElement(predicate: (T) -> Boolean): T? {
val result = find(predicate)
assertThat(result).isNotNull()
return result
}
// 使用
val users = listOf(User("Alice"), User("Bob"))
val found = users.shouldContainElement { it.name == "Alice" }
你看,shouldContainElement本身接收一个高阶函数predicate,内部用find配合Lambda完成查找。这样写出来的测试代码,读起来就像自然语言一样。
我曾经在一个大型项目中,把常用的UI验证逻辑都封装成了扩展函数。比如验证RecyclerView的item数量:
fun RecyclerView.shouldHaveItemCount(count: Int) {
val actual = adapter?.itemCount ?: 0
assertThat(actual).isEqualTo(count)
}
测试用例里直接写recyclerView.shouldHaveItemCount(3),清爽得很。
高阶函数模拟依赖:告别接口泛滥
以前我们做依赖注入,动不动就要定义一堆接口。其实很多时候,我们只需要一个函数而已。高阶函数可以帮我们省掉很多接口定义。
比如,一个网络请求的依赖,传统做法是定义一个接口:
interface ApiService {
suspend fun fetchData(): Data
}
然后Mock这个接口。但如果你用高阶函数,可以直接把依赖定义成一个函数类型:
class DataRepository(
private val fetchData: suspend () -> Data
) {
suspend fun getData(): Data = fetchData()
}
测试的时候,直接传一个Lambda进去:
val fakeFetch: suspend () -> Data = { Data("fake") }
val repo = DataRepository(fakeFetch)
val result = repo.getData()
assertThat(result.name).isEqualTo("fake")
看到了吗?不需要Mock框架,不需要接口,一个Lambda就搞定了。这就是高阶函数的威力——把行为当作参数传递。
我的建议:如果依赖只有一个方法,优先考虑用函数类型代替接口。这样测试代码更轻量,也更容易理解。
单元测试与UI测试的简化:实战案例
说了这么多理论,我们来个完整的例子。假设我们要测试一个ViewModel,它依赖一个UseCase:
class MainViewModel(
private val getUserName: (Int) -> String
) : ViewModel() {
private val _name = MutableLiveData<String>()
val name: LiveData<String> get() = _name
fun loadUser(userId: Int) {
_name.value = getUserName(userId)
}
}
测试代码可以这样写:
class MainViewModelTest {
@Test
fun `loadUser should update name`() {
// 直接用高阶函数模拟依赖
val fakeGetName: (Int) -> String = { id ->
if (id == 1) "Alice" else "Unknown"
}
val viewModel = MainViewModel(fakeGetName)
viewModel.loadUser(1)
assertThat(viewModel.name.value).isEqualTo("Alice")
}
}
没有Mock,没有Stub,就是一个简单的Lambda。测试跑起来飞快,而且逻辑一目了然。
UI测试方面,我习惯用Compose的语义测试配合扩展函数。比如:
fun SemanticsNodeInteraction.shouldHaveText(expected: String) {
assert(this.fetchSemanticsNode().config[SemanticsProperties.text].toString().contains(expected))
}
// 使用
composeTestRule.onNodeWithTag("greeting").shouldHaveText("Hello")
这样写出来的UI测试,可读性非常高。团队成员一看就知道这个节点应该显示什么内容。
知识体系图
下面这张图总结了本章的核心知识点和它们之间的关系:
避坑指南:我曾经在项目里过度使用扩展函数,给所有类都加了自定义断言,结果导致测试代码难以维护。后来我总结了一个原则——扩展函数只封装“高频重复”的逻辑,低频的断言直接用原生API就好。
好了,这一章的内容就到这里。核心就三句话:MockK的DSL让Mock更自然,扩展函数让断言更简洁,高阶函数让依赖模拟更轻量。把这些技巧用起来,你的测试代码会变得像业务代码一样好读、好写、好维护。