Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@
- Fix `RootViewWithoutFocusException` being intermittently thrown by `Espresso.closeSoftKeyboard()`
- **Breaking:** `dev.testify.internal.helpers.closeSoftKeyboard()` now requires an `Activity` parameter
- Fix `ClassCastException` when passing `-PshardCount` and `-PshardIndex` to `screenshotTest` or `screenshotRecord`
- Add support for test-only modules. The plugin can now be applied to a `com.android.test` module;
previously it failed with `Gradle project must contain an 'android' closure`
- `applicationPackageId` and `testPackageId` are both inferred: the application under test comes
from `targetProjectPath`, and the test package is the module's own namespace
- `screenshotTest` and `screenshotRecord` also depend on the target project's install task, so
the application under test is installed and not merely assumed to be present
- `baselineSourceDir` defaults to `src/main/assets` for a test module, since a `com.android.test`
module has no `androidTest` source set — its tests are its `main` sources
- The Testify library is added as an `implementation` dependency rather than
`androidTestImplementation` for the same reason
- See the new `Samples/Flix/FlixTest` module for a worked example
- `screenshotTest` and `screenshotRecord` now fail when `am instrument` did not run the tests at all
— an uninstalled application under test, for example. Previously the task reported success having
run nothing
- The check is that the output carries a JUnit summary, `OK (n tests)` or `FAILURES!!!`. A run
that executed always produces one, so this does not depend on recognising the ways
instrumentation can fail to start
- A recording run still never fails because a screenshot differs, but it does now fail if
nothing ran
- The plugin also reads the instrumentation's standard error, which it previously discarded

## 6.0.0

Expand Down
32 changes: 29 additions & 3 deletions Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
package dev.testify

