29、单元测试与集成测试:如何为MediaExtractor代码编写测试用例

说实话,很多做音视频开发的同学,写起业务代码来飞快,一到写测试就头疼。我以前也这样,觉得测试是QA的事。直到有一次,我改了一行MediaExtractor的配置代码,结果导致某个视频格式的轨道信息全乱了,上线后被用户骂惨了。嗯,从那以后,我再也不敢轻视测试了。

今天我们就来聊聊,怎么给MediaExtractor相关的代码写测试。我会把单元测试和集成测试分开讲,毕竟它们的侧重点完全不同。

为什么测试MediaExtractor这么特殊?

MediaExtractor是Android系统级的API,它直接操作底层多媒体引擎。说白了,它不是在操作你的Java对象,而是在跟C++层的Stagefright打交道。这就带来了几个麻烦:

  • 依赖系统环境:MediaExtractor需要真实的媒体文件,或者模拟的文件描述符
  • 状态不可控:你没法Mock掉MediaExtractor内部的解码器选择逻辑
  • 异步行为:虽然MediaExtractor本身是同步的,但它的数据源可能来自网络或ContentProvider

我个人习惯把测试分成两层:一层是纯逻辑的单元测试,另一层是跟MediaExtractor打交道的集成测试。这样出了问题,我能快速定位是业务逻辑错了,还是跟系统API交互出了问题。

单元测试:Mock掉一切外部依赖

单元测试的核心原则是——不依赖任何真实的外部资源。对于MediaExtractor来说,我们通常不会直接Mock MediaExtractor本身,而是把业务逻辑封装起来,把MediaExtractor作为参数传进去。

举个例子,假设我们有一个提取视频元信息的方法:

class MediaInfoExtractor(private val extractor: MediaExtractor) {

    fun getVideoDuration(filePath: String): Long {
        extractor.setDataSource(filePath)
        val durationUs = extractor.getTrackFormat(0)
            ?.getLong(MediaFormat.KEY_DURATION) ?: 0L
        extractor.release()
        return durationUs / 1000
    }
}

这段代码直接依赖了MediaExtractor,没法单元测试。我一般会这样重构:

interface MediaDataSource {
    fun setDataSource(path: String)
    fun getTrackFormat(index: Int): MediaFormat?
    fun release()
}

class MediaInfoExtractor(private val dataSource: MediaDataSource) {

    fun getVideoDuration(filePath: String): Long {
        dataSource.setDataSource(filePath)
        val durationUs = dataSource.getTrackFormat(0)
            ?.getLong(MediaFormat.KEY_DURATION) ?: 0L
        dataSource.release()
        return durationUs / 1000
    }
}

这样一来,单元测试就简单了:

class MediaInfoExtractorTest {

    @Test
    fun `getVideoDuration returns correct duration in milliseconds`() {
        val mockDataSource = mockk<MediaDataSource>()
        val mockFormat = mockk<MediaFormat>()

        every { mockDataSource.setDataSource(any()) } returns Unit
        every { mockDataSource.getTrackFormat(0) } returns mockFormat
        every { mockFormat.getLong(MediaFormat.KEY_DURATION) } returns 5_000_000L
        every { mockDataSource.release() } returns Unit

        val extractor = MediaInfoExtractor(mockDataSource)
        val duration = extractor.getVideoDuration("/fake/path.mp4")

        assertEquals(5000L, duration)
    }
}
我的经验:用MockK而不是Mockito来写Kotlin测试。MockK对Kotlin的协程、空安全、默认参数支持得更好。我之前用Mockito踩过不少坑,比如无法Mock掉Kotlin的object方法。

集成测试:跟真实的MediaExtractor打交道

单元测试只能保证你的逻辑正确,但没法保证MediaExtractor真的能解析你的文件。集成测试就要上真家伙了。

我建议在Android的Instrumentation测试中写集成测试,因为MediaExtractor需要访问文件系统。测试用的媒体文件可以放在androidTest/assets目录下。

来看一个典型的集成测试:

@RunWith(AndroidJUnit4::class)
class MediaExtractorIntegrationTest {

    @Test
    fun `extract video track info from mp4 file`() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        val assetFileDescriptor = context.assets.openFd("test_video.mp4")

        val extractor = MediaExtractor()
        extractor.setDataSource(
            assetFileDescriptor.fileDescriptor,
            assetFileDescriptor.startOffset,
            assetFileDescriptor.length
        )

        // 验证轨道数量
        val trackCount = extractor.trackCount
        assertTrue("Expected at least 1 track", trackCount >= 1)

        // 遍历所有轨道,找到视频轨道
        var videoTrackIndex = -1
        for (i in 0 until trackCount) {
            val format = extractor.getTrackFormat(i)
            val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
            if (mime.startsWith("video/")) {
                videoTrackIndex = i
                break
            }
        }

        assertTrue("Video track not found", videoTrackIndex >= 0)

