Having an official solution for Compose screenshots is great. Basically, you get something similar to Paparazzi—a wrapper around layoutlib. What I enjoy is the separate source set. But that comes with a small price: no code coverage. Of course, there is an open issue requesting this, but Kover seems to be in a kind of limbo as it is being migrated to Kotlin; no new features are being added right now. Let’s fix that.
First, the Gradle task wiring – that’s not too difficult. Of course, AI helped with all of this. We need to set up proper dependencies between tasks and also attach the Kover agent to the screenshot run.
afterEvaluate {
val binReportsDir = layout.buildDirectory.dir("kover/bin-reports")
val agentJar = layout.buildDirectory.file("kover/kover-jvm-agent-0.9.7.jar")
tasks.withType<Test>()
.matching { it.name.startsWith("validate") && it.name.endsWith("ScreenshotTest") }
.configureEach {
val binReport = binReportsDir.map { it.file("${name}.ic") }
doFirst { binReport.get().asFile.delete() }
val argsFile = temporaryDir.resolve("kover-agent.args")
doFirst {
argsFile.parentFile.mkdirs()
argsFile.printWriter().use { pw ->
pw.append("report.file=").appendLine(binReport.get().asFile.canonicalPath)
pw.append("exclude=").appendLine("android.*")
pw.append("exclude=").appendLine("com.android.*")
pw.append("exclude=").appendLine("jdk.internal.*")
}
}
jvmArgumentProviders += CommandLineArgumentProvider {
val jar = agentJar.get().asFile
if (jar.exists()) mutableListOf("-javaagent:${jar.canonicalPath}=file:${argsFile.canonicalPath}")
else mutableListOf()
}
}
// Include screenshot coverage in Kover's merged reports
tasks.matching { it.name.startsWith("koverGenerateArtifact") }.configureEach {
val variantName = name.removePrefix("koverGenerateArtifact")
val screenshotTask = tasks.findByName("validate${variantName}ScreenshotTest")
?: return@configureEach
dependsOn(screenshotTask)
val reports = this::class.java.methods.first { it.name == "getReports" }
.invoke(this) as org.gradle.api.file.ConfigurableFileCollection
reports.from(binReportsDir.map { it.file("validate${variantName}ScreenshotTest.ic") })
}
}
The screenshot plugin relies on layoutlib – the same rendering engine that powers Compose previews in Android Studio. To render your composables on the host JVM, it bundles Android framework stubs and loads them through an isolated ClassLoader with PlatformClassLoader as parent, which only exposes JDK classes. Your app and Compose classes are passed to the internal renderer separately as file paths. This strict isolation prevents the app’s classpath from interfering with layoutlib’s rendering – a sensible design that works great, until Kover needs to resolve all types your Composable code touches.
Why does this matter for coverage? Kover’s agent uses ASM to instrument bytecode. ASM needs to compute stack frame types, which requires resolving class hierarchies via ClassLoader’s getResourceAsStream() method. When the classloader can’t find androidx/compose/runtime/Composable.class as a resource, ASM frame computation fails, and Kover silently skips that class.
First, to compile the fix, a few dependencies are needed:
[libraries]
android-screenshot-validation-engine = { module = "com.android.tools.screenshot:screenshot-validation-junit-engine", version.ref = "android-screenshot" }
android-screenshot-preview-renderer = { module = "com.android.tools.compose:compose-preview-renderer", version.ref = "android-screenshot" }
dependencies {
screenshotTestCompileOnly(libs.android.screenshot.validation.engine)
screenshotTestCompileOnly(libs.android.screenshot.preview.renderer)
}
The key component to fix this is a custom classloader. Class loading and resource loading serve different purposes here. Layoutlib needs class loading isolation – that’s non-negotiable. But resource loading (reading .class files as byte streams for ASM analysis) doesn’t need the same isolation. By falling back to SystemClassLoader only for resource-related methods, we give Kover everything it needs for frame computation without breaking layoutlib’s rendering.
private class ResourceEnhancedClassLoader(
urls: Array<URL>,
parent: ClassLoader, // PlatformClassLoader
) : URLClassLoader(urls, parent) {
private val systemClassLoader: ClassLoader = ClassLoader.getSystemClassLoader()
override fun getResourceAsStream(name: String): InputStream? =
super.getResourceAsStream(name) ?: systemClassLoader.getResourceAsStream(name)
override fun getResource(name: String): URL? =
super.getResource(name) ?: systemClassLoader.getResource(name)
override fun getResources(name: String): Enumeration<URL> {
val parentResources = super.getResources(name).toList()
val systemResources = systemClassLoader.getResources(name).toList()
return java.util.Collections.enumeration(parentResources + systemResources)
}
}
Then you need to use it in an updated Renderer class. Start by copying the original Renderer implementation and store it as app/src/screenshotTest/kotlin/com/android/tools/screenshot/renderer/Renderer.kt — the exact same package as the original, so it shadows it on the classpath. Then change the logic around classloading in its init block. We have to use reflection to get the platform class loader – getPlatformClassLoader() isn’t in the Android SDK compile classpath but is available at runtime since screenshot tests run on the host JVM:
// PlatformClassLoader for class loading isolation, enhanced with
// SystemClassLoader fallback for resource streams (fixes Kover agent).
val parent = ClassLoader::class.java
.getMethod("getPlatformClassLoader")
.invoke(null) as ClassLoader
val urls = layoutlibClassPathFiles.map { it.toURI().toURL() }.toTypedArray()
isolatedClassLoaderForRendering = ResourceEnhancedClassLoader(urls, parent)
And that is all. Run the screenshot validation Gradle task and check the code coverage results.
Conclusion
Both workarounds – the manual agent attachment and the renderer classloader fix – are admittedly hacky. They rely on Kover internals (the agent JAR path, the args file format) and on shadowing a library class by matching its exact package. Any update to either Kover or the Screenshot plugin could break them. I’ve opened a feature request to support code coverage natively in the screenshot plugin. Until that lands, this is what it takes.