Android 如何测试依赖了Activity/FileProvider的代码?

244 阅读2分钟

1. 简单的拍照辅助类

笔者最近开发过程中,写了一个类PhotoCaptureLauncher 主要功能:

  1. 权限检查,如果没有相机权限则返回null
  2. 调用系统拍照,返回对应的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:

  1. 没有权限时,返回null
  2. 有权限时,返回image uri

2. 测试难点

当前类会依赖Android系统类,比如:ActivityUri,我们可以借助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}")
        }
    }
}