        // 验证视频轨道的关键属性
        val videoFormat = extractor.getTrackFormat(videoTrackIndex)
        val width = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
        val height = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
        val frameRate = videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)

        assertTrue("Width should be positive", width > 0)
        assertTrue("Height should be positive", height > 0)
        assertTrue("Frame rate should be positive", frameRate > 0)

        extractor.release()
        assetFileDescriptor.close()
    }
}
注意:测试用的媒体文件不要太大。我曾经放了一个100MB的4K视频到assets里,结果每次跑测试都要等半天。建议用ffmpeg生成小尺寸的测试视频,比如320x240、5秒钟、1Mbps码率就够了。

测试数据源:用ContentResolver还是FileDescriptor?

在实际项目中,MediaExtractor的数据源可能来自文件路径、ContentProvider或者网络流。不同的数据源,测试方式也不一样。

数据源类型 测试方法 注意事项
文件路径 直接使用assets中的文件 注意文件路径权限,Android 10+需要Scoped Storage适配
ContentResolver 使用MediaStore API插入测试文件 测试完成后记得清理,否则会污染系统媒体库
网络流 使用本地HTTP Server模拟 可以用MockWebServer或者NanoHTTPD
FileDescriptor 从assets中获取fd 注意startOffset和length参数,不要传错

我个人最推荐用FileDescriptor的方式,因为它最接近真实场景,而且不需要处理文件路径的权限问题。你想想看,在Android 11上,直接传文件路径给MediaExtractor,有时候会莫名其妙地失败,但用fd就稳得很。

测试覆盖率:哪些场景必须覆盖?

写测试不是越多越好,而是要把精力花在刀刃上。我总结了几个MediaExtractor最容易出问题的场景:

  • 空文件或损坏文件:MediaExtractor会不会抛异常?你的catch代码能不能兜住?
  • 无视频轨道的文件:比如纯音频文件,你的代码能不能正确处理?
  • 多轨道文件:有些视频有多个音频轨道(比如多语言),你的轨道选择逻辑对不对?
  • 旋转角度:有些视频的旋转信息在MediaFormat里,你的代码有没有读取KEY_ROTATION
  • 超大文件:MediaExtractor在seek的时候会不会卡死?

来看一个测试损坏文件的例子:

@Test(expected = IOException::class)
fun `setDataSource with corrupted file throws IOException`() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val corruptedFile = File(context.cacheDir, "corrupted.mp4")
    // 写入一些随机字节,模拟损坏文件
    corruptedFile.writeBytes(ByteArray(1024) { it.toByte() })

    val extractor = MediaExtractor()
    try {
        extractor.setDataSource(corruptedFile.absolutePath)
    } finally {
        extractor.release()
        corruptedFile.delete()
    }
}
避坑指南:我曾经在测试损坏文件时,发现MediaExtractor在某些设备上不会抛IOException,而是返回一个空的轨道列表。后来我加了一个额外的断言——如果trackCount为0,也视为异常情况。所以不要只依赖异常类型来判断,还要检查返回值。

用SVG梳理测试策略

下面这张图是我自己总结的MediaExtractor测试策略,你可以照着这个思路来组织你的测试代码:

MediaExtractor 测试策略 MediaExtractor 测试 单元测试 集成测试 Mock 数据源接口 验证业务逻辑正确性 测试异常分支处理 真实文件解析验证 多格式兼容性测试 边界条件覆盖

测试代码的组织结构

我习惯在项目中这样组织测试代码:

app/
├── src/
│   ├── main/java/...          # 生产代码
│   ├── test/java/...          # 单元测试
│   │   └── MediaInfoExtractorTest.kt
│   └── androidTest/
│       ├── java/...           # 集成测试
│       │   └── MediaExtractorIntegrationTest.kt
│       └── assets/
│           ├── test_video.mp4
│           ├── test_audio_only.mp4
│           └── test_multi_track.mp4

单元测试放在test目录,集成测试放在androidTest目录。这样在CI/CD里可以分开跑——单元测试每次提交都跑,集成测试只在合并到主分支时跑,节省时间。

小技巧:在build.gradle里配置testOptions,让单元测试和集成测试使用不同的JVM参数。比如集成测试可以分配更大的堆内存,因为要加载媒体文件。

总结一下

写MediaExtractor的测试,说白了就是两件事:

  • 用Mock把业务逻辑和系统API解耦,保证你的代码在各种边界条件下都能正确运行
  • 用真实的媒体文件验证MediaExtractor的行为,确保它能在你的目标设备上正常工作

我见过太多人只写单元测试不写集成测试,结果上线后才发现MediaExtractor在某些机型上解析不了特定格式的视频。也见过只写集成测试不写单元测试的,每次跑测试都要等好几分钟,效率极低。

两者结合,才是正道。


公众号:蓝海资料掘金营,微信deep3321