测试基础
本教程共 100 篇 · 第 98 篇 · 更新于 2026-07-28 · 约 9 分钟阅读
98. 测试基础
本节目标:理解 Android 测试的分类,学会用 JUnit 写单元测试、用 Espresso 写 UI 测试、用 Compose Testing 测可组合函数,掌握基本的测试思维。
写代码不难,改代码才难。改了一行,不知道有没有搞坏别的地方。测试就是给代码加一道保险——改完跑一下测试,绿了就放心。
测试分几类
打个比方,你造了一辆车。发动机单独测一下能不能转,这叫单元测试。把发动机装上车,在赛道上跑一圈,这叫集成测试。让试驾员坐进去踩油门打方向盘,这叫 UI 测试。
Android 测试分两大类:
| 类型 | 运行在哪 | 测什么 | 速度 |
|---|---|---|---|
| 本地单元测试 | JVM(开发机) | 纯 Kotlin 逻辑,不依赖 Android 框架 | 快(毫秒级) |
| Instrumented 测试 | 设备/模拟器 | 需要 Android 框架(Context、数据库、UI) | 慢(秒级) |
对应项目里的两个测试目录:
app/
├── src/
│ ├── main/ # 正式代码
│ ├── test/ # 本地单元测试(JVM)
│ └── androidTest/ # Instrumented 测试(设备)
test/ 下的测试不需要设备,跑在开发机的 JVM 上,速度快。androidTest/ 下的需要连真机或模拟器。
加测试依赖
模块级 build.gradle.kts:
dependencies {
// 本地单元测试
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
// Instrumented 测试
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// Compose 测试
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.6")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
}
Note
testImplementation给test/目录用,androidTestImplementation给androidTest/目录用。别搞混了。
JUnit 基础
JUnit 是 Java/Kotlin 生态最基础的测试框架。写个简单的:
// src/test/kotlin/com/example/app/CalculatorTest.kt
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int {
require(b != 0) { "除数不能为零" }
return a / b
}
}
测试类:
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class CalculatorTest {
private val calculator = Calculator()
@Test
fun add_两个正数_返回正确和() {
val result = calculator.add(2, 3)
assertEquals(5, result)
}
@Test
fun divide_除数为零_抛异常() {
assertThrows(IllegalArgumentException::class.java) {
calculator.divide(10, 0)
}
}
}
要点:
@Test标记测试方法。assertEquals(期望值, 实际值)断言相等。assertThrows断言抛出指定异常。- 方法名用中文描述场景,一眼看懂测的是啥。
TipAndroid Studio 里点测试类左边的绿色三角就能跑。或者命令行
./gradlew test。
测试 ViewModel
ViewModel 里有协程,测试时要控制协程调度器。用 kotlinx-coroutines-test:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class NoteViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun 加载笔记_正常返回_更新UI状态() = runTest {
// 准备假数据
val fakeRepo = FakeNoteRepository(listOf(Note(1, "测试笔记")))
val viewModel = NoteViewModel(fakeRepo)
// 执行
viewModel.loadNotes()
// 验证
assertEquals(1, viewModel.uiState.value.notes.size)
assertEquals("测试笔记", viewModel.uiState.value.notes[0].title)
}
}
关键是 Dispatchers.setMain(testDispatcher) 把主线程调度器换成测试调度器,这样协程不会真的切线程。runTest 会自动等待协程完成。
假对象和 Mock
测试 ViewModel 时,Repository 是依赖项。你不能连真数据库,得用假对象替换。
最简单的方式——手写假类:
class FakeNoteRepository(private val notes: List<Note>) : NoteRepository {
override suspend fun getNotes(): List<Note> = notes
override suspend fun saveNote(note: Note) {
// 啥也不做,或者记录调用
}
}
手写假类简单直接,小型项目够用。如果接口方法多,可以用 MockK 库自动生成:
// build.gradle.kts
testImplementation("io.mockk:mockk:1.13.12")
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class NoteViewModelTest {
@Test
fun 加载笔记_返回空列表_显示空状态() = runTest {
val mockRepo = mockk<NoteRepository>()
coEvery { mockRepo.getNotes() } returns emptyList()
val viewModel = NoteViewModel(mockRepo)
viewModel.loadNotes()
assert(viewModel.uiState.value.isEmpty)
}
@Test
fun 加载笔记_网络错误_显示错误信息() = runTest {
val mockRepo = mockk<NoteRepository>()
coEvery { mockRepo.getNotes() } throws RuntimeException("网络错误")
val viewModel = NoteViewModel(mockRepo)
viewModel.loadNotes()
assert(viewModel.uiState.value.error != null)
}
}
coEvery 模拟挂起函数的返回值,every 模拟普通函数。MockK 还能验证调用次数(verify)。
Tip测试覆盖率不必追求 100%。核心逻辑(计算、状态转换、边界条件)必须测,UI 细节酌情测。
Espresso UI 测试
Espresso 测传统 View 体系的 UI 交互。虽然 Compose 是主线,但如果你有 XML 布局,Espresso 仍然有用。
基本模式:找到控件 -> 执行操作 -> 验证结果。
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@Test
fun 输入账号密码_点击登录_显示欢迎信息() {
ActivityScenario.launch(LoginActivity::class.java)
// 找到输入框,输入内容
onView(withId(R.id.etUsername)).perform(typeText("admin"))
onView(withId(R.id.etPassword)).perform(typeText("123456"))
// 关闭键盘
onView(withId(R.id.etPassword)).perform(closeSoftKeyboard())
// 点击登录按钮
onView(withId(R.id.btnLogin)).perform(click())
// 验证欢迎信息出现
onView(withId(R.id.tvWelcome))
.check(matches(withText("欢迎, admin")))
}
}
Espresso 三板斧:
onView(...)找到控件。.perform(...)执行操作(输入、点击、滑动)。.check(matches(...))验证状态(文本、可见性、是否选中)。
Compose 测试
Compose 有自己的测试 API,比 Espresso 更简洁。核心是 composeTestRule。
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class NoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun 笔记列表_有数据_显示标题() {
composeTestRule.setContent {
NoteScreen(
uiState = NoteUiState(
notes = listOf(Note(1, "买菜"), Note(2, "写代码")),
isEmpty = false,
error = null
)
)
}
// 验证列表项显示
composeTestRule.onNodeWithText("买菜").assertIsDisplayed()
composeTestRule.onNodeWithText("写代码").assertIsDisplayed()
}
@Test
fun 空列表_显示空状态提示() {
composeTestRule.setContent {
NoteScreen(
uiState = NoteUiState(
notes = emptyList(),
isEmpty = true,
error = null
)
)
}
composeTestRule.onNodeWithText("暂无笔记").assertIsDisplayed()
}
@Test
fun 点击添加按钮_触发回调() {
var clicked = false
composeTestRule.setContent {
NoteScreen(
uiState = NoteUiState(notes = emptyList(), isEmpty = true, error = null),
onAddClick = { clicked = true }
)
}
composeTestRule.onNodeWithContentDescription("添加笔记").performClick()
assert(clicked)
}
}
常用查找方式:
| 方法 | 找什么 |
|---|---|
onNodeWithText("文本") | 按显示文本找 |
onNodeWithContentDescription("描述") | 按 contentDescription 找 |
onNodeWithTag("tag") | 按测试 tag 找 |
onAllNodesWithText("文本") | 找多个匹配项 |
Tip给关键可组合函数加
Modifier.testTag("tag"),测试时用onNodeWithTag精准定位。比按文本找更稳定,不怕改文案。
测试交互流程
测试「输入 -> 点击 -> 验证」的完整流程:
@Test
fun 输入笔记内容_点击保存_列表新增一条() {
composeTestRule.setContent {
val viewModel = NoteViewModel(FakeNoteRepository(emptyList()))
NoteScreen(viewModel = viewModel)
}
// 点击添加按钮
composeTestRule.onNodeWithContentDescription("添加").performClick()
// 输入标题
composeTestRule.onNodeWithTag("titleInput").performTextInput("新笔记")
// 点击保存
composeTestRule.onNodeWithText("保存").performClick()
// 验证列表出现新笔记
composeTestRule.onNodeWithText("新笔记").assertIsDisplayed()
}
Room 数据库测试
测 Room DAO 需要用真实数据库(内存模式):
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class NoteDaoTest {
private lateinit var database: AppDatabase
private lateinit var noteDao: NoteDao
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).allowMainThreadQueries().build()
noteDao = database.noteDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun 插入笔记_查询_返回正确数据() = runTest {
val note = Note(title = "测试", content = "内容")
noteDao.insert(note)
val notes = noteDao.getAll()
assertEquals(1, notes.size)
assertEquals("测试", notes[0].title)
}
@Test
fun 删除笔记_查询_列表为空() = runTest {
val note = Note(id = 1, title = "测试", content = "内容")
noteDao.insert(note)
noteDao.delete(note)
val notes = noteDao.getAll()
assert(notes.isEmpty())
}
}
inMemoryDatabaseBuilder 创建内存数据库,测试完自动销毁,不污染设备数据。allowMainThreadQueries 只在测试里用,正式代码别开。
测试组织结构
好的测试结构让人一目了然:
src/
├── test/ # 本地单元测试
│ └── kotlin/com/example/app/
│ ├── CalculatorTest.kt
│ ├── NoteViewModelTest.kt
│ └── fakes/
│ └── FakeNoteRepository.kt
└── androidTest/ # Instrumented 测试
└── kotlin/com/example/app/
├── NoteDaoTest.kt
├── NoteScreenTest.kt
└── LoginActivityTest.kt
命名约定:被测类名 + Test,如 CalculatorTest。方法名描述场景,如 add_两个正数_返回正确和。
测试技巧
Given-When-Then 模式
用注释把测试分三段,可读性更好:
@Test
fun 余额不足_转账_失败() = runTest {
// Given: 账户余额 100
val account = BankAccount(balance = 100)
val transfer = TransferService(account)
// When: 转账 200
val result = transfer.transfer(amount = 200)
// Then: 转账失败
assert(!result.success)
assertEquals(100, account.balance) // 余额不变
}
参数化测试
同一逻辑多组输入,用循环或参数化:
@Test
fun 加法_多组输入_返回正确结果() {
val cases = listOf(
Triple(1, 2, 3),
Triple(-1, 1, 0),
Triple(0, 0, 0),
Triple(100, 200, 300)
)
val calc = Calculator()
cases.forEach { (a, b, expected) ->
assertEquals(expected, calc.add(a, b))
}
}
常见坑
- 测试依赖放错目录:
testImplementation写的测试放在androidTest/下,编译不过。 - 协程测试忘
setMain:ViewModel 里用Dispatchers.Main,测试里不替换会卡住或报错。 - Compose 测试找不到节点:可能节点不可见(被覆盖、未渲染),或查找条件不唯一。用
printToLog打印节点树排查。 - Espresso 测试超时:异步操作没等完就断言。用
IdlingResource或onView().check的重试机制。 - 内存数据库忘 close:
@After里调database.close(),不然测试套件之间有残留。 - 测试方法名太随意:
test1、testAdd这种名字看不出测了啥,改用场景描述。
Warning测试不是越多越好。测核心逻辑和容易出 bug 的边界条件。UI 测试慢且脆,适量即可。没意义的测试(比如测 getter/setter)是负担不是资产。
小结
测试分本地单元测试(JVM,快)和 Instrumented 测试(设备,慢)。JUnit 是基础框架,@Test 标记方法,assertEquals 断言。ViewModel 测试用 runTest + Dispatchers.setMain,依赖用假类或 MockK 替换。Compose 测试用 composeTestRule,onNodeWithText / onNodeWithTag 查找节点,performClick / performTextInput 交互。Room 测试用内存数据库。测试命名描述场景,结构用 Given-When-Then。
下一章讲性能与内存优化。