import dev.testify.internal.android
import dev.testify.internal.isTestModule
import dev.testify.internal.targetProjectPath
import dev.testify.internal.testModulePackageId
import dev.testify.internal.applicationTargetPackageId
import dev.testify.internal.inferredAndroidTestInstallTask
import dev.testify.internal.inferredDefaultTestVariantId
Expand Down Expand Up @@ -113,12 +116,17 @@ internal data class TestifySettings(
val android = project.android
val extension = project.getTestifyExtension()

// A `com.android.test` module has no `androidTest` source set; its tests are its
// `main` sources, so that is where its baselines belong.
val testSourceSet = if (project.isTestModule) "main" else "androidTest"
val baselineSourceDir = extension.baselineSourceDir
?: project.android.sourceSets.findByName("androidTest")?.assets?.directories?.firstOrNull()
?: "src/androidTest/assets"
?: project.android.sourceSets.findByName(testSourceSet)?.assets?.directories?.firstOrNull()
?: "src/$testSourceSet/assets"
val testRunner = extension.testRunner ?: android.defaultConfig.testInstrumentationRunner ?: "unknown"
val pullWaitTime = extension.pullWaitTime ?: 0L
val testPackageId = extension.testPackageId ?: project.inferredDefaultTestVariantId
val testPackageId = extension.testPackageId
?: project.testModulePackageId
?: project.inferredDefaultTestVariantId
val targetPackageId = extension.applicationPackageId ?: project.inferredTargetPackageId
val version = TestifySettings::class.java.getPackage().implementationVersion
val isSnapshot = version?.contains("SNAPSHOT", ignoreCase = true) ?: false
Expand Down Expand Up @@ -188,6 +196,13 @@ private val Project.inferredTargetPackageId: String
get() {
var targetPackageId: String? = this.applicationTargetPackageId

// A `com.android.test` module has no applicationId of its own: the application under test is
// the project named by `targetProjectPath`. Screenshots are written into that application's
// data directory, so this has to be its id or `screenshotPull` looks in the wrong package.
if (targetPackageId.isNullOrEmpty()) {
targetPackageId = this.targetProjectApplicationId
}

// If we still do not have a targetPackageId, it is likely a library project
// Infer the package from the test configuration
if (targetPackageId.isNullOrEmpty()) {
Expand All @@ -197,6 +212,17 @@ private val Project.inferredTargetPackageId: String
return targetPackageId
}

/**
* The applicationId of the project a `com.android.test` module targets, or `null` for any other
* module type or when the target cannot be resolved.
*/
private val Project.targetProjectApplicationId: String?
get() {
val targetPath = this.targetProjectPath ?: return null
val targetProject = this.rootProject.findProject(targetPath) ?: return null
return targetProject.applicationTargetPackageId
}

open class TestifyExtension {

var baselineSourceDir: String? = null
Expand Down
11 changes: 9 additions & 2 deletions Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ package dev.testify
import dev.testify.TestifyPlugin.Companion.EVALUATED_SETTINGS
import dev.testify.internal.Style.Description
import dev.testify.internal.android
import dev.testify.internal.isTestModule
import dev.testify.internal.isVerbose
import dev.testify.internal.println
import dev.testify.tasks.internal.TaskNameProvider
Expand Down Expand Up @@ -78,8 +79,14 @@ class TestifyPlugin : Plugin<Project> {
if (settings.autoImplementLibrary) {
val version = javaClass.getPackage().implementationVersion.orEmpty()
val dependency = "dev.testify:testify:$version"
if (project.isVerbose) println(Description, "Adding androidTestImplementation($dependency)")
project.dependencies.add("androidTestImplementation", dependency)

if (project.isTestModule) {
if (project.isVerbose) println(Description, "Adding implementation($dependency)")
project.dependencies.add("implementation", dependency)
} else {
if (project.isVerbose) println(Description, "Adding androidTestImplementation($dependency)")
project.dependencies.add("androidTestImplementation", dependency)
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions Plugins/Gradle/src/main/kotlin/dev/testify/internal/Adb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class Adb(
return this
}

fun execute(targetsDevice: Boolean = true): String {
fun execute(targetsDevice: Boolean = true, redirectErrorStream: Boolean = false): String {
if (targetsDevice) {
val deviceTarget = Device.targets(adbService)[deviceTargetIndex]
if (deviceTarget != null) {
Expand All @@ -121,7 +121,7 @@ class Adb(
println(Description, command)
}

return runProcess(command, streamData ?: BufferedStream())
return runProcess(command, streamData ?: BufferedStream(), redirectErrorStream)
}

fun testOptions(testOptionsBuilder: TestOptionsBuilder): Adb {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,23 @@ sealed class StreamData {
internal val Project.destinationImageDirectory: String
get() = "${project.testifySettings.baselineSourceDir}${File.separatorChar}"

fun runProcess(command: String, streamData: StreamData = BufferedStream()): String {
val process = Runtime.getRuntime().exec(command)
/**
* Run [command] and return what it wrote.
*
* @param redirectErrorStream merge the process's standard error into the returned output. `adb`
* writes most of what it has to say to standard output, but `am instrument` reports a failure to
* start the tests at all — an uninstalled target package, for instance — on standard error. A
* caller that inspects the output for failures has to see it, or it reports success having run
* nothing.
*/
fun runProcess(
command: String,
streamData: StreamData = BufferedStream(),
redirectErrorStream: Boolean = false
): String {
val process = ProcessBuilder(*command.split(" ").toTypedArray())
.redirectErrorStream(redirectErrorStream)
.start()
val result = streamData.handleInputStream(process.inputStream)
process.waitFor()
return result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,51 @@ package dev.testify.internal
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.dsl.TestExtension
import org.gradle.api.GradleException
import org.gradle.api.Project

val Project.android: CommonExtension<*, *, *, *, *, *>
get() = this.extensions.findByType(ApplicationExtension::class.java)
?: this.extensions.findByType(LibraryExtension::class.java)
?: this.extensions.findByType(TestExtension::class.java)
?: throw GradleException("Gradle project must contain an `android` closure")

val Project.isTestModule: Boolean
get() = this.extensions.findByType(TestExtension::class.java) != null

/**
* The Gradle path of the application under test, for a `com.android.test` module.
*
* A test-only module declares its target with `targetProjectPath`. The instrumentation runs against
* that application, and its screenshots are written into that application's data directory, so the
* target's APK has to be installed for the tests to run at all.
*
* `null` for any other module type.
*/
val Project.targetProjectPath: String?
get() = this.extensions.findByType(TestExtension::class.java)?.targetProjectPath

/**
* The applicationId of a `com.android.test` module's own APK.
*
* A test module's APK carries the instrumentation, so this is the package `am instrument` is invoked
* against.
*
* It is the namespace, deliberately and not as a fallback. AGP 9 does not let a `com.android.test`
* module choose its own applicationId: `TestDefaultConfig` does not expose `applicationId` in the
* typed DSL, and setting it from the Groovy DSL is accepted silently and then ignored — the built
* APK, and the installed package, use the namespace regardless. Reading `defaultConfig.applicationId`
* would therefore report an id that is not installed.
*
* `null` for any other module type.
*/
val Project.testModulePackageId: String?
get() {
val testExtension = this.extensions.findByType(TestExtension::class.java) ?: return null
return testExtension.namespace
}

val Project.isVerbose: Boolean
get() = (this.properties["verbose"] as? String)?.toBoolean() ?: false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import dev.testify.internal.StreamData.ConsoleStream
import dev.testify.internal.Style.Failure
import dev.testify.internal.TestOptionsBuilder
import dev.testify.internal.fromEnv
import dev.testify.internal.inferredInstallTask
import dev.testify.internal.println
import dev.testify.internal.targetProjectPath
import dev.testify.tasks.internal.TaskDependencyProvider
import dev.testify.tasks.internal.TaskNameProvider
import dev.testify.tasks.internal.TestifyDefaultTask
Expand Down Expand Up @@ -168,11 +170,15 @@ open class ScreenshotTestTask : TestifyDefaultTask() {
.argument("-w")
.argument("$testPackageId/$testRunner")
.stream(ConsoleStream)
.execute()
// `am instrument` reports a failure to start the tests on standard error, and
// finalizeTaskAction has to see it.
.execute(redirectErrorStream = true)
finalizeTaskAction(log)
}

protected open fun finalizeTaskAction(log: String) {
verifyInstrumentationRan(log)

if (log.contains("FAILURES!!!") ||
log.contains("INSTRUMENTATION_CODE: 0") ||
log.contains("Process crashed while executing")
Expand All @@ -182,7 +188,36 @@ open class ScreenshotTestTask : TestifyDefaultTask() {
}
}

/**
* Fail if `am instrument` did not get as far as running the tests.
*
* A run that executed always ends with JUnit's summary — `OK (n tests)` when everything passed,
* `FAILURES!!!` otherwise — so a log with neither means nothing ran. Testing for the summary
* rather than for particular error strings is deliberate: the ways instrumentation can fail to
* start are open-ended, and matching them individually kept missing variants. An uninstalled
* application under test reports `Error=Unable to find instrumentation target package` on some
* runs and only `INSTRUMENTATION_FAILED` on others, and either way the task used to report
* success having run no tests at all.
*/
protected fun verifyInstrumentationRan(log: String) {
if (log.contains(JUNIT_PASS_SUMMARY) || log.contains(JUNIT_FAIL_SUMMARY)) return

println(Failure, "THE SCREENSHOT TESTS DID NOT RUN!!!")
throw RuntimeException(
"`am instrument` did not run any tests. The output has no test summary, which means " +
"the instrumentation could not start. Check that the application under test is " +
"installed and that testPackageId and testRunner are correct; run with " +
"`-Pverbose=true` to see the command and its full output."
)
}

companion object : TaskNameProvider, TaskDependencyProvider {
/** JUnit's summary for a run where every test passed, including a run of no tests. */
private const val JUNIT_PASS_SUMMARY = "OK ("

/** JUnit's summary for a run with at least one failure. */
private const val JUNIT_FAIL_SUMMARY = "FAILURES!!!"

override fun taskName() = "screenshotTest"

override fun setDependencies(taskNameProvider: TaskNameProvider, project: Project) {
Expand All @@ -200,6 +235,33 @@ open class ScreenshotTestTask : TestifyDefaultTask() {
getInstallDebugTask(project)?.let { installDebugTask ->
task.dependsOn(installDebugTask)
}
addTargetProjectInstallDependency(task, project)
}

/**
* For a `com.android.test` module, also install the application it targets.
*
* A test module's own `installDebug` installs the test APK; nothing installs the application
* named by `targetProjectPath`. Without it `am instrument` cannot find its target package,
* so no tests run — and the screenshots would have nowhere to go, because they are written
* into the target application's data directory.
*/
private fun addTargetProjectInstallDependency(task: Task, project: Project) {
val targetPath = project.targetProjectPath ?: return
val targetProject = project.rootProject.findProject(targetPath) ?: return

fun dependOnInstallTaskOf(evaluated: Project) {
evaluated.inferredInstallTask?.let { taskName ->
task.dependsOn("${evaluated.path}:$taskName")
}
}

// The target may be configured before or after this module, depending on project order.
if (targetProject.state.executed) {
dependOnInstallTaskOf(targetProject)
} else {
targetProject.afterEvaluate(::dependOnInstallTaskOf)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ open class InternalScreenshotTestRecordTask : ScreenshotTestTask() {
}

override fun finalizeTaskAction(log: String) {
// Do nothing
// A recording run never fails because a screenshot differs - that is the point of it. It
// must still fail if the instrumentation never ran, or it reports success having recorded
// nothing.
verifyInstrumentationRan(log)
}

companion object : TaskNameProvider {
Expand Down
1 change: 1 addition & 0 deletions Samples/Flix/FlixTest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
65 changes: 65 additions & 0 deletions Samples/Flix/FlixTest/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
id 'com.android.test'
id 'dev.testify'
alias(libs.plugins.compose.compiler)
}

android {
// Deliberately not `…flix.test`: that is FlixSample's own androidTest APK id (its
// applicationId plus AGP's default `.test` suffix), and the two APKs would install over each
// other.
namespace 'dev.testify.samples.flix.screenshots'
compileSdk 36

// Target the Flix application module
targetProjectPath ':FlixSample'

defaultConfig {
minSdkVersion 26
targetSdk 36

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_25
targetCompatibility JavaVersion.VERSION_25
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25)
}
}
buildFeatures {
compose true
}
}

dependencies {

implementation project(":Library")
implementation project(":ComposeExtensions")

def composeBom = platform('androidx.compose:compose-bom:2025.08.01')
implementation composeBom
implementation 'androidx.compose.material3:material3:1.3.2'

// AndroidX Test
implementation 'androidx.test.ext:junit:1.3.0'
// ComposableScreenshotRule extends ActivityTestRule, which lives here.
implementation "androidx.test:rules:1.7.0"
implementation "androidx.test:runner:1.7.0"

// Align lifecycle with the application under test, to keep the test APK and app APK from
// disagreeing on the version.
implementation "androidx.lifecycle:lifecycle-common:2.9.3"
implementation "androidx.lifecycle:lifecycle-runtime:2.9.3"
}

testify {
// applicationPackageId and testPackageId are both inferred for a test module:
// the application under test comes from `targetProjectPath`, and the test APK is this module.
autoImplementLibrary false
}
3 changes: 3 additions & 0 deletions Samples/Flix/FlixTest/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading