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 测试策略:我个人的「三层测试法」

在项目中,我一般把测试分成三层:

  1. 单元测试层:覆盖所有工具类、ViewModel、数据解析逻辑。目标是80%以上的代码覆盖率。
  2. 集成测试层:测试模块之间的交互,比如MediaPlayer和AudioManager的配合。用Mock模拟系统服务。
  3. UI自动化层:覆盖核心用户流程,比如播放音乐、切换音源、调节音量。每个流程写一个端到端测试。

我曾经在一个项目里只写了单元测试,结果UI上有个按钮点击没反应,直到测试人员提了bug才发现。从那以后,UI自动化测试我再也不敢省了。

26.5 避坑指南

最后分享几个我踩过的坑:

  • 不要依赖测试执行顺序。每个测试应该是独立的。我见过有人写测试A时设置了一个全局变量,测试B依赖这个变量。结果测试顺序一变,全挂了。
  • 避免在测试中使用Thread.sleep()。用Espresso的IdlingResource或者Kotlin的runBlocking来等待异步操作。sleep会让测试变慢,而且不稳定。
  • 车载环境模拟要注意时区。有些车载系统默认时区是UTC,而模拟器可能是本地时区。我遇到过因为时区问题导致日志时间戳对不上的情况。
  • UI测试截图要保留。每次跑完UI测试,把失败时的截图保存下来。这在排查问题时非常有用。

总结一下:测试不是写代码的敌人,而是帮你守住质量底线的朋友。单元测试保逻辑,UI测试保交互,环境模拟保兼容。三管齐下,才能让你的车载多媒体应用在实车上跑得稳。

车载多媒体测试体系 单元测试层 工具类 · ViewModel · 数据解析 · 边界条件 覆盖率目标:≥80% | 框架:JUnit + MockK 集成测试层 模块交互 · 系统服务模拟 · 数据流验证 框架:Mockito + Robolectric UI自动化测试层 用户流程 · 界面交互 · 跨应用测试 框架:Espresso + UI Automator
公众号:蓝海资料掘金营,微信 deep3321