01-03-14 Jetpack组件测试策略
·
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 |
测试工具链
- JUnit5: 测试框架
- MockK: Mock库
- Turbine: Flow测试
- Truth: 断言库
- Espresso: UI测试
- Hilt Testing: 依赖注入测试
- Room Testing: 数据库测试
- WorkManager Testing: 后台任务测试
测试金字塔
- 70% 单元测试:快速、稳定、易维护
- 20% 集成测试:验证组件协作
- 10% UI测试:验证用户体验
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)