01-03-14 Jetpack组件测试策略

标签:单元测试 集成测试 UI测试 测试工具链
系列范围: 基于Blog #301-350的测试实践总结

🎯 本文目标

掌握Jetpack组件的完整测试策略,包括:

  • 单元测试、集成测试、UI测试
  • 各组件的测试最佳实践
  • Mock、Fake、Stub的使用
  • 完整的测试工具链

📋 1. Jetpack组件测试概览

1.1 测试金字塔

         /\
        /  \       UI Tests (10%)
       /    \      - Espresso
      /------\     - Compose UI Test
     /        \
    /          \   Integration Tests (20%)
   /            \  - Room + DAO
  /--------------\ - Repository + API
 /                \
/                  \ Unit Tests (70%)
--------------------
- ViewModel         - UseCase
- Repository        - Mapper
- Utils             - Extensions

1.2 测试分类

测试类型 覆盖范围 工具 占比
单元测试 单个类/函数 JUnit5, MockK 70%
集成测试 多个组件协作 JUnit5, Hilt, Room 20%
UI测试 用户交互 Espresso, Compose UI Test 10%

1.3 测试依赖

// build.gradle.kts
dependencies {
    // 单元测试
    testImplementation("junit:junit:4.13.2")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("app.cash.turbine:turbine:1.0.0")
    testImplementation("com.google.truth:truth:1.1.5")

    // Android测试
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.arch.core:core-testing:2.2.0")

    // Hilt测试
    androidTestImplementation("com.google.dagger:hilt-android-testing:2.48")
    kaptAndroidTest("com.google.dagger:hilt-compiler:2.48")

    // Room测试
    testImplementation("androidx.room:room-testing:2.6.0")

    // Navigation测试
    androidTestImplementation("androidx.navigation:navigation-testing:2.7.5")

    // WorkManager测试
    androidTestImplementation("androidx.work:work-testing:2.9.0")

    // Paging测试
    testImplementation("androidx.paging:paging-common:3.2.1")

    // Compose UI测试
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.4")
    debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4")
}

2️⃣ Lifecycle测试

2.1 测试Lifecycle事件

/**
 * 测试LifecycleObserver
 */
class LocationObserverTest {

    private lateinit var lifecycleOwner: LifecycleOwner
    private lateinit var lifecycle: LifecycleRegistry
    private lateinit var observer: LocationObserver

    @Before
    fun setup() {
        lifecycleOwner = mock()
        lifecycle = LifecycleRegistry(lifecycleOwner)
        observer = LocationObserver(ApplicationProvider.getApplicationContext())

        lifecycle.addObserver(observer)
    }

    @Test
    fun `when lifecycle starts, should start location updates`() {
        // When
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)

        // Then
        // 验证位置监听已启动
        assertTrue(observer.isLocationTrackingActive())
    }

    @Test
    fun `when lifecycle stops, should stop location updates`() {
        // Given
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)

        // When
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)

        // Then
        // 验证位置监听已停止
        assertFalse(observer.isLocationTrackingActive())
    }

    @Test
    fun `when lifecycle destroyed, should clean up resources`() {
        // When
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)

        // Then
        // 验证资源已释放
        assertNull(observer.getLocationManager())
    }
}

2.2 测试ProcessLifecycleOwner

/**
 * 测试应用前后台切换
 */
@HiltAndroidTest
class AppLifecycleObserverTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var appLifecycleObserver: AppLifecycleObserver

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun `when app goes to foreground, should start heartbeat`() {
        // Given
        val lifecycle = LifecycleRegistry(mock())
        lifecycle.addObserver(appLifecycleObserver)

        // When
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)

        // Then
        assertTrue(appLifecycleObserver.isHeartbeatActive())
    }

    @Test
    fun `when app goes to background, should stop heartbeat`() {
        // Given
        val lifecycle = LifecycleRegistry(mock())
        lifecycle.addObserver(appLifecycleObserver)
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)

        // When
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)

        // Then
        assertFalse(appLifecycleObserver.isHeartbeatActive())
    }
}

3️⃣ ViewModel测试

3.1 测试规则

/**
 * InstantTaskExecutorRule: LiveData同步执行
 * MainCoroutineRule: 协程在测试线程执行
 */
class DeviceListViewModelTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: DeviceListViewModel
    private lateinit var repository: IDeviceRepository

    @Before
    fun setup() {
        repository = mockk()
        viewModel = DeviceListViewModel(
            getDevicesUseCase = GetDevicesUseCase(repository)
        )
    }

    @Test
    fun `when loadDevices succeeds, should emit Success state`() = runTest {
        // Given
        val devices = listOf(
            Device("1", "Camera 1", true),
            Device("2", "Camera 2", false)
        )
        coEvery { repository.getDevicesStream() } returns flowOf(devices)

        // When
        viewModel.loadDevices()

        // Then
        val state = viewModel.uiState.value
        assertTrue(state is DeviceUiState.Success)
        assertEquals(2, (state as DeviceUiState.Success).devices.size)
    }

    @Test
    fun `when loadDevices fails, should emit Error state`() = runTest {
        // Given
        coEvery { repository.getDevicesStream() } throws IOException("Network error")

        // When
        viewModel.loadDevices()

        // Then
        val state = viewModel.uiState.value
        assertTrue(state is DeviceUiState.Error)
        assertEquals("Network error", (state as DeviceUiState.Error).message)
    }
}

