在Android开发中,单元测试是保证代码质量的第一道防线。作为一名有五年Android开发经验的工程师,我深刻体会到单元测试的重要性——它能在开发早期发现80%以上的基础逻辑错误。
单元测试是针对软件中最小可测试单元的验证过程。在Kotlin/Android环境下,这个"最小单元"通常指:
注意:涉及Android框架(如Context、Activity)的测试属于Instrumentation测试,需要运行在设备或模拟器上,不属于纯单元测试范畴。
标准的Android项目测试代码存放在两个目录:
code复制app/
├── src/
│ ├── main/ # 主代码
│ ├── test/ # 本地单元测试(无需设备)
│ └── androidTest/ # 仪器化测试(需要设备)
本地单元测试(本文重点)使用标准JVM运行,测试执行速度快(通常在毫秒级),是开发过程中最常用的测试手段。
在模块的build.gradle中添加基础依赖:
groovy复制dependencies {
// 基础测试框架
testImplementation "junit:junit:4.13.2"
// Kotlin协程测试支持
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4"
// Mock框架
testImplementation "io.mockk:mockk:1.13.4"
}
一个标准的测试类包含以下元素:
kotlin复制import org.junit.Test
import org.junit.Assert.*
class EmailValidatorTest {
@Test
fun `当输入标准邮箱格式时应返回true`() {
// Arrange - 准备测试数据
val email = "test@example.com"
// Act - 执行被测方法
val result = EmailValidator.isValid(email)
// Assert - 验证结果
assertTrue(result)
}
}
推荐使用以下命名风格之一:
下划线风格(传统JUnit风格):
kotlin复制@Test
fun emailValidator_CorrectEmailSimple_ReturnsTrue()
反引号描述风格(Kotlin特有,更易读):
kotlin复制@Test
fun `当输入标准邮箱格式时应返回true`()
JUnit提供多种断言方法:
| 方法 | 用途 | 示例 |
|---|---|---|
assertTrue |
验证条件为真 | assertTrue(result) |
assertEquals |
验证相等 | assertEquals(42, actual) |
assertNull |
验证为null | assertNull(obj) |
assertThrows |
验证抛出异常 | assertThrows<IllegalArgumentException> { code() } |
在实际项目中,被测对象通常依赖其他组件(如网络、数据库)。测试替身的主要价值在于:
| 类型 | 用途 | MockK实现 | 典型场景 |
|---|---|---|---|
| Mock | 验证交互行为 | mockk<T>() |
验证是否调用了支付接口 |
| Stub | 返回预设数据 | every { ... } returns ... |
模拟API返回测试数据 |
| Fake | 简化实现 | 实现轻量级替代类 | 内存数据库替代Room |
| Spy | 记录调用信息 | spyk<T>() |
统计缓存命中率 |
| Dummy | 填充参数 | mockk<T>(relaxed=true) |
填充不使用的依赖项 |
kotlin复制val userRepository = mockk<UserRepository>() // 严格模式mock
every { userRepository.findById(any()) } returns User("mockUser") // 定义行为
val user = userRepository.findById("123") // 返回预设值
verify { userRepository.findById("123") } // 验证调用
MockK提供丰富的参数匹配器:
kotlin复制every {
service.process(
ofType<Request>(), // 类型匹配
anyInt(), // 任意Int
isNull(), // null值
more(5), // 大于5
capture(slot) // 捕获参数
)
} returns Response.OK
kotlin复制verify {
// 精确验证
service.call(exact = "value")
// 宽松验证
service.call(any(), "important")
// 调用次数验证
service.call() wasCalled 3..5 times
}
测试协程代码需要特殊处理:
kotlin复制class CoroutineTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun cleanup() {
Dispatchers.resetMain()
}
@Test
fun `测试协程代码`() = runTest(testDispatcher) {
val viewModel = MyViewModel()
viewModel.loadData()
// 推进虚拟时间
advanceUntilIdle()
assertEquals(3, viewModel.items.size)
}
}
MockK可以mock静态方法和顶层函数:
kotlin复制@Test
fun testStaticMethod() {
mockkStatic(TextUtils::class)
every { TextUtils.isEmpty(any()) } returns false
assertFalse(TextUtils.isEmpty("")) // 返回mock值
unmockkStatic(TextUtils::class) // 清理
}
kotlin复制object ConfigManager {
fun getConfig() = "real"
}
@Test
fun testSingleton() {
mockkObject(ConfigManager)
every { ConfigManager.getConfig() } returns "test"
assertEquals("test", ConfigManager.getConfig())
unmockkObject(ConfigManager)
}
FIRST原则:
测试金字塔:
code复制UI测试 → 10%
Integration测试 → 20%
Unit测试 → 70%
问题1:测试随机失败
原因:共享状态未清理
解决:使用@Before重置测试环境
kotlin复制@Before
fun setup() {
MockKAnnotations.init(this)
mockkStatic(Utils::class)
}
@After
fun tearDown() {
unmockkAll()
}
问题2:测试执行慢
原因:误用真实依赖
解决:全面mock外部依赖
kotlin复制val db = mockk<Database> {
every { query(any()) } returns mockk {
every { execute() } returns TestData
}
}
问题3:过度验证
反模式:
kotlin复制verify {
service.call1()
service.call2()
// 过多验证导致测试脆弱
}
正确做法:只验证关键交互
kotlin复制@Test
fun `测试输入边界`() {
// 空输入
assertFalse(validator.isValid(""))
// 超长输入
val longString = "a".repeat(1000)
assertFalse(validator.isValid(longString))
// 非法字符
assertFalse(validator.isValid("test@测试.com"))
}
kotlin复制@Test
fun `测试异常情况`() {
val repo = mockk<UserRepository>()
every { repo.findById(any()) } throws UserNotFoundException()
assertThrows<ApiException> {
service.getUserProfile("invalid")
}
}
使用JUnit 5的@ParameterizedTest:
kotlin复制@ParameterizedTest
@ValueSource(strings = [
"test@example.com",
"name+tag@example.co.uk",
"first.last@sub.domain.com"
])
fun `有效邮箱格式测试`(email: String) {
assertTrue(EmailValidator.isValid(email))
}
将重复代码提取为扩展函数:
kotlin复制fun <T> mockkWithDefaults(clazz: KClass<T>): T {
return mockk<T>(relaxUnitFun = true) {
every { anyNonNullMethod<T>() } answers {
defaultAnswer(this.call)
}
}
}
// 使用
val service = mockkWithDefaults(MyService::class)
kotlin复制object TestDataFactory {
fun createUser(
id: String = "testId",
name: String = "Test User"
) = User(id, name)
}
// 使用
val user = TestDataFactory.createUser(name = "Custom")
kotlin复制fun assertUser(expected: User, actual: User) {
assertEquals(expected.id, actual.id)
assertEquals(expected.name, actual.name)
// 更多字段比较...
}
// 使用
assertUser(expectedUser, actualUser)
| 特性 | MockK | Mockito | PowerMock |
|---|---|---|---|
| Kotlin支持 | 原生支持 | 需要额外库 | 有限支持 |
| Final类mock | 直接支持 | 需要配置 | 支持 |
| 协程支持 | 完善 | 有限 | 不支持 |
| 静态方法mock | 支持 | 需要PowerMock | 支持 |
| 学习曲线 | 中等 | 简单 | 陡峭 |
kotlin复制assertThat(user).isNotNull()
assertThat(list).containsExactly("a", "b")
kotlin复制user.shouldNotBeNull()
list.shouldContain("a")
在gradle.properties中启用并行测试:
code复制org.gradle.parallel=true
org.gradle.workers.max=4
使用@Tag注解分类测试:
kotlin复制@Tag("fast")
class FastTests { /*...*/ }
@Tag("slow")
class IntegrationTests { /*...*/ }
然后通过Gradle命令执行特定分组:
bash复制./gradlew test --tests "*FastTests*"
配置JaCoCo生成覆盖率报告:
groovy复制android {
buildTypes {
debug {
testCoverageEnabled true
}
}
}
// 生成报告命令
// ./gradlew createDebugCoverageReport
在电商App项目中,我们通过单元测试发现了几个关键问题:
优惠券计算逻辑错误:
缓存一致性问题:
日期处理时区问题:
测试代码与生产代码的比例最终维持在1:1.5左右,关键业务类覆盖率超过85%。在持续集成环境中,每次提交都会运行约2000个单元测试,平均执行时间控制在3分钟以内。