1. 简单的拍照辅助类
笔者最近开发过程中,写了一个类PhotoCaptureLauncher
主要功能:
- 权限检查,如果没有相机权限则返回
null - 调用系统拍照,返回对应的
uri
当我在着手为这段代码增加unit test的时候,遇到了一些问题。
先来看看PhotoCaptureLauncher伪代码如下:
internal class PhotoCaptureLauncher(
private val cameraPermissionChecker: CameraPermissionChecker,
) {
suspend fun launch(activity: ComponentActivity): Uri? {
if (!cameraPermissionChecker.check(activity)) return null
val outputUri: Uri = newImageUri(context) ?: return null
val success: Boolean = activity.activityResultRegistry.launchForResultWithCleanup(
contract = ActivityResultContracts.TakePicture(),
input = outputUri,
)
}
private fun newImageUri(context: Context): Uri? {
val tempFile = File.createTempFile("example", ".jpg", context.cacheDir)
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
}
internal class CameraPermissionChecker() {
suspend fun check(activity: ComponentActivity): Boolean {
val granted = activity.activityResultRegistry.launchForResultWithCleanup(
contract = ActivityResultContracts.RequestPermission(),
input = Manifest.permission.CAMERA,
)
return granted
}
}
// 将ActivityResult API包装成suspend方法,函数返回值即为Activity的返回结果
internal suspend fun <I, O> ActivityResultRegistry.launchForResultWithCleanup(
contract: ActivityResultContract<I, O>,
input: I,
): O {
val resultDefer = CompletableDeferred<O>()
val key = UUID.randomUUID().toString()
val state = MutableStateFlow<ActivityResultLauncher<*>?>(null)
val launcher = register(key, contract) {
state.value?.unregister()
resultDefer.complete(it)
}
launcher.launch(input)
state.value = launcher
return resultDefer.await()
}
该类的职责相对比较清晰,有以下2个test case:
- 没有权限时,返回
null - 有权限时,返回
image uri
2. 测试难点
当前类会依赖Android系统类,比如:Activity、Uri,我们可以借助robolectric测试库来测试。
2.1 ActivityResult API
代码中用到了ActivityResult API, 测试代码中如何模拟打开Activity并返回结果
2.2 FileProvider#getUriForFile
它内部读取了Manifest.xml的配置项,但在测试环境没有提供这样的配置,从而会导致异常
...
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
...
3. 解决方案
3.1 ActivityResult API
ActivityResult API核心类是ActivityResultRegistry, 我们可以为activity提供一个测试TestActivityResultRegistry,在onLaunch中直接调用dispatchResult 返回我们期望的结果即可。
@Test
fun xxx() {
val activity: ComponentActivity = spyk(Robolectric.buildActivity(ComponentActivity::class.java).setup().get())
every { activity.activityResultRegistry } returns getTestActivityResultRegistry(result = true)
val resultUri: Uri? = photoCaptureLauncher.launch(activity)
...
}
private fun getTestActivityResultRegistry(result: Boolean): ActivityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
dispatchResult(requestCode, result)
}
}
3.2 FileProvider#getUriForFile
通过配置shadows 在测试环境提供一个自己定义的FileProvider,这样就无需依赖Manifest.xml了。
@Config(shadows = [ShadowFileProvider::class])
@RunWith(AndroidJUnit4::class)
internal class PhotoCaptureLauncherTest {
...
}
@Implements(FileProvider::class)
@Suppress("UtilityClassWithPublicConstructor")
internal class ShadowFileProvider {
companion object {
@JvmStatic
@Implementation
@Suppress("UnusedParameter")
fun getUriForFile(context: Context, authority: String, file: File): Uri {
return Uri.parse("content://$authority/${file.name}")
}
}
}
4. 完整测试代码
项目中使用mockk框架,测试代码如下:
@Config(shadows = [ShadowFileProvider::class])
@RunWith(AndroidJUnit4::class)
internal class PhotoCaptureLauncherTest {
private val cameraPermissionChecker: CameraPermissionChecker = mockk()
private val photoCaptureLauncher: PhotoCaptureLauncher = PhotoCaptureLauncher(cameraPermissionChecker)
@Test
fun `given camera permission denied, then launch should return null`() = runTest {
coEvery { cameraPermissionChecker.check(any()) } returns false
val resultUri: Uri? = photoCaptureLauncher.launch(ApplicationProvider.getApplicationContext())
resultUri shouldBe null
}
@Test
fun `given camera permission grated, then launch should return camera photo uri`() = runTest {
coEvery { cameraPermissionChecker.check(any()) } returns true
val activity: ComponentActivity = spyk(Robolectric.buildActivity(ComponentActivity::class.java).setup().get())
every { activity.activityResultRegistry } returns getTestActivityResultRegistry(result = true)
val resultUri: Uri? = photoCaptureLauncher.launch(activity)
resultUri shouldNotBe null
}
private fun getTestActivityResultRegistry(result: Boolean): ActivityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
dispatchResult(requestCode, result)
}
}
}
@Implements(FileProvider::class)
@Suppress("UtilityClassWithPublicConstructor")
internal class ShadowFileProvider {
companion object {
@JvmStatic
@Implementation
@Suppress("UnusedParameter")
fun getUriForFile(context: Context, authority: String, file: File): Uri {
return Uri.parse("content://$authority/${file.name}")
}
}
}