/**
 * MainCoroutineRule: 替换Dispatchers.Main
 */
@ExperimentalCoroutinesApi
class MainCoroutineRule(
    private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {

    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

3.2 测试StateFlow

/**
 * 使用Turbine测试StateFlow
 */
class DeviceViewModelTest {

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: DeviceViewModel
    private lateinit var repository: IDeviceRepository

    @Before
    fun setup() {
        repository = mockk()
        viewModel = DeviceViewModel(repository)
    }

    @Test
    fun `should emit loading then success states`() = runTest {
        // Given
        val devices = listOf(Device("1", "Camera", true))
        coEvery { repository.getDevicesStream() } returns flowOf(devices)

        // When & Then
        viewModel.devices.test {
            // 初始值
            assertEquals(emptyList<Device>(), awaitItem())

            // 加载后的值
            viewModel.loadDevices()
            assertEquals(devices, awaitItem())

            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `should emit events correctly`() = runTest {
        // When & Then
        viewModel.events.test {
            viewModel.onDeviceClicked(Device("1", "Camera", true))

            val event = awaitItem()
            assertTrue(event is DeviceEvent.NavigateToDetail)
            assertEquals("1", (event as DeviceEvent.NavigateToDetail).deviceId)

            cancelAndIgnoreRemainingEvents()
        }
    }
}

3.3 测试协程

/**
 * 测试viewModelScope中的协程
 */
class EventListViewModelTest {

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: EventListViewModel
    private lateinit var repository: IEventRepository

    @Before
    fun setup() {
        repository = mockk()
        viewModel = EventListViewModel(repository)
    }

    @Test
    fun `should load events when initialized`() = runTest {
        // Given
        val events = listOf(Event("1", "Motion detected"))
        coEvery { repository.getEventsStream() } returns flowOf(events)

        // When
        // ViewModel构造函数中已调用loadEvents()
        advanceUntilIdle() // 等待协程执行完毕

        // Then
        val state = viewModel.uiState.value
        assertTrue(state is EventUiState.Success)
        assertEquals(1, (state as EventUiState.Success).events.size)
    }

    @Test
    fun `should handle concurrent operations`() = runTest {
        // Given
        coEvery { repository.refreshEvents() } coAnswers {
            delay(1000)
        }

        // When
        val job1 = launch { viewModel.refresh() }
        val job2 = launch { viewModel.refresh() }

        // 快速推进时间
        advanceTimeBy(1000)

        // Then
        job1.join()
        job2.join()
        coVerify(exactly = 2) { repository.refreshEvents() }
    }
}

4️⃣ LiveData测试

4.1 测试LiveData观察

/**
 * 使用InstantTaskExecutorRule
 */
class DeviceLiveDataViewModelTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var viewModel: DeviceLiveDataViewModel

    @Before
    fun setup() {
        val repository = mockk<IDeviceRepository>()
        viewModel = DeviceLiveDataViewModel(repository)
    }

    @Test
    fun `should emit device name`() {
        // Given
        val device = Device("1", "Camera 1", true)

        // When
        viewModel.setDevice(device)

        // Then
        assertEquals("Camera 1", viewModel.deviceName.value)
    }

    @Test
    fun `should transform device to display name`() {
        // Given
        val device = Device("1", "Camera 1", true)
        coEvery { viewModel.repository.getDevice("1") } returns device

        // When
        viewModel.loadDevice("1")

        // Then
        assertEquals("Camera 1 (Online)", viewModel.displayName.value)
    }
}

/**
 * 测试MediatorLiveData
 */
class DashboardViewModelTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var viewModel: DashboardViewModel
    private lateinit var deviceRepository: IDeviceRepository
    private lateinit var eventRepository: IEventRepository

    @Before
    fun setup() {
        deviceRepository = mockk()
        eventRepository = mockk()
        viewModel = DashboardViewModel(deviceRepository, eventRepository)
    }

    @Test
    fun `should combine devices and events`() {
        // Given
        val devices = listOf(Device("1", "Camera", true))
        val events = listOf(Event("1", "Motion"))

        every { deviceRepository.getDevicesLiveData() } returns MutableLiveData(devices)
        every { eventRepository.getRecentEventsLiveData() } returns MutableLiveData(events)

        // When
        viewModel.loadDashboard()

        // Then
        val dashboard = viewModel.dashboard.value
        assertNotNull(dashboard)
        assertEquals(1, dashboard.deviceCount)
        assertEquals(1, dashboard.recentEvents.size)
    }
}

4.2 测试observeForever

/**
 * 使用observeForever测试
 */
class DeviceNameViewModelTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var viewModel: DeviceNameViewModel

    @Before
    fun setup() {
        viewModel = DeviceNameViewModel()
    }

    @Test
    fun `should observe value with observeForever`() {
        // Given
        val observer = mockk<Observer<String>>(relaxed = true)
        viewModel.deviceName.observeForever(observer)

        // When
        viewModel.updateName("Camera 1")
        viewModel.updateName("Camera 2")

        // Then
        verifySequence {
            observer.onChanged("Camera 1")
            observer.onChanged("Camera 2")
        }

        // Clean up
        viewModel.deviceName.removeObserver(observer)
    }
}

5️⃣ DataBinding测试

5.1 测试数据绑定

/**
 * 测试DataBinding
 */
@RunWith(AndroidJUnit4::class)
class DeviceItemBindingTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(TestActivity::class.java)

    private lateinit var binding: ItemDeviceBinding

    @Before
    fun setup() {
        activityRule.scenario.onActivity { activity ->
            binding = ItemDeviceBinding.inflate(activity.layoutInflater)
            activity.setContentView(binding.root)
        }
    }

    @Test
    fun `should bind device name`() {
        // Given
        val device = Device("1", "Camera 1", true)

        // When
        binding.device = device
        binding.executePendingBindings()

        // Then
        assertEquals("Camera 1", binding.deviceName.text.toString())
    }

    @Test
    fun `should update visibility based on device status`() {
        // Given
        val device = Device("1", "Camera", true)

        // When
        binding.device = device
        binding.executePendingBindings()

        // Then
        assertEquals(View.VISIBLE, binding.statusIndicator.visibility)
    }
}

