首页 / Android 入门教程 / 测试基础

Android 入门教程

测试基础

本教程共 100 篇 · 第 98 篇 · 更新于 2026-07-28 · 约 9 分钟阅读

AndroidAndroid 入门教程测试JUnitEspressoCompose 测试单元测试

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

testImplementationtest/ 目录用,androidTestImplementationandroidTest/ 目录用。别搞混了。

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 断言抛出指定异常。
  • 方法名用中文描述场景,一眼看懂测的是啥。
Tip

Android 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 三板斧:

  1. onView(...) 找到控件。
  2. .perform(...) 执行操作(输入、点击、滑动)。
  3. .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))
    }
}

常见坑

  1. 测试依赖放错目录testImplementation 写的测试放在 androidTest/ 下,编译不过。
  2. 协程测试忘 setMain:ViewModel 里用 Dispatchers.Main,测试里不替换会卡住或报错。
  3. Compose 测试找不到节点:可能节点不可见(被覆盖、未渲染),或查找条件不唯一。用 printToLog 打印节点树排查。
  4. Espresso 测试超时:异步操作没等完就断言。用 IdlingResourceonView().check 的重试机制。
  5. 内存数据库忘 close@After 里调 database.close(),不然测试套件之间有残留。
  6. 测试方法名太随意test1testAdd 这种名字看不出测了啥,改用场景描述。
Warning

测试不是越多越好。测核心逻辑和容易出 bug 的边界条件。UI 测试慢且脆,适量即可。没意义的测试(比如测 getter/setter)是负担不是资产。

小结

测试分本地单元测试(JVM,快)和 Instrumented 测试(设备,慢)。JUnit 是基础框架,@Test 标记方法,assertEquals 断言。ViewModel 测试用 runTest + Dispatchers.setMain,依赖用假类或 MockK 替换。Compose 测试用 composeTestRuleonNodeWithText / onNodeWithTag 查找节点,performClick / performTextInput 交互。Room 测试用内存数据库。测试命名描述场景,结构用 Given-When-Then。

下一章讲性能与内存优化。