在 Android 项目中,Utilities 通常指存放格式转换、日期处理、文件读写、屏幕适配等静态方法的工具集合。这类代码被上层业务频繁调用,一旦出错影响面极广。与界面或组件测试不同,实用程序测试更关注输入输出的确定性和异常分支的完整性,因此应当优先在本地 JVM 环境完成,避免每次运行都启动模拟器拖慢反馈速度。

纯静态工具方法的 JVM 单元测试
最常见的 Utilities 是只包含 public static 方法的类,例如字符串判空、dp 与 px 转换、时间戳格式化等。这类方法不依赖 Android 运行环境,可以直接使用 JUnit 4 或 JUnit 5 在本地 JVM 中测试,速度极快且无需任何框架注入。测试重点应放在等价类划分上:合法值、边界值、空值与异常值都必须覆盖。
以字符串工具为例,我们需要验证 isBlank 方法在 null、空串、纯空格时的返回结果,同时确认其不会抛出未声明异常。下面是一段 Kotlin 工具及其对应测试:
object StringUtils {
fun isBlank(input: String?): Boolean {
return input == null || input.trim().isEmpty()
}
}
class StringUtilsTest {
@Test
fun testIsBlank() {
assert(StringUtils.isBlank(null))
assert(StringUtils.isBlank(""))
assert(StringUtils.isBlank(" "))
assert(!StringUtils.isBlank("abc"))
}
}
这种写法的优势在于用例执行时间在毫秒级,可放入每次 git commit 的 pre-commit 钩子。缺点是当工具方法内部引用了 android.util.Log 或 Build.VERSION 等 Android 专有类时,纯 JVM 测试会直接抛出 NoClassDefFoundError,此时必须引入 Robolectric 或将其依赖抽象出来。
对于包含数学计算的工具,如颜色混合、贝塞尔曲线估值,除了正常用例,还应构造浮点精度边界。建议用 assertArrayEquals 并指定 delta 容差,避免由于 IEEE 754 精度问题导致测试偶发失败。工具类虽小,但累积起来的可靠性决定了业务层是否敢放心复用。
依赖 Context 或系统资源的 Utilities 测试方案
现实中的 Utilities 经常需要读取 Context 获取屏幕密度、字符串资源或系统服务,例如 dp2px 需要 Resources.getDisplayMetrics(),文件工具需要 context.filesDir。直接在 JVM 测试里 new 一个 Context 是不可能的,传统做法是把 Context 作为参数传入并交由调用方管理,测试时用 MockK 伪造返回数据。
MockK 提供了 mockk() 与 every 组合,可以精确控制 Context 的行为。以下示例展示如何测试一个依赖资源的文本获取工具:
object ResUtils {
fun getAppName(context: Context): String {
return context.getString(R.string.app_name)
}
}
@Test
fun testGetAppName() {
val ctx = mockk<Context>()
every { ctx.getString(R.string.app_name) } returns "DemoApp"
assert(ResUtils.getAppName(ctx) == "DemoApp")
}
如果工具类将 Context 保存为内部静态变量(反例),测试就会因状态污染而相互干扰。更好的设计是采用依赖注入或参数传递,使方法保持纯函数特性。对于必须使用 Application 场景的,可启用 Robolectric 的 @RunWith(RobolectricTestRunner::class),它会在 JVM 内模拟 Android 环境,允许真实调用资源与生命周期,但构建速度明显慢于纯 MockK。
选择方案时建议遵循测试金字塔:百分之八十的 Utilities 用 MockK 做轻量隔离,仅当逻辑强耦合资源且难以抽离时再用 Robolectric。这样既能保证覆盖率,又不会让本地测试套件慢到被开发者绕过。
异步、边界与异常分支的覆盖策略
不少 Utilities 封装了线程切换、IO 操作或加密计算,这类代码最容易被漏测的是异常路径。例如文件工具在外部存储不可用时应当抛出自定义异常而非崩溃,日期工具在解析非法字符串时要返回默认值而不是 NumberFormatException 外泄。测试时必须主动构造失败场景。
对于使用 Coroutine 的挂起工具方法,应当用 runTest 作用域并指定 Dispatchers.Unconfined 或 StandardTestDispatcher 来控制执行顺序。下面的代码演示了如何验证一个带超时的网络工具:
suspend fun fetchWithTimeout(url: String, timeMillis: Long): String {
return withTimeout(timeMillis) {
delay(50)
"ok"
}
}
@Test
fun testTimeout() = runTest {
try {
withTimeout(10) {
delay(50)
"never"
}
} catch (e: TimeoutCancellationException) {
assert(true)
return@runTest
}
assert(false)
}
除了异常,时区与 locale 也是 Utilities 测试的高频坑。同一个时间戳格式化方法在北京时区和伦敦时区输出不同,若测试机环境固定则永远发现不了。解决办法是在用例里显式设置 TimeZone.setDefault() 与 Locale.setDefault(),分别验证多地区表现,并在 CI 配置中跑一组不同语言环境的矩阵任务。
最后建议为 Utilities 模块配置 JaCoCo 覆盖率门槛,例如行覆盖不低于百分之八十五,分支覆盖不低于百分之七十。当新提交导致门槛下降时阻断合并,从机制上迫使开发者补测试。配合上述分层策略,Android 实用程序测试就能做到既快又全,真正发挥防护网作用。