5.2 测试BindingAdapter

/**
 * 测试自定义BindingAdapter
 */
@RunWith(AndroidJUnit4::class)
class BindingAdapterTest {

    @Test
    fun `imageUrl BindingAdapter should load image`() {
        // Given
        val imageView = ImageView(ApplicationProvider.getApplicationContext())
        val url = "https://example.com/image.jpg"

        // When
        BindingAdapters.loadImage(imageView, url)

        // Then
        // 验证Glide.with()被调用
        // 需要使用Robolectric或Mockk
    }

    @Test
    fun `deviceStatus BindingAdapter should set correct text`() {
        // Given
        val textView = TextView(ApplicationProvider.getApplicationContext())
        val device = Device("1", "Camera", true)

        // When
        BindingAdapters.setDeviceStatus(textView, device)

        // Then
        assertEquals("Online", textView.text.toString())
    }
}

6️⃣ Room测试

6.1 内存数据库测试

/**
 * 使用内存数据库测试DAO
 */
@RunWith(AndroidJUnit4::class)
class DeviceDaoTest {

    private lateinit var database: deviceSecurityDatabase
    private lateinit var deviceDao: DeviceDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        database = Room.inMemoryDatabaseBuilder(
            context,
            deviceSecurityDatabase::class.java
        )
            .allowMainThreadQueries() // 测试时允许主线程查询
            .build()
        deviceDao = database.deviceDao()
    }

    @After
    fun closeDb() {
        database.close()
    }

    @Test
    fun insertAndReadDevice() = runBlocking {
        // Given
        val device = DeviceEntity("1", "Camera", true)

        // When
        deviceDao.insertDevice(device)
        val loaded = deviceDao.getDeviceById("1")

        // Then
        assertNotNull(loaded)
        assertEquals("Camera", loaded?.deviceName)
        assertTrue(loaded?.isOnline == true)
    }

    @Test
    fun insertMultipleDevices() = runBlocking {
        // Given
        val devices = listOf(
            DeviceEntity("1", "Camera 1", true),
            DeviceEntity("2", "Camera 2", false)
        )

        // When
        deviceDao.insertDevices(devices)
        val allDevices = deviceDao.getAllDevicesOnce()

        // Then
        assertEquals(2, allDevices.size)
    }

    @Test
    fun updateDevice() = runBlocking {
        // Given
        val device = DeviceEntity("1", "Camera", true)
        deviceDao.insertDevice(device)

        // When
        deviceDao.updateDeviceStatus("1", false)
        val updated = deviceDao.getDeviceById("1")

        // Then
        assertFalse(updated?.isOnline == true)
    }

    @Test
    fun deleteDevice() = runBlocking {
        // Given
        val device = DeviceEntity("1", "Camera", true)
        deviceDao.insertDevice(device)

        // When
        deviceDao.deleteDevice(device)
        val deleted = deviceDao.getDeviceById("1")

        // Then
        assertNull(deleted)
    }

    @Test
    fun observeDevicesFlow() = runBlocking {
        // Given
        val devices = listOf(
            DeviceEntity("1", "Camera 1", true),
            DeviceEntity("2", "Camera 2", false)
        )

        // When & Then
        deviceDao.observeDevices().test {
            // 初始为空
            assertEquals(emptyList<DeviceEntity>(), awaitItem())

            // 插入数据
            deviceDao.insertDevices(devices)
            assertEquals(2, awaitItem().size)

            // 更新数据
            deviceDao.updateDeviceStatus("1", false)
            val updated = awaitItem()
            assertEquals(2, updated.size)
            assertFalse(updated[0].isOnline)

            cancelAndIgnoreRemainingEvents()
        }
    }
}

6.2 测试Migration

/**
 * 测试数据库迁移
 */
