跳转至

第09章 测试策略与质量保证

测试策略与质量保证图

学习目标:掌握Android应用测试方法,建立完整的质量保证体系。

预计学习时间:5-7天 实践时间:2-3天


目录

  1. 测试基础
  2. 单元测试
  3. UI测试
  4. 集成测试
  5. 测试覆盖率
  6. 实践练习

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过程


本章小结

核心要点

  1. 测试金字塔指导测试比例:单元测试 > 集成测试 > UI测试
  2. 单元测试使用MockK模拟依赖,Turbine测试Flow
  3. UI测试使用Compose测试框架,验证用户交互
  4. 覆盖率是质量指标之一,但不是唯一目标
  5. TDD是良好的开发实践,提高代码质量

下一步

完成本章学习后,请进入第10章:部署流程与持续集成


本章完成时间:预计5-7天