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)
}
}
集成测试:跟真实的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()
}
}
测试数据源:用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()
}
}
用SVG梳理测试策略
下面这张图是我自己总结的MediaExtractor测试策略,你可以照着这个思路来组织你的测试代码:
测试代码的组织结构
我习惯在项目中这样组织测试代码:
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里可以分开跑——单元测试每次提交都跑,集成测试只在合并到主分支时跑,节省时间。
总结一下
写MediaExtractor的测试,说白了就是两件事:
- 用Mock把业务逻辑和系统API解耦,保证你的代码在各种边界条件下都能正确运行
- 用真实的媒体文件验证MediaExtractor的行为,确保它能在你的目标设备上正常工作
我见过太多人只写单元测试不写集成测试,结果上线后才发现MediaExtractor在某些机型上解析不了特定格式的视频。也见过只写集成测试不写单元测试的,每次跑测试都要等好几分钟,效率极低。
两者结合,才是正道。
公众号:蓝海资料掘金营,微信deep3321