diff --git a/Samples/Flix/FlixHilt/build.gradle b/Samples/Flix/FlixHilt/build.gradle new file mode 100644 index 00000000..e227c93e --- /dev/null +++ b/Samples/Flix/FlixHilt/build.gradle @@ -0,0 +1,88 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +buildscript { + ext { + versions = [ + 'compose': [ + 'compilerExt': '1.5.14', + 'ui' : '1.6.7', + ] + ] + } +} + +plugins { + id 'com.android.library' + alias(libs.plugins.google.devtools.ksp) + id 'com.google.dagger.hilt.android' + id 'dev.testify' + alias(libs.plugins.compose.compiler) +} + +android { + namespace 'dev.testify.samples.flix.hilt' + compileSdk 36 + + defaultConfig { + minSdkVersion 26 + targetSdkVersion 36 + + // Hilt tests need HiltTestApplication. This setting is module-wide, which is why the + // example lives here rather than in :FlixSample — see HiltTestRunner. + testInstrumentationRunner "dev.testify.samples.flix.hilt.HiltTestRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_25 + targetCompatibility JavaVersion.VERSION_25 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_25) + } + } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion "${versions.compose.compilerExt}" + } +} + +dependencies { + def composeBom = platform('androidx.compose:compose-bom:2025.08.01') + implementation composeBom + + implementation "androidx.appcompat:appcompat:1.7.1" + implementation 'androidx.compose.material3:material3:1.3.2' + implementation "androidx.hilt:hilt-navigation-compose:1.2.0" + implementation "com.google.dagger:hilt-android:2.60.1" + ksp "com.google.dagger:hilt-compiler:2.60.1" + + // HiltComposableTestActivity lives in the `debug` source set, which cannot see the + // `androidTest` dependencies below. It extends ComposableTestActivity, which implements an + // interface from the core library, so both artifacts are needed to compile it. + // + // `compileOnly` is enough: at run time the Testify classes come from the test APK, as they do + // for ComposableTestActivity itself. `debugImplementation` would instead add Testify's own test + // dependencies to the module and clash with the versions `androidTest` resolves. + debugCompileOnly project(":Library") + debugCompileOnly project(path: ":ComposeExtensions") + + androidTestImplementation composeBom + androidTestImplementation project(":Library") + androidTestImplementation project(path: ":ComposeExtensions") + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation "androidx.test:core-ktx:1.4.0" + androidTestImplementation "androidx.test:rules:1.7.0" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:${versions.compose.ui}" + + androidTestImplementation "com.google.dagger:hilt-android-testing:2.60.1" + kspAndroidTest "com.google.dagger:hilt-compiler:2.60.1" +} + +testify { + applicationPackageId "dev.testify.samples.flix.hilt.test" + testPackageId "dev.testify.samples.flix.hilt.test" + autoImplementLibrary false +} diff --git a/Samples/Flix/FlixHilt/src/androidTest/assets/screenshots/37-1080x2220@440dp-en_US/HiltComposableScreenshotTest_default.png b/Samples/Flix/FlixHilt/src/androidTest/assets/screenshots/37-1080x2220@440dp-en_US/HiltComposableScreenshotTest_default.png new file mode 100644 index 00000000..cddfe0bc Binary files /dev/null and b/Samples/Flix/FlixHilt/src/androidTest/assets/screenshots/37-1080x2220@440dp-en_US/HiltComposableScreenshotTest_default.png differ diff --git a/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltComposableScreenshotTest.kt b/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltComposableScreenshotTest.kt new file mode 100644 index 00000000..905a5e6c --- /dev/null +++ b/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltComposableScreenshotTest.kt @@ -0,0 +1,87 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.samples.flix.hilt + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.test.core.app.launchActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dev.testify.annotation.ScreenshotInstrumentation +import dev.testify.compose.scenario.ComposableScreenshotScenarioRule +import org.junit.Rule +import org.junit.Test + +/** + * Demonstrates screenshot testing a composable that resolves its dependencies through Hilt. + * + * The composable under test calls `hiltViewModel()`, which needs an `@AndroidEntryPoint` host. The + * three pieces that make that work: + * + * 1. [HiltComposableTestActivity] in the `debug` source set, an `@AndroidEntryPoint` subclass of + * `ComposableTestActivity`, declared in `src/debug/AndroidManifest.xml`. + * 2. [HiltTestRunner] as the module's `testInstrumentationRunner`. + * 3. [HiltAndroidRule] ordered before the Testify rule, so the component is ready before the + * activity launches. + * + * `ComposableScreenshotRule` cannot be used here: it hardcodes `ComposableTestActivity` as its + * activity. `ComposableScreenshotScenarioRule` lets the test choose the host. + * + * @see Testing composables that use Hilt + */ +@HiltAndroidTest +class HiltComposableScreenshotTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val rule = ComposableScreenshotScenarioRule() + + /** + * Resolves [SampleViewModel] through Hilt and renders a value that comes from it. If the + * injection fails the test throws rather than capturing, so a passing baseline is itself the + * assertion that Hilt resolved through [HiltComposableTestActivity]. + */ + @Composable + private fun HiltBackedContent() { + val viewModel = hiltViewModel() + Text(text = viewModel.label) + } + + @ScreenshotInstrumentation + @Test + fun default() { + launchActivity().use { scenario -> + rule + .withScenario(scenario) + .setCompose { + HiltBackedContent() + } + .assertSame() + } + } +} diff --git a/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltTestRunner.kt b/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltTestRunner.kt new file mode 100644 index 00000000..77147d21 --- /dev/null +++ b/Samples/Flix/FlixHilt/src/androidTest/java/dev/testify/samples/flix/hilt/HiltTestRunner.kt @@ -0,0 +1,45 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.samples.flix.hilt + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * Substitutes [HiltTestApplication] for the app's own [Application] so Hilt can build its test + * component. + * + * `testInstrumentationRunner` is module-wide, which is why this module exists. Pointing the `Flix` + * sample at this runner would replace its `FlixApplication`, and with it the Coil `ImageLoader` that + * `FlixApplication` supplies through `ImageLoaderFactory` — so its image-backed screenshot tests + * would capture before their images had drawn. Keeping the Hilt example in its own module leaves + * that suite alone. + */ +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/Samples/Flix/FlixHilt/src/debug/AndroidManifest.xml b/Samples/Flix/FlixHilt/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..951eabc8 --- /dev/null +++ b/Samples/Flix/FlixHilt/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/Samples/Flix/FlixHilt/src/debug/java/dev/testify/samples/flix/hilt/HiltComposableTestActivity.kt b/Samples/Flix/FlixHilt/src/debug/java/dev/testify/samples/flix/hilt/HiltComposableTestActivity.kt new file mode 100644 index 00000000..72d2391d --- /dev/null +++ b/Samples/Flix/FlixHilt/src/debug/java/dev/testify/samples/flix/hilt/HiltComposableTestActivity.kt @@ -0,0 +1,59 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.samples.flix.hilt + +import dagger.hilt.android.AndroidEntryPoint +import dev.testify.ComposableTestActivity + +/** + * A Hilt-aware host for composables under test. + * + * Hilt injects only into activities annotated with [AndroidEntryPoint]. Testify's + * [ComposableTestActivity] is not, so a composable that resolves a dependency — through + * `hiltViewModel()`, for example — fails inside it with: + * + * ``` + * java.lang.IllegalStateException: Given component holder class dev.testify.ComposableTestActivity + * does not implement interface dagger.hilt.internal.GeneratedComponent or interface + * dagger.hilt.internal.GeneratedComponentManager + * ``` + * + * Subclassing it here adds the annotation without changing anything else, so + * `ComposableScreenshotScenarioRule` can host Hilt-backed composables. + * + * This lives in the `debug` source set, with its manifest entry in `src/debug/AndroidManifest.xml` + * and Testify on the debug compile classpath as `debugCompileOnly`. That is the arrangement an + * application module needs — a test-only activity has no business in a shipping build — and it is + * what the [Hilt recipe](https://testify.dev/docs/recipes/hilt) describes, so this sample mirrors + * the recipe step for step. + * + * It is not the only arrangement that works. In a library module the test APK *is* the application, + * so the activity can equally live in `androidTest` with an `androidTest` manifest and no + * `compileOnly` at all. `debug` is used here to match the recipe. + * + * @see HiltComposableScreenshotTest + */ +@AndroidEntryPoint +class HiltComposableTestActivity : ComposableTestActivity() diff --git a/Samples/Flix/FlixHilt/src/main/java/dev/testify/samples/flix/hilt/SampleViewModel.kt b/Samples/Flix/FlixHilt/src/main/java/dev/testify/samples/flix/hilt/SampleViewModel.kt new file mode 100644 index 00000000..8b3e71a9 --- /dev/null +++ b/Samples/Flix/FlixHilt/src/main/java/dev/testify/samples/flix/hilt/SampleViewModel.kt @@ -0,0 +1,41 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.samples.flix.hilt + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * A view model with nothing in it, so the test has something for Hilt to resolve. + * + * The point of [HiltComposableScreenshotTest] is that `hiltViewModel()` resolves at all inside a + * screenshot test. Giving this no dependencies and no mutable state keeps the rendered output + * deterministic, so a failure means the injection broke rather than the data changed. + */ +@HiltViewModel +class SampleViewModel @Inject constructor() : ViewModel() { + val label: String = "injected by Hilt" +} diff --git a/bitrise.yml b/bitrise.yml index 491cfde3..c8e2013e 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -431,6 +431,22 @@ workflows: - test_package: dev.testify.samples.flix.library.test - test_runner: androidx.test.runner.AndroidJUnitRunner - verbose: true + - android-build@1: + inputs: + - variant: DebugAndroidTest + - module: FlixHilt + - git::https://github.com/ndtp/bitrise-step-android-testify.git@main: + timeout: 1800 + title: Run Screenshot Tests + inputs: + - adb_command: "./gradlew FlixHilt:screenshotTest" + - module: ":FlixHilt" + - app_apk: "./Samples/Flix/FlixHilt/build/outputs/apk/androidTest/debug/FlixHilt-debug-androidTest.apk" + - app_package: dev.testify.samples.flix.hilt.test + - test_apk: "./Samples/Flix/FlixHilt/build/outputs/apk/androidTest/debug/FlixHilt-debug-androidTest.apk" + - test_package: dev.testify.samples.flix.hilt.test + - test_runner: dev.testify.samples.flix.hilt.HiltTestRunner + - verbose: true - custom-test-results-export@1.0: is_always_run: true timeout: 120 diff --git a/settings.gradle b/settings.gradle index 4e3ba1ae..1d6b79a7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ dependencyResolutionManagement { include ':Accessibility' include ':ComposeExtensions' +include ':FlixHilt' include ':FlixLibrary' include ':FlixSample' include ':FullscreenCaptureMethod' @@ -32,6 +33,7 @@ includeBuild("./Plugins/Gradle") { name = "Plugin" } project(':Accessibility').projectDir = new File("./Ext/Accessibility") project(':ComposeExtensions').projectDir = new File("./Ext/Compose") +project(':FlixHilt').projectDir = new File("./Samples/Flix/FlixHilt") project(':FlixLibrary').projectDir = new File("./Samples/Flix/FlixLibrary") project(':FlixSample').projectDir = new File("./Samples/Flix") project(':FullscreenCaptureMethod').projectDir = new File("./Ext/Fullscreen")