@RunWith(AndroidJUnit4::class)
class MigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        deviceSecurityDatabase::class.java
    )

    @Test
    fun migrate1To2() {
        // Given: 创建版本1的数据库
        val db = helper.createDatabase(TEST_DB, 1).apply {
            execSQL("INSERT INTO devices VALUES ('1', 'Camera', 1)")
            close()
        }

        // When: 执行迁移到版本2
        helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

        // Then: 验证新字段存在
        val db2 = getMigrationHelper().runMigrationsAndValidate(TEST_DB, 2, false)
        val cursor = db2.query("SELECT firmware_version FROM devices WHERE deviceId = '1'")
        cursor.moveToFirst()
        assertEquals("1.0.0", cursor.getString(0))
        cursor.close()
    }

    @Test
    fun migrateAll() {
        // Create version 1
        helper.createDatabase(TEST_DB, 1).apply {
            execSQL("INSERT INTO devices VALUES ('1', 'Camera', 1)")
            close()
        }

        // Migrate to latest version
        helper.runMigrationsAndValidate(
            TEST_DB,
            4,
            true,
            MIGRATION_1_2,
            MIGRATION_2_3,
            MIGRATION_3_4
        )

        // Verify data integrity
        Room.databaseBuilder(
            ApplicationProvider.getApplicationContext(),
            deviceSecurityDatabase::class.java,
            TEST_DB
        ).build().apply {
            val device = deviceDao().getDeviceByIdSync("1")
            assertNotNull(device)
            assertEquals("Camera", device?.deviceName)
            close()
        }
    }

    companion object {
        private const val TEST_DB = "migration_test"
    }
}

7️⃣ Navigation测试

7.1 测试Fragment导航

/**
 * 测试Navigation
 */
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class NavigationTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun testNavigateToDeviceDetail() {
        // Given
        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        navController.setGraph(R.navigation.main_nav_graph)

        val scenario = launchFragmentInHiltContainer<DeviceListFragment> {
            viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
                if (event == Lifecycle.Event.ON_RESUME) {
                    Navigation.setViewNavController(requireView(), navController)
                }
            })
        }

        // When
        onView(withId(R.id.device_item)).perform(click())

        // Then
        assertEquals(R.id.deviceDetailFragment, navController.currentDestination?.id)
    }

    @Test
    fun testNavigateWithArguments() {
        // Given
        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        navController.setGraph(R.navigation.main_nav_graph)

        val scenario = launchFragmentInHiltContainer<DeviceListFragment> {
            Navigation.setViewNavController(requireView(), navController)
        }

        // When
        val deviceId = "device-123"
        scenario.onFragment { fragment ->
            fragment.findNavController().navigate(
                DeviceListFragmentDirections.actionToDetail(deviceId)
            )
        }

        // Then
        assertEquals(R.id.deviceDetailFragment, navController.currentDestination?.id)
        assertEquals(deviceId, navController.backStack.last().arguments?.getString("deviceId"))
    }
}

7.2 测试DeepLink

/**
 * 测试DeepLink
 */
@RunWith(AndroidJUnit4::class)
class DeepLinkTest {

    @Test
    fun testDeepLinkNavigation() {
        // Given
        val deepLinkUri = Uri.parse("devicesecurity://device/device-123")

        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        navController.setGraph(R.navigation.main_nav_graph)

        // When
        navController.navigate(deepLinkUri)

        // Then
        assertEquals(R.id.deviceDetailFragment, navController.currentDestination?.id)
        assertEquals("device-123", navController.backStack.last().arguments?.getString("deviceId"))
    }
}

8️⃣ WorkManager测试

8.1 测试Worker

/**
 * 测试Worker
 */
@RunWith(AndroidJUnit4::class)
class DeviceSyncWorkerTest {

    private lateinit var context: Context
    private lateinit var executor: Executor

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        executor = Executors.newSingleThreadExecutor()
    }

    @Test
    fun testDeviceSyncWorker() {
        // Given
        val inputData = workDataOf("deviceId" to "device-123")
        val request = OneTimeWorkRequestBuilder<DeviceSyncWorker>()
            .setInputData(inputData)
            .build()

        // When
        val worker = TestWorkerBuilder<DeviceSyncWorker>(
            context = context,
            executor = executor,
            inputData = inputData
        ).build()

        val result = worker.doWork()

        // Then
        assertTrue(result is ListenableWorker.Result.Success)
    }

    @Test
    fun testWorkerWithHilt() = runBlocking {
        // Given
        val inputData = workDataOf("deviceId" to "device-123")

        // When
        val worker = TestListenableWorkerBuilder<DeviceSyncWorker>(context)
            .setInputData(inputData)
            .build()

        val result = worker.startWork().get()

        // Then
        assertTrue(result is ListenableWorker.Result.Success)
    }
}

8.2 测试WorkManager调度

/**
 * 测试WorkManager调度
 */
@RunWith(AndroidJUnit4::class)
class WorkManagerSchedulingTest {

    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val config = Configuration.Builder()
            .setMinimumLoggingLevel(Log.DEBUG)
            .setExecutor(SynchronousExecutor())
            .build()

        WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
    }

    @Test
    fun testPeriodicWork() {
        // Given
        val request = PeriodicWorkRequestBuilder<EventSyncWorker>(15, TimeUnit.MINUTES)
            .build()

        val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext())

        // When
        workManager.enqueue(request).result.get()

        val workInfo = workManager.getWorkInfoById(request.id).get()

        // Then
        assertEquals(WorkInfo.State.ENQUEUED, workInfo.state)
    }

    @Test
    fun testWorkConstraints() {
        // Given
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()

        val request = OneTimeWorkRequestBuilder<DeviceSyncWorker>()
            .setConstraints(constraints)
            .build()

        val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext())

        // When
        workManager.enqueue(request).result.get()

        // Then
        val workInfo = workManager.getWorkInfoById(request.id).get()
        assertEquals(constraints, workInfo.constraints)
    }

    @Test
    fun testWorkChain() {
        // Given
        val download = OneTimeWorkRequestBuilder<DownloadWorker>().build()
        val process = OneTimeWorkRequestBuilder<ProcessWorker>().build()
        val upload = OneTimeWorkRequestBuilder<UploadWorker>().build()

        val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext())

        // When
        workManager.beginWith(download)
            .then(process)
            .then(upload)
            .enqueue()
            .result
            .get()

        // Then
        val downloadInfo = workManager.getWorkInfoById(download.id).get()
        val processInfo = workManager.getWorkInfoById(process.id).get()
        val uploadInfo = workManager.getWorkInfoById(upload.id).get()

        assertTrue(
            downloadInfo.state == WorkInfo.State.ENQUEUED ||
            downloadInfo.state == WorkInfo.State.RUNNING
        )
    }
}

9️⃣ Paging测试

9.1 测试PagingSource

/**
 * 测试PagingSource
 */
class EventPagingSourceTest {

    private lateinit var apiService: deviceApiService
    private lateinit var pagingSource: EventPagingSource

    @Before
    fun setup() {
        apiService = mockk()
        pagingSource = EventPagingSource(apiService, "device-123")
    }

    @Test
    fun `load returns page when successful`() = runTest {
        // Given
        val events = listOf(
            EventDto("1", "Motion detected"),
            EventDto("2", "Person detected")
        )
        val response = EventResponse(events)

        coEvery {
            apiService.getEvents(any(), any(), any())
        } returns response

        // When
        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(
                key = null,
                loadSize = 20,
                placeholdersEnabled = false
            )
        )

        // Then
        assertTrue(result is PagingSource.LoadResult.Page)
        val page = result as PagingSource.LoadResult.Page
        assertEquals(2, page.data.size)
        assertEquals(null, page.prevKey)
        assertEquals(1, page.nextKey)
    }

    @Test
    fun `load returns error when fails`() = runTest {
        // Given
        coEvery {
            apiService.getEvents(any(), any(), any())
        } throws IOException("Network error")

        // When
        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(
                key = null,
                loadSize = 20,
                placeholdersEnabled = false
            )
        )

        // Then
        assertTrue(result is PagingSource.LoadResult.Error)
    }

    @Test
    fun `load returns empty page at end of pagination`() = runTest {
        // Given
        coEvery {
            apiService.getEvents(any(), page = 10, any())
        } returns EventResponse(emptyList())

        // When
        val result = pagingSource.load(
            PagingSource.LoadParams.Append(
                key = 10,
                loadSize = 20,
                placeholdersEnabled = false
            )
        )

        // Then
        assertTrue(result is PagingSource.LoadResult.Page)
        val page = result as PagingSource.LoadResult.Page
        assertTrue(page.data.isEmpty())
        assertEquals(null, page.nextKey)
    }
}

9.2 测试RemoteMediator

/**
 * 测试RemoteMediator
 */
@OptIn(ExperimentalPagingApi::class)
class EventRemoteMediatorTest {

    private lateinit var apiService: deviceApiService
    private lateinit var database: deviceSecurityDatabase
    private lateinit var eventDao: EventDao
    private lateinit var remoteMediator: EventRemoteMediator

    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        database = Room.inMemoryDatabaseBuilder(context, deviceSecurityDatabase::class.java).build()
        eventDao = database.eventDao()
        apiService = mockk()
        remoteMediator = EventRemoteMediator("device-123", apiService, database, eventDao)
    }

    @After
    fun teardown() {
        database.close()
    }

    @Test
    fun `refresh loads data successfully`() = runTest {
        // Given
        val events = listOf(
            EventDto("1", "Motion"),
            EventDto("2", "Person")
        )
        coEvery { apiService.getEvents(any(), any(), any()) } returns EventResponse(events)

        // When
        val result = remoteMediator.load(
            LoadType.REFRESH,
            PagingState(
                pages = emptyList(),
                anchorPosition = null,
                config = PagingConfig(pageSize = 20),
                leadingPlaceholderCount = 0
            )
        )

        // Then
        assertTrue(result is RemoteMediator.MediatorResult.Success)
        assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)

        val savedEvents = eventDao.getEventsByDeviceSync("device-123")
        assertEquals(2, savedEvents.size)
    }

    @Test
    fun `refresh clears old data`() = runTest {
        // Given
        val oldEvents = listOf(
            EventEntity("old-1", "device-123", "Old event", System.currentTimeMillis())
        )
        eventDao.insertEvents(oldEvents)

        val newEvents = listOf(EventDto("new-1", "New event"))
        coEvery { apiService.getEvents(any(), any(), any()) } returns EventResponse(newEvents)

        // When
        remoteMediator.load(
            LoadType.REFRESH,
            PagingState(
                pages = emptyList(),
                anchorPosition = null,
                config = PagingConfig(pageSize = 20),
                leadingPlaceholderCount = 0
            )
        )

        // Then
        val savedEvents = eventDao.getEventsByDeviceSync("device-123")
        assertEquals(1, savedEvents.size)
        assertEquals("new-1", savedEvents[0].eventId)
    }
}

