26、测试与调试:单元测试、UI自动化测试、车载环境模拟
说实话,很多车载开发的同学对测试这块有点「敬而远之」。觉得写代码已经够累了,还要写测试?
我以前也这么想。直到有一次,我负责的媒体播放模块在实车路测时,突然在切换音源时崩溃了。原因很简单——一个边界条件没处理。那次之后,我彻底改变了看法。测试不是负担,是帮你省时间的。
26.1 单元测试:给代码上「保险」
单元测试,说白了就是测试你的函数或类能不能按预期工作。在车载多媒体开发中,我习惯把单元测试分成两类:纯逻辑测试和依赖隔离测试。
26.1.1 纯逻辑测试
这类测试最简单。比如你写了一个解析蓝牙电话本的工具函数:
// PhoneBookParser.kt
fun parseVCard(raw: String): List<Contact> {
if (raw.isBlank()) return emptyList()
return raw.split("\n")
.filter { it.startsWith("TEL:") }
.map { Contact(phone = it.removePrefix("TEL:").trim()) }
}
对应的单元测试:
class PhoneBookParserTest {
@Test
fun `parseVCard should return empty list for blank input`() {
val result = parseVCard("")
assertTrue(result.isEmpty())
}
@Test
fun `parseVCard should extract phone numbers`() {
val input = "TEL:13800138000\nTEL:13900139000"
val result = parseVCard(input)
assertEquals(2, result.size)
assertEquals("13800138000", result[0].phone)
}
}
我在项目中遇到过一个问题:某个解析函数在输入包含特殊字符时直接抛异常。后来加了个边界测试,才把问题堵住。嗯,边界值测试真的很重要。
26.1.2 依赖隔离测试
车载应用经常依赖系统服务,比如AudioManager、CarMediaManager。直接调用这些服务,测试就跑不起来了。我的做法是用Mockito或MockK来模拟依赖。
核心原则:只测试你自己的代码逻辑,不要测试Android框架。
class MediaPlayerViewModelTest {
private val mockAudioManager = mock<AudioManager>()
private val viewModel = MediaPlayerViewModel(mockAudioManager)
@Test
fun `playMusic should request audio focus`() {
viewModel.playMusic()
verify(mockAudioManager).requestAudioFocus(any(), any(), any())
}
}
你想想看,如果不做隔离,每次跑测试都得开模拟器,那速度得多慢?
26.2 UI自动化测试:让机器替你「点屏幕」
UI测试,就是模拟用户操作。比如点击播放按钮、滑动列表、检查界面是否显示正确。
26.2.1 Espresso:Android官方方案
Espresso是Google推荐的UI测试框架。它的写法很直观:
@Test
fun `click play button should start playback`() {
onView(withId(R.id.btn_play))
.perform(click())
onView(withId(R.id.tv_status))
.check(matches(withText("Playing")))
}
但车载UI有个特殊点——很多控件是自定义的,比如圆形旋钮、进度条。这时候Espresso的匹配器可能不够用。我建议自己写一个自定义Matcher:
fun withProgress(value: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with progress: $value")
}
override fun matchesSafely(view: View): Boolean {
return (view as? SeekBar)?.progress == value
}
}
}
26.2.2 UI Automator:跨应用测试
有时候你需要测试多媒体应用和系统UI的交互,比如调节音量时,系统音量条是否弹出。这时候用Espresso就不够了,得用UI Automator。
@Test
fun `volume up should show system UI`() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.pressVolumeUp()
// 等待系统音量UI出现
device.wait(Until.hasObject(By.desc("Volume panel")), 2000)
assertNotNull(device.findObject(By.desc("Volume panel")))
}
小技巧:UI Automator的查找速度比Espresso慢,建议只在跨应用场景使用。同一个应用内的UI测试,优先用Espresso。
26.3 车载环境模拟:没有车也能测
这是车载开发最头疼的问题。没有实车,怎么测试?我经历过好几次:代码在模拟器上跑得好好的,一上实车就出问题。
26.3.1 模拟器配置
Android模拟器其实可以模拟车载环境。关键配置如下:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 屏幕尺寸 | 1280x720 或 1920x1080 | 车载屏幕常见分辨率 |
| 系统镜像 | Android 12+ Automotive | 带车载扩展API |
| 内存 | 2GB-4GB | 模拟车载硬件资源限制 |
| 存储 | 32GB | 模拟车载存储空间 |
26.3.2 模拟车载信号
车载应用依赖很多信号,比如车速、转向灯、空调状态。模拟器本身不提供这些。我的做法是写一个Mock数据源:
class MockCarSignalProvider : CarSignalProvider {
private val _speed = MutableStateFlow(0)
override val speed: StateFlow<Int> = _speed
fun setSpeed(kmh: Int) {
_speed.value = kmh
}
}
// 在测试中
@Test
fun `when speed exceeds 80, video playback should pause`() {
val provider = MockCarSignalProvider()
val viewModel = VideoPlayerViewModel(provider)
provider.setSpeed(85)
assertFalse(viewModel.isPlaying.value)
}
注意:模拟数据永远无法完全替代实车测试。特别是蓝牙连接、GPS信号、CAN总线数据这些,模拟器只能做到「看起来像」,实际行为可能有差异。
26.4 测试策略:我个人的「三层测试法」
在项目中,我一般把测试分成三层:
- 单元测试层:覆盖所有工具类、ViewModel、数据解析逻辑。目标是80%以上的代码覆盖率。
- 集成测试层:测试模块之间的交互,比如MediaPlayer和AudioManager的配合。用Mock模拟系统服务。
- UI自动化层:覆盖核心用户流程,比如播放音乐、切换音源、调节音量。每个流程写一个端到端测试。
我曾经在一个项目里只写了单元测试,结果UI上有个按钮点击没反应,直到测试人员提了bug才发现。从那以后,UI自动化测试我再也不敢省了。
26.5 避坑指南
最后分享几个我踩过的坑:
- 不要依赖测试执行顺序。每个测试应该是独立的。我见过有人写测试A时设置了一个全局变量,测试B依赖这个变量。结果测试顺序一变,全挂了。
- 避免在测试中使用Thread.sleep()。用Espresso的IdlingResource或者Kotlin的runBlocking来等待异步操作。sleep会让测试变慢,而且不稳定。
- 车载环境模拟要注意时区。有些车载系统默认时区是UTC,而模拟器可能是本地时区。我遇到过因为时区问题导致日志时间戳对不上的情况。
- UI测试截图要保留。每次跑完UI测试,把失败时的截图保存下来。这在排查问题时非常有用。
总结一下:测试不是写代码的敌人,而是帮你守住质量底线的朋友。单元测试保逻辑,UI测试保交互,环境模拟保兼容。三管齐下,才能让你的车载多媒体应用在实车上跑得稳。