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测试,可读性非常高。团队成员一看就知道这个节点应该显示什么内容。

知识体系图

下面这张图总结了本章的核心知识点和它们之间的关系:

扩展函数与高阶函数在测试中的应用 测试简化核心 MockK DSL语法 every / coEvery 高阶函数 returns / throws 扩展函数 → 流畅的Mock定义 测试扩展函数 shouldContainElement() shouldHaveItemCount() → 自定义断言 高阶函数模拟依赖 函数类型替代接口 Lambda作为构造参数 → 无需Mock框架 UI测试简化 Compose语义测试扩展 shouldHaveText() → 可读性提升

避坑指南:我曾经在项目里过度使用扩展函数,给所有类都加了自定义断言,结果导致测试代码难以维护。后来我总结了一个原则——扩展函数只封装“高频重复”的逻辑,低频的断言直接用原生API就好。

好了,这一章的内容就到这里。核心就三句话:MockK的DSL让Mock更自然,扩展函数让断言更简洁,高阶函数让依赖模拟更轻量。把这些技巧用起来,你的测试代码会变得像业务代码一样好读、好写、好维护。

公众号:蓝海资料掘金营,微信deep3321