第4章:创建基类 Fixture——抽象夹具、泛型设计、状态接口、初始化方法

本课程进入核心设计层:定义一个可复用的抽象夹具基类,通过泛型与状态接口约束,让所有具体的 ViewModel 状态注入夹具能够统一管理初始化、状态变更和测试断言。框架式夹具能让测试代码减少 40% 的模板代码,同时提升类型安全。

4.1 抽象夹具的设计原理

传统测试中,每个 ViewModel 的状态都需要单独创建「setup – assign – verify」代码块,状态错写或漏赋值的编译期错误难以捕获。抽象夹具基类采用 模板方法 + 泛型 模式:

  • 泛型参数 T : ViewModelState — 约束所有可注入状态必须实现统一接口。
  • 抽象方法 createInitialState() — 子类负责返回该 ViewModel 的初始状态实例。
  • 公共方法 injectInto(viewModel) — 基类实现状态注入逻辑。
核心收益: 任何新增 ViewModel 只需编写状态类并实现接口 + 继承夹具基类完成初始状态定义,即可获得通用的“重置 → 注入 → 验证”能力,杜绝重复的 LiveData/StateFlow 赋值代码。

4.2 状态接口定义

状态接口作为所有 ViewModel 状态的契约,通常包含三个基本字段,用于描述 UI 的通用生命周期:

字段类型说明
loadingBoolean是否处于加载中
errorString?错误信息,null 表示无错误
dataT?业务数据占位,具体类型由子接口决定

Kotlin 实现示例如下:

interface ViewModelState<T> {
    val loading: Boolean
    val error: String?
    val data: T?
}

// 具体业务状态
data class LoginState(
    override val loading: Boolean = false,
    override val error: String? = null,
    override val data: String? = null,  // 使用 String 作为示例数据
    val username: String = "",
    val password: String = ""
) : ViewModelState<String>

4.3 基类 Fixture 泛型设计

抽象夹具基类 ViewModelFixture 使用泛型 T : ViewModelState,并提供 var currentState: T 作为内部状态持有变量。以下是完整实现:

abstract class ViewModelFixture<T : ViewModelState<*>> {

    protected var currentState: T? = null

    // 子类必须实现:返回初始状态
    abstract fun createInitialState(): T

    // 子类可选重写:在注入前做的额外准备
    open fun onBeforeInject(state: T) {}

    // 核心注入方法:将状态写入 ViewModel 内部 StateFlow
    fun injectInto(viewModel: BaseViewModel<T>) {
        val state = currentState ?: createInitialState()
        onBeforeInject(state)
        viewModel._uiState.value = state
        currentState = state
    }

    // 快速重置到初始状态
    fun reset() {
        currentState = createInitialState()
    }

    // 链式修改状态(简化测试写法)
    fun update(block: T.() -> T) {
        val base = currentState ?: createInitialState()
        currentState = base.block()
    }
}
Abstract Fixture 基类与状态接口关系 ViewModelFixture<T> +currentState: T? +injectInto(viewModel) ViewModelState<T> loading: Boolean error: String? / data: T? 约束泛型 LoginState + username, password LoginFixture + createInitialState(): LoginState extends implements 测试中直接调用 val fixture = LoginFixture() fixture.injectInto(viewModel) // 一行注入初始状态

4.4 配置编写与 Gralde 依赖

如果使用专门的 fixture 模块(如 :test:fixtures),在模块级 build.gradle.kts 中添加如下依赖:

// 测试夹具模块依赖
dependencies {
    api(kotlin("test"))
    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    // 可选:添加 StateFlow testing 支持
    api("app.cash.turbine:turbine:1.0.0")
}
调试技巧: 在基类 injectInto 中加入 println("[Fixture] injected state: $state"),运行时可通过 Logcat 快速定位状态是否被正确覆盖。配合 Turbine 库 testScope.runTest 可原子化验证 StateFlow 发射。

4.5 常见错误与避坑

  • 未调用 reset() — 多次测试共享同一个 fixture 实例时,currentState 会保留上一个测试的修改值。建议在每个 @Before 中调用 fixture.reset()。
  • 状态接口泛型污染 — 若 ViewModelState 本身携带泛型 T,子类继承时必须明确指定具体类型,否则编译器会无法推断。
  • 重写 onBeforeInject 时忘记调用 super.onBeforeInject() — 若基类将来在 onBeforeInject 中添加日志等逻辑,子类覆盖时需调用 super 方法。

4.6 性能与稳定性优化

  • 避免在 createInitialState() 中进行 IO 操作 — 该方法在测试中高频调用,应保持纯内存对象创建。
  • 使用 inline 优化更新操作 — 对于 update 方法,可添加 inline 和 crossinline 修饰,减少 lambda 对象分配(在极端大量用例下有效)。
  • 状态快照拍摄 — 基类可增加 snapshot() 方法返回当前状态的不可变副本,用于测试中多步变更后对比。
⚠ 稳定性注意: 不要将 fixture 设计为单例。多个测试用例如果共享同一个 fixture 实例且没有正确重置,会导致状态污染,产生难以追踪的“闪烁测试”。每个测试方法都应创建新的 fixture 实例(或在 @Before 中 reset())。