🔟 Hilt测试

10.1 HiltAndroidTest

/**
 * 测试Hilt注入
 */
@HiltAndroidTest
class DeviceRepositoryTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var repository: IDeviceRepository

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun testGetDevices() = runBlocking {
        // When
        val devices = repository.getDevices()

        // Then
        assertNotNull(devices)
    }
}

10.2 替换Module

/**
 * 测试Module:替换生产Module
 */
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [NetworkModule::class]
)
object TestNetworkModule {

    @Provides
    @Singleton
    fun provideFakeApiService(): deviceApiService {
        return FakeApiService()
    }
}

/**
 * Fake实现
 */
class FakeApiService : deviceApiService {
    private val devices = mutableListOf<DeviceDto>()

    override suspend fun getDevices(): List<DeviceDto> {
        return devices
    }

    override suspend fun getDevice(deviceId: String): DeviceDto {
        return devices.first { it.deviceId == deviceId }
    }

    fun addDevice(device: DeviceDto) {
        devices.add(device)
    }
}

10.3 BindValue快速注入

/**
 * 使用@BindValue快速注入测试依赖
 */
@HiltAndroidTest
class DeviceListFragmentTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @BindValue
    @JvmField
    val repository: IDeviceRepository = FakeDeviceRepository()

    @Before
    fun setup() {
        hiltRule.inject()

        // 准备测试数据
        (repository as FakeDeviceRepository).addDevice(
            Device("1", "Camera 1", true)
        )
    }

    @Test
    fun testDeviceList() {
        launchFragmentInHiltContainer<DeviceListFragment>()

        onView(withId(R.id.device_list))
            .check(matches(isDisplayed()))
    }
}

/**
 * Fake Repository
 */
class FakeDeviceRepository : IDeviceRepository {
    private val devices = mutableListOf<Device>()
    private val devicesFlow = MutableStateFlow(devices.toList())

    override fun getDevicesStream(): Flow<List<Device>> = devicesFlow

    override suspend fun getDevice(deviceId: String): Device? {
        return devices.firstOrNull { it.id == deviceId }
    }

    fun addDevice(device: Device) {
        devices.add(device)
        devicesFlow.value = devices.toList()
    }

    fun clear() {
        devices.clear()
        devicesFlow.value = emptyList()
    }
}

1️⃣1️⃣ 测试工具链

11.1 JUnit5

/**
 * JUnit5示例
 */
@ExtendWith(MockKExtension::class)
class DeviceViewModelJUnit5Test {

    @MockK
    private lateinit var repository: IDeviceRepository

    @InjectMockKs
    private lateinit var viewModel: DeviceViewModel

    @BeforeEach
    fun setup() {
        MockKAnnotations.init(this)
    }

    @Test
    fun `should load device successfully`() = runBlocking {
        // Given
        val device = Device("1", "Camera", true)
        coEvery { repository.getDevice("1") } returns device

        // When
        viewModel.loadDevice("1")

        // Then
        assertEquals(device, viewModel.device.value)
    }

    @Nested
    @DisplayName("Device connection tests")
    inner class ConnectionTests {

        @Test
        fun `should connect device`() {
            // ...
        }

        @Test
        fun `should disconnect device`() {
            // ...
        }
    }
}

11.2 MockK

/**
 * MockK高级用法
 */
class MockKAdvancedTest {

    @Test
    fun `test with relaxed mock`() {
        // Relaxed mock: 自动返回默认值
        val repository = mockk<IDeviceRepository>(relaxed = true)

        // When
        val result = runBlocking { repository.getDevice("1") }

        // Then
        // relaxed mock返回null(默认值)
        assertNull(result)
    }

    @Test
    fun `test with spy`() {
        // Spy: 部分mock,保留真实实现
        val repository = spyk(DeviceRepository(mockk(), mockk()))

        every { repository.someMethod() } returns "mocked"

        // When
        val result = repository.someMethod()

        // Then
        assertEquals("mocked", result)
    }

    @Test
    fun `test with verify order`() {
        val repository = mockk<IDeviceRepository>(relaxed = true)

        // When
        runBlocking {
            repository.connectDevice("1")
            repository.disconnectDevice("1")
        }

        // Then
        verifyOrder {
            runBlocking {
                repository.connectDevice("1")
                repository.disconnectDevice("1")
            }
        }
    }

    @Test
    fun `test with answer`() {
        val repository = mockk<IDeviceRepository>()

        coEvery { repository.getDevice(any()) } coAnswers {
            val deviceId = firstArg<String>()
            Device(deviceId, "Camera $deviceId", true)
        }

        // When
        val device = runBlocking { repository.getDevice("123") }

        // Then
        assertEquals("Camera 123", device?.name)
    }
}

11.3 Turbine

/**
 * Turbine测试Flow
 */
class TurbineFlowTest {

    @Test
    fun `test flow emissions`() = runTest {
        // Given
        val flow = flow {
            emit(1)
            emit(2)
            emit(3)
        }

        // When & Then
        flow.test {
            assertEquals(1, awaitItem())
            assertEquals(2, awaitItem())
            assertEquals(3, awaitItem())
            awaitComplete()
        }
    }

    @Test
    fun `test flow with error`() = runTest {
        // Given
        val flow = flow {
            emit(1)
            throw IOException("Error")
        }

        // When & Then
        flow.test {
            assertEquals(1, awaitItem())
            awaitError()
        }
    }

    @Test
    fun `test StateFlow`() = runTest {
        // Given
        val stateFlow = MutableStateFlow(0)

        // When & Then
        stateFlow.test {
            assertEquals(0, awaitItem())

            stateFlow.value = 1
            assertEquals(1, awaitItem())

            stateFlow.value = 2
            assertEquals(2, awaitItem())

            cancelAndIgnoreRemainingEvents()
        }
    }
}

11.4 Truth

/**
 * Truth断言库
 */
class TruthAssertionTest {

    @Test
    fun `test with Truth assertions`() {
        // Given
        val devices = listOf(
            Device("1", "Camera 1", true),
            Device("2", "Camera 2", false)
        )

        // Then
        assertThat(devices).hasSize(2)
        assertThat(devices).containsExactly(
            Device("1", "Camera 1", true),
            Device("2", "Camera 2", false)
        )
        assertThat(devices[0].name).isEqualTo("Camera 1")
        assertThat(devices[0].isOnline).isTrue()
    }

    @Test
    fun `test nullable values`() {
        val device: Device? = null

        assertThat(device).isNull()
    }

    @Test
    fun `test collections`() {
        val devices = listOf(
            Device("1", "Camera 1", true),
            Device("2", "Camera 2", false),
            Device("3", "Camera 3", true)
        )

        assertThat(devices)
            .hasSize(3)
        assertThat(devices.filter { it.isOnline })
            .hasSize(2)
    }
}

1️⃣2️⃣ 完整测试案例

案例1: 设备列表端到端测试

/**
 * 端到端测试:设备列表
 */
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class DeviceListE2ETest {

    @get:Rule(order = 0)
    var hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @BindValue
    @JvmField
    val repository: IDeviceRepository = FakeDeviceRepository()

    @Before
    fun setup() {
        hiltRule.inject()

        // 准备测试数据
        (repository as FakeDeviceRepository).apply {
            addDevice(Device("1", "Camera 1", true))
            addDevice(Device("2", "Camera 2", false))
            addDevice(Device("3", "Camera 3", true))
        }
    }

    @Test
    fun testDeviceListDisplayed() {
        // Then
        composeTestRule.onNodeWithText("Camera 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Camera 2").assertIsDisplayed()
        composeTestRule.onNodeWithText("Camera 3").assertIsDisplayed()
    }

    @Test
    fun testClickDevice() {
        // When
        composeTestRule.onNodeWithText("Camera 1").performClick()

        // Then
        composeTestRule.onNodeWithTag("device_detail").assertIsDisplayed()
    }

    @Test
    fun testRefresh() {
        // When
        composeTestRule.onNodeWithContentDescription("Refresh").performClick()

        // Then
        composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
        composeTestRule.waitUntil(5000) {
            composeTestRule.onAllNodesWithText("Camera 1").fetchSemanticsNodes().isNotEmpty()
        }
    }
}

案例2: Repository集成测试

/**
 * 集成测试:Repository + DAO + API
 */
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class DeviceRepositoryIntegrationTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var database: deviceSecurityDatabase

    @Inject
    lateinit var deviceDao: DeviceDao

    @BindValue
    @JvmField
    val apiService: deviceApiService = FakeApiService()

    private lateinit var repository: DeviceRepository

    @Before
    fun setup() {
        hiltRule.inject()
        repository = DeviceRepository(apiService, deviceDao, Dispatchers.IO)
    }

    @After
    fun teardown() {
        database.close()
    }

    @Test
    fun testSyncDevices() = runBlocking {
        // Given
        val remoteDevices = listOf(
            DeviceDto("1", "Camera 1", true),
            DeviceDto("2", "Camera 2", false)
        )
        (apiService as FakeApiService).setDevices(remoteDevices)

        // When
        repository.refreshDevices()

        // Then
        val localDevices = deviceDao.getAllDevicesOnce()
        assertEquals(2, localDevices.size)
        assertEquals("Camera 1", localDevices[0].deviceName)
    }

    @Test
    fun testOfflineFirstStrategy() = runBlocking {
        // Given: 数据库有数据
        deviceDao.insertDevice(DeviceEntity("1", "Cached Camera", true))

        // When: 观察数据流
        val devices = repository.getDevicesStream().first()

        // Then: 立即返回缓存数据
        assertEquals(1, devices.size)
        assertEquals("Cached Camera", devices[0].name)
    }
}

案例3: ViewModel + Repository单元测试

/**
 * 单元测试:ViewModel + Mock Repository
 */
class DeviceListViewModelUnitTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: DeviceListViewModel
    private lateinit var repository: IDeviceRepository
    private lateinit var getDevicesUseCase: GetDevicesUseCase

    @Before
    fun setup() {
        repository = mockk()
        getDevicesUseCase = GetDevicesUseCase(repository)
        viewModel = DeviceListViewModel(getDevicesUseCase)
    }

    @Test
    fun `should load devices on init`() = runTest {
        // Given
        val devices = listOf(
            Device("1", "Camera 1", true),
            Device("2", "Camera 2", false)
        )
        coEvery { repository.getDevicesStream() } returns flowOf(devices)

        // When
        // ViewModel构造函数已触发加载
        advanceUntilIdle()

        // Then
        viewModel.devices.test {
            assertEquals(devices, awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `should emit loading state`() = runTest {
        // Given
        coEvery { repository.getDevicesStream() } returns flow {
            delay(1000)
            emit(emptyList<Device>())
        }

        // When & Then
        viewModel.uiState.test {
            assertEquals(DeviceUiState.Loading, awaitItem())

            advanceTimeBy(1000)
            assertTrue(awaitItem() is DeviceUiState.Success)

            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `should handle error`() = runTest {
        // Given
        coEvery { repository.getDevicesStream() } returns flow {
            throw IOException("Network error")
        }

        // When
        viewModel.refresh()
        advanceUntilIdle()

        // Then
        viewModel.uiState.test {
            val state = awaitItem()
            assertTrue(state is DeviceUiState.Error)
            assertEquals("Network error", (state as DeviceUiState.Error).message)

            cancelAndIgnoreRemainingEvents()
        }
    }
}

1️⃣3️⃣ CI/CD集成

13.1 GitHub Actions

# .github/workflows/android.yml
name: Android CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Run unit tests
      run: ./gradlew testDebugUnitTest

    - name: Run instrumentation tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 30
        script: ./gradlew connectedDebugAndroidTest

    - name: Generate test coverage report
      run: ./gradlew jacocoTestReport

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        files: ./build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml

13.2 测试覆盖率

// build.gradle.kts
plugins {
    id("jacoco")
}

jacoco {
    toolVersion = "0.8.10"
}

tasks.register<JacocoReport>("jacocoTestReport") {
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
    }

    val fileFilter = listOf(
        "**/R.class",
        "**/R$*.class",
        "**/BuildConfig.*",
        "**/Manifest*.*",
        "**/*Test*.*",
        "android/**/*.*",
        "**/di/**",
        "**/*_Hilt*.*"
    )

    val debugTree = fileTree("${buildDir}/intermediates/classes/debug") {
        exclude(fileFilter)
    }

    val mainSrc = "${project.projectDir}/src/main/java"

    sourceDirectories.setFrom(files(mainSrc))
    classDirectories.setFrom(files(debugTree))
    executionData.setFrom(fileTree(buildDir) {
        include("**/*.exec", "**/*.ec")
    })
}

1️⃣4️⃣ 测试最佳实践

14.1 测试命名规范

/**
 * 测试命名:should_expectedBehavior_when_condition
 */
class DeviceViewModelTest {

    @Test
    fun `should emit Success state when load devices succeeds`() {
        // ...
    }

    @Test
    fun `should emit Error state when load devices fails`() {
        // ...
    }

    @Test
    fun `should update device status when connect succeeds`() {
        // ...
    }
}

14.2 Given-When-Then模式

@Test
fun `should load device details`() {
    // Given: 准备测试数据和mock
    val device = Device("1", "Camera", true)
    coEvery { repository.getDevice("1") } returns device

    // When: 执行被测试的操作
    viewModel.loadDevice("1")

    // Then: 验证结果
    assertEquals(device, viewModel.device.value)
}

14.3 测试独立性

/**
 * ❌ 错误:测试之间有依赖
 */
class BadTest {
    private var device: Device? = null

    @Test
    fun test1_createDevice() {
        device = Device("1", "Camera", true)
    }

    @Test
    fun test2_useDevice() {
        // 依赖test1,test2单独运行会失败
        assertNotNull(device)
    }
}

/**
 * ✅ 正确:每个测试独立
 */
class GoodTest {

    private lateinit var device: Device

    @Before
    fun setup() {
        // 每个测试前都重新初始化
        device = Device("1", "Camera", true)
    }

    @Test
    fun test1() {
        assertNotNull(device)
    }

    @Test
    fun test2() {
        assertNotNull(device)
    }
}

14.4 测试覆盖率目标

- 单元测试覆盖率:≥80%
- 集成测试覆盖率:≥60%
- UI测试覆盖率:≥40%

重点覆盖:
- 业务逻辑(ViewModel, UseCase, Repository)
- 数据库操作(DAO)
- 复杂算法和计算
- 错误处理和边界情况

🎯 15. 总结

测试策略总结

组件 测试类型 测试重点 工具
Lifecycle 单元测试 生命周期事件响应 JUnit, MockK
ViewModel 单元测试 业务逻辑,状态管理 JUnit, Turbine
LiveData 单元测试 数据观察,转换 InstantTaskExecutorRule
DataBinding UI测试 数据绑定正确性 Espresso
Room 集成测试 DAO, Migration Room Testing
Navigation UI测试 页面跳转,参数传递 Navigation Testing
WorkManager 单元测试 任务执行,调度 WorkManager Testing
Paging 单元测试 分页加载,缓存 Paging Testing
Hilt 集成测试 依赖注入 Hilt Testing

测试工具链

  1. JUnit5: 测试框架
  2. MockK: Mock库
  3. Turbine: Flow测试
  4. Truth: 断言库
  5. Espresso: UI测试
  6. Hilt Testing: 依赖注入测试
  7. Room Testing: 数据库测试
  8. WorkManager Testing: 后台任务测试

测试金字塔

  • 70% 单元测试:快速、稳定、易维护
  • 20% 集成测试:验证组件协作
  • 10% UI测试:验证用户体验
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