第09章 测试策略与质量保证¶
学习目标:掌握Android应用测试方法,建立完整的质量保证体系。
预计学习时间:5-7天 实践时间:2-3天
目录¶
1. 测试基础¶
1.1 测试金字塔¶
Text Only
/\
/ \
/ UI \ UI测试(少量)
/------\
/Integration\ 集成测试(中等)
/--------------\
/ Unit Tests \ 单元测试(大量)
/------------------\
1.2 测试依赖配置¶
Kotlin
// build.gradle.kts
dependencies {
// 单元测试
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("app.cash.turbine:turbine:1.0.0")
// UI测试
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.4")
androidTestImplementation("androidx.navigation:navigation-testing:2.7.5")
// Hilt测试
androidTestImplementation("com.google.dagger:hilt-android-testing:2.57.1")
kspAndroidTest("com.google.dagger:hilt-compiler:2.57.1")
}
2. 单元测试¶
2.1 ViewModel测试¶
Kotlin
@ExperimentalCoroutinesApi
class NewsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: NewsViewModel
private lateinit var getNewsUseCase: GetNewsUseCase
@Before
fun setup() {
getNewsUseCase = mockk()
viewModel = NewsViewModel(getNewsUseCase)
}
@Test
fun `loadNews success updates uiState`() = runTest {
// Given
val news = listOf(NewsItem("1", "Title", "Content"))
coEvery { getNewsUseCase(any()) } returns Result.success(news)
// When
viewModel.onEvent(NewsEvent.Refresh)
// Then
viewModel.uiState.test {
assertEquals(NewsUiState(isLoading = true), awaitItem())
assertEquals(
NewsUiState(isLoading = false, news = news),
awaitItem()
)
cancel()
}
}
@Test
fun `loadNews failure shows error`() = runTest {
// Given
val error = Exception("Network error")
coEvery { getNewsUseCase(any()) } returns Result.failure(error)
// When
viewModel.onEvent(NewsEvent.Refresh)
// Then
viewModel.uiState.test {
assertEquals(NewsUiState(isLoading = true), awaitItem())
assertEquals(
NewsUiState(isLoading = false, error = "Network error"),
awaitItem()
)
cancel()
}
}
}
// 测试规则
@ExperimentalCoroutinesApi
class MainDispatcherRule : TestWatcher() {
private val testDispatcher = StandardTestDispatcher()
override fun starting(description: Description?) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
Dispatchers.resetMain()
}
}
2.2 Repository测试¶
Kotlin
class UserRepositoryTest {
private lateinit var repository: UserRepositoryImpl
private val userApi: UserApi = mockk()
private val userDao: UserDao = mockk()
@Before
fun setup() {
repository = UserRepositoryImpl(userApi, userDao, Dispatchers.Unconfined)
}
@Test
fun `getUser from network saves to local`() = runTest {
// Given
val userId = "123"
val userDto = UserDto(userId, "John", "john@example.com")
coEvery { userApi.getUser(userId) } returns userDto
coEvery { userDao.insertUser(any()) } just Runs
// When
val result = repository.getUser(userId)
// Then
assertTrue(result.isSuccess)
assertEquals(userId, result.getOrNull()?.id)
coVerify { userDao.insertUser(any()) }
}
@Test
fun `getUser network failure falls back to local`() = runTest {
// Given
val userId = "123"
val userEntity = UserEntity(userId, "John", "john@example.com")
coEvery { userApi.getUser(userId) } throws IOException()
coEvery { userDao.getUserById(userId) } returns userEntity
// When
val result = repository.getUser(userId)
// Then
assertTrue(result.isSuccess)
assertEquals(userId, result.getOrNull()?.id)
}
}
2.3 UseCase测试¶
Kotlin
class GetNewsUseCaseTest {
private val newsRepository: NewsRepository = mockk()
private val useCase = GetNewsUseCase(newsRepository)
@Test
fun `invoke returns news from repository`() = runTest {
// Given
val category = NewsCategory.TECH
val news = listOf(NewsItem("1", "Title", "Content"))
coEvery { newsRepository.getNews(category) } returns news
// When
val result = useCase(category).first()
// Then
assertEquals(Result.success(news), result)
}
}
3. UI测试¶
3.1 Compose UI测试¶
Kotlin
@HiltAndroidTest
class LoginScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var loginUseCase: LoginUseCase
@Before
fun init() {
hiltRule.inject()
}
@Test
fun loginScreen_displayedCorrectly() {
composeTestRule.setContent {
MyAppTheme {
LoginScreen()
}
}
// 验证元素存在
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
composeTestRule.onNodeWithText("Password").assertIsDisplayed()
composeTestRule.onNodeWithText("Login").assertIsDisplayed()
}
@Test
fun loginScreen_showsErrorForInvalidInput() {
composeTestRule.setContent {
MyAppTheme {
LoginScreen()
}
}
// 输入无效数据
composeTestRule.onNodeWithText("Email")
.performTextInput("invalid")
composeTestRule.onNodeWithText("Password")
.performTextInput("123")
// 点击登录
composeTestRule.onNodeWithText("Login").performClick()
// 验证错误显示
composeTestRule.onNodeWithText("Invalid email format")
.assertIsDisplayed()
}
@Test
fun loginScreen_navigatesOnSuccess() {
// 模拟成功登录
coEvery { loginUseCase(any(), any()) } returns Result.success(User("1", "John"))
composeTestRule.setContent {
MyAppTheme {
LoginScreen(onLoginSuccess = { /* 验证导航 */ })
}
}
// 输入有效数据
composeTestRule.onNodeWithText("Email")
.performTextInput("john@example.com")
composeTestRule.onNodeWithText("Password")
.performTextInput("password123")
composeTestRule.onNodeWithText("Login").performClick()
// 验证加载状态
composeTestRule.onNodeWithContentDescription("Loading")
.assertIsDisplayed()
}
}
3.2 Espresso测试¶
Kotlin
@RunWith(AndroidJUnit4::class)
@LargeTest
@HiltAndroidTest
class MainActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun navigation_betweenScreens() {
// 点击底部导航
onView(withId(R.id.navigation_home))
.perform(click())
// 验证Home页面
onView(withId(R.id.home_container))
.check(matches(isDisplayed()))
// 点击Profile
onView(withId(R.id.navigation_profile))
.perform(click())
// 验证Profile页面
onView(withId(R.id.profile_container))
.check(matches(isDisplayed()))
}
}
4. 集成测试¶
4.1 数据库测试¶
Kotlin
@RunWith(AndroidJUnit4::class)
@SmallTest
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).build()
userDao = database.userDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndRetrieveUser() = runTest {
// Given
val user = UserEntity("1", "John", "john@example.com")
// When
userDao.insertUser(user)
val retrieved = userDao.getUserById("1")
// Then
assertEquals(user, retrieved)
}
@Test
fun observeUsers_emitsUpdates() = runTest {
// Given
val user1 = UserEntity("1", "John", "john@example.com")
val user2 = UserEntity("2", "Jane", "jane@example.com")
// When & Then
userDao.getUsersFlow().test {
assertEquals(emptyList<UserEntity>(), awaitItem())
userDao.insertUser(user1)
assertEquals(listOf(user1), awaitItem())
userDao.insertUser(user2)
assertEquals(listOf(user1, user2), awaitItem())
cancel()
}
}
}
4.2 网络测试¶
Kotlin
class UserApiTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var userApi: UserApi
@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
userApi = retrofit.create(UserApi::class.java)
}
@After
fun tearDown() {
mockWebServer.shutdown()
}
@Test
fun `getUser returns parsed data`() = runTest {
// Given
val response = """
{
"id": "1",
"name": "John",
"email": "john@example.com"
}
""".trimIndent()
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(response)
)
// When
val result = userApi.getUser("1")
// Then
assertEquals("1", result.id)
assertEquals("John", result.name)
}
@Test
fun `getUser handles error response`() = runTest {
// Given
mockWebServer.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("{\"error\": \"User not found\"}")
)
// When & Then
assertThrows<HttpException> {
userApi.getUser("999")
}
}
}
5. 测试覆盖率¶
5.1 JaCoCo配置¶
Kotlin
// build.gradle.kts
plugins {
id("jacoco")
}
jacoco {
toolVersion = "0.8.11"
}
tasks.withType<Test> {
configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
tasks.register<JacocoReport>("jacocoFullReport") {
dependsOn("testDebugUnitTest", "createDebugCoverageReport")
reports {
xml.required.set(true)
html.required.set(true)
}
val fileFilter = listOf(
"**/R.class",
"**/R$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
"android/**/*.*"
)
val debugTree = fileTree("${buildDir}/tmp/kotlin-classes/debug") {
exclude(fileFilter)
}
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
classDirectories.setFrom(files(debugTree))
executionData.setFrom(fileTree(buildDir) {
include("jacoco/testDebugUnitTest.exec")
include("outputs/code_coverage/debugAndroidTest/connected/**/*.ec")
})
}
5.2 覆盖率报告解读¶
| 指标 | 说明 | 目标值 |
|---|---|---|
| Instruction Coverage | 字节码指令覆盖率 | > 80% |
| Branch Coverage | 分支覆盖率 | > 70% |
| Line Coverage | 代码行覆盖率 | > 80% |
| Method Coverage | 方法覆盖率 | > 80% |
| Class Coverage | 类覆盖率 | > 90% |
6. 实践练习¶
练习1:完整测试套件¶
任务:为一个功能模块编写完整的测试套件
要求: - 单元测试(ViewModel、UseCase、Repository) - UI测试(Compose) - 集成测试(Database) - 达到80%以上覆盖率
练习2:测试驱动开发¶
任务:使用TDD开发一个新功能
要求: - 先写测试,后写实现 - 红-绿-重构循环 - 提交历史展示TDD过程
本章小结¶
核心要点¶
- 测试金字塔指导测试比例:单元测试 > 集成测试 > UI测试
- 单元测试使用MockK模拟依赖,Turbine测试Flow
- UI测试使用Compose测试框架,验证用户交互
- 覆盖率是质量指标之一,但不是唯一目标
- TDD是良好的开发实践,提高代码质量
下一步¶
完成本章学习后,请进入第10章:部署流程与持续集成。
本章完成时间:预计5-7天