diff --git a/README.md b/README.md index 7fa8f50..161eb0e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ Now in Android Appで使用されている技術は次のとおりで、これ - Composeのユニットテストについて学ぶ - ViewModelを結合してComposeをテストする - ComposeのNavigationをテストする +- Jetpack Composeの画面スクリーンショットを使ってVisual Regression Testを実現する + - Composeのプレビュー画面でVisual Regression Testを行う + - Visual Regression TestをCIで実行する + - 様々なケースでComposeの画面スクリーンショットを撮る + - Composable Preview Scannerを使ってプレビュー画面のスクリーンショットを撮る ## オリジナルのNow in Android Appからの変更点 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1ac7e53..3a19464 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi import com.google.samples.apps.nowinandroid.NiaBuildType plugins { @@ -34,7 +35,8 @@ android { versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level // Custom test runner to set up Hilt dependency graph - testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" + testInstrumentationRunner = + "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" vectorDrawables { useSupportLibrary = true } @@ -47,7 +49,10 @@ android { val release by getting { isMinifyEnabled = true applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. @@ -77,9 +82,9 @@ android { testOptions { unitTests { all { - it.systemProperty( - "roborazzi.output.dir", - rootProject.file("screenshots").absolutePath + it.systemProperties( + "roborazzi.output.dir" to rootProject.file("screenshots").absolutePath, + "robolectric.pixelCopyRenderMode" to "hardware" ) } isIncludeAndroidResources = true @@ -153,6 +158,8 @@ dependencies { testImplementation(libs.roborazzi) testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.compose.preview.scanner.support) + testImplementation(libs.compose.preview.scanner) testImplementation(libs.robolectric) } @@ -167,4 +174,13 @@ configurations.configureEach { roborazzi { outputDir.set(rootProject.file("screenshots")) -} + @OptIn(ExperimentalRoborazziApi::class) + generateComposePreviewRobolectricTests { + enable = true + testerQualifiedClassName = "com.google.samples.apps.nowinandroid.MyComposePreviewTester" + packages = listOf( + "com.google.samples.apps.nowinandroid.feature.interests", + "com.google.samples.apps.nowinandroid.feature.foryou" + ) + } +} \ No newline at end of file diff --git a/app/src/testAnswer/java/com/github/takahirom/roborazzi/.gitkeep b/app/src/testAnswer/java/com/github/takahirom/roborazzi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/MyComposePreviewTester.kt b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/MyComposePreviewTester.kt new file mode 100644 index 0000000..91dc64b --- /dev/null +++ b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/MyComposePreviewTester.kt @@ -0,0 +1,119 @@ +package com.google.samples.apps.nowinandroid + +import android.content.Context +import android.content.res.Configuration +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.ComposePreviewTester +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.ui.DelayedPreview +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowDisplay +import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner +import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo +import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder +import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview +import sergio.sastre.composable.preview.scanner.core.preview.getAnnotation +import kotlin.math.roundToInt + +@OptIn(ExperimentalRoborazziApi::class) +class MyComposePreviewTester : ComposePreviewTester { + val composeTestRule = createAndroidComposeRule() + override fun options(): ComposePreviewTester.Options { + val testLifecycleOptions = ComposePreviewTester.Options.JUnit4TestLifecycleOptions( + testRuleFactory = { composeTestRule } + ) + return super.options().copy(testLifecycleOptions = testLifecycleOptions) + } + + override fun previews(): List> { + val options = options() + return AndroidComposablePreviewScanner() + .scanPackageTrees(*options.scanOptions.packages.toTypedArray()) + .includeAnnotationInfoForAllOf(DelayedPreview::class.java) + .getPreviews() + } + + override fun test(preview: ComposablePreview) { + val delay = preview.getAnnotation()?.delay ?: 0L + val previewScannerFileName = + AndroidPreviewScreenshotIdBuilder(preview).build() + val fileName = + if (delay == 0L) previewScannerFileName else "${previewScannerFileName}_delay$delay" + val filePath = "$fileName.png" + preview.myApplyToRobolectricConfiguration() + composeTestRule.activityRule.scenario.recreate() + composeTestRule.apply { + try { + if (delay != 0L) { + mainClock.autoAdvance = false + } + setContent { + ApplyToCompositionLocal(preview) { + preview() + } + } + if (delay != 0L) { + mainClock.advanceTimeBy(delay) + } + onRoot().captureRoboImage(filePath = filePath) + } finally { + mainClock.autoAdvance = true + } + } + } +} + +@Composable +fun ApplyToCompositionLocal( + preview: ComposablePreview, + content: @Composable () -> Unit +) { + val fontScale = preview.previewInfo.fontScale + val density = LocalDensity.current + val customDensity = + Density(density = density.density, fontScale = density.fontScale * fontScale) + CompositionLocalProvider(LocalDensity provides customDensity) { + content() + } + +} + + +fun ComposablePreview.myApplyToRobolectricConfiguration() { + val preview = this + // ナイトモード + when (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") + Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") + else -> { /* do nothing */ + } + } + + // 画面サイズ + if (preview.previewInfo.widthDp != -1 && preview.previewInfo.heightDp != -1) { + setDisplaySize(preview.previewInfo.widthDp, preview.previewInfo.heightDp) + } +} + +private fun setDisplaySize(widthDp: Int, heightDp: Int) { + val context = ApplicationProvider.getApplicationContext() + val display = ShadowDisplay.getDefaultDisplay() + val density = context.resources.displayMetrics.density + widthDp.let { + val widthPx = (widthDp * density).roundToInt() + Shadows.shadowOf(display).setWidth(widthPx) + } + heightDp.let { + val heightPx = (heightDp * density).roundToInt() + Shadows.shadowOf(display).setHeight(heightPx) + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 3c0ccd3..095dc33 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -27,7 +27,7 @@ import java.io.File * Configure Compose-specific options */ internal fun Project.configureAndroidCompose( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { val libs = extensions.getByType().named("libs") diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 279be1e..3482bda 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -30,7 +30,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions * Configure base Kotlin with Android options */ internal fun Project.configureKotlinAndroid( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { compileSdk = 34 @@ -71,6 +71,6 @@ internal fun Project.configureKotlinAndroid( } } -fun CommonExtension<*, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { +fun CommonExtension<*, *, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { (this as ExtensionAware).extensions.configure("kotlinOptions", block) } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index dad270e..1c7b4c1 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -23,7 +23,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St } fun Project.configureFlavors( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} ) { commonExtension.apply { diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DelayedPreview.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DelayedPreview.kt new file mode 100644 index 0000000..8b190b9 --- /dev/null +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DelayedPreview.kt @@ -0,0 +1,3 @@ +package com.google.samples.apps.nowinandroid.core.ui + +annotation class DelayedPreview(val delay: Long) diff --git a/docs/handson/UILayerTest.md b/docs/handson/UILayerTest.md index 812897b..db215e6 100644 --- a/docs/handson/UILayerTest.md +++ b/docs/handson/UILayerTest.md @@ -64,3 +64,4 @@ UIテストには、コード変更前後の画面スクリーンショットを - [Composeのプレビュー画面でVisual Regression Testを行う](./VisualRegressionTest_Preview.md) - [Visual Regression TestをCIで実行する](./VisualRegressionTest_CI.md) - [様々なケースでComposeの画面スクリーンショットを撮る](./VisualRegressionTest_Advanced.md) + - [Composable Preview Scannerを使ってプレビュー画面のスクリーンショットを撮る](./VisualRegressionTest_Preview_ComposablePreviewScanner.md) diff --git a/docs/handson/VisualRegressionTest_Advanced.md b/docs/handson/VisualRegressionTest_Advanced.md index 0df7d09..eb7da8f 100644 --- a/docs/handson/VisualRegressionTest_Advanced.md +++ b/docs/handson/VisualRegressionTest_Advanced.md @@ -26,7 +26,7 @@ #### ユースケース1:1つの`@Preview`アノテーションにつき、複数パターンのスクリーンショットを保存する -このユースケースは、「[Composeのプレビュー画面でVisual Regression Testを行う](VisualRegressionTest_Test.md)」で紹介した`ParameterizedRobolectricTestRunner`の仕組みを使うと自然に実現できる。 +このユースケースは、「[Composeのプレビュー画面でVisual Regression Testを行う](VisualRegressionTest_Preview.md)」で紹介した`ParameterizedRobolectricTestRunner`の仕組みを使うと自然に実現できる。 ```kotlin @RunWith(ParameterizedRobolectricTestRunner::class) diff --git a/docs/handson/VisualRegressionTest_Preview.md b/docs/handson/VisualRegressionTest_Preview.md index 391a6aa..f738db3 100644 --- a/docs/handson/VisualRegressionTest_Preview.md +++ b/docs/handson/VisualRegressionTest_Preview.md @@ -97,8 +97,8 @@ plugins { } dependencies { - implementation("com.airbnb.android:showkase:1.0.2") - ksp("com.airbnb.android:showkase-processor:1.0.2") + implementation("com.airbnb.android:showkase:1.0.3") + ksp("com.airbnb.android:showkase-processor:1.0.3") } ``` @@ -238,7 +238,7 @@ RoborazziはRobolectricに依存しているため、RoborazziとRobolectricの ```groovy plugins { - id("io.github.takahirom.roborazzi") version "1.10.1" apply false + id("io.github.takahirom.roborazzi") version "1.26.0" apply false } ``` @@ -248,7 +248,7 @@ plugins { ```kotlin plugins { - id("io.github.takahirom.roborazzi") version "1.10.1" apply false + id("io.github.takahirom.roborazzi") version "1.26.0" } android { @@ -260,9 +260,9 @@ android { } dependencies { - testImplementation("io.github.takahirom.roborazzi:roborazzi:1.10.1") - testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.10.1") - testImplementation("org.robolectric:robolectric:4.11.1") + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.26.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.26.0") + testImplementation("org.robolectric:robolectric:4.13") } ``` diff --git a/docs/handson/VisualRegressionTest_Preview_ComposablePreviewScanner.md b/docs/handson/VisualRegressionTest_Preview_ComposablePreviewScanner.md new file mode 100644 index 0000000..b9a84bc --- /dev/null +++ b/docs/handson/VisualRegressionTest_Preview_ComposablePreviewScanner.md @@ -0,0 +1,391 @@ +# Composable Preview Scannerを使ってプレビュー画面のスクリーンショットを撮る + +本章では、`@Preview`アノテーションが付けられたComposeのプレビュー画面のスクリーンショットを、Composable Preview Scannerを使って撮る方法を説明する。 + + +この章は、DroidKaigi 2024のセッション「[仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう](https://2024.droidkaigi.jp/timetable/692264/)」の補助資料として、次の仕組みの実装方法を説明する。 + + + +- プレビュー関数を集めるライブラリとして[Composable Preview Scanner](https://github.com/sergio-sastre/ComposablePreviewScanner)を使用する +- `@Preview`アノテーションに指定されたプロパティのうち、次のプロパティに指定された内容にしたがってスクリーンショットを出し分ける + - `uiMode`のうち、ナイトモードとライトモードの指定 + - `widthDp`・`heightDp` (画面サイズ) + - `fontScale` (フォントスケール) +- プレビュー関数に`@DelayedPreview(delay = ...)`というカスタムアノテーションが付いている場合は、プレビュー画像を表示してから`delay`の時間だけ経過したタイミングのスクリーンショットを保存する + + + + +## サンプルコードについて + + +- このコードを試す場合は `demoAnswerDebug` ビルドバリアントを利用する +- テストを実行して保存されたスクリーンショットは、`./gradlew clean`では消えない。代わりに次のコマンドを利用する + ```sh + ./gradlew clearRoborazziDemoAnswerDebug + ``` +- 他の章の解説の都合上、本章とは無関係なテストコードも含まれている。ここで説明しているテストだけを実行したいときは、次のコマンドを利用する + ```sh + ./gradlew app:testDemoAnswerDebugUnitTest --tests com.github.takahirom.roborazzi.RoborazziPreviewParameterizedTests + ``` +- Android Studioでは、`build/generated/`配下に生成されたクラスを個別にGradleのtestタスクで実行できない。 + Android Studioから実行するときは、`app/src/testAnswer`の`com.github.takahirom.roborazzi`パッケージを右クリックして「Run 'Tests in 'com.github...'」を実行する + + + + +## この説明で動作確認したバージョン + +- Android Studio: Koala Feature Drop 2024.1.2 +- Gradle: 8.7 +- Android Gradle Plugin: 8.5.1 +- Robolectric: 4.13 +- Roborazzi: 1.26.0 +- Composable Preview Scanner: 0.3.0 + +## ビルドスクリプトのセットアップ + + +### gradle.properties + + + +次のプロパティを設定する +- Roborazziが撮影したスクリーンショット画像の保存先ディレクトリを一箇所に固定するプロパティ +- Android Studioでテストを実行したときにスクリーンショットが保存されるようにするプロパティ + +``` +roborazzi.record.filePathStrategy=relativePathFromRoborazziContextOutputDirectory +roborazzi.test.record=true +``` + +### settings.gradle.kts + +`dependencyResolutionManagement`の`repositories`に、Composable Preview Scannerの配布元であるjitpack.ioを追加する。 + +```kotlin +dependencyResolutionManagement { + ... + repositories { + ... + maven { + url = URI("https://jitpack.io") + } + } +} +``` + +### トップレベルのbuild.gradle.kts + +トップレベルの`build.gradle.kts`でRoborazziプラグインの使用を宣言する。 + +```kotlin +plugins { + id("io.github.takahirom.roborazzi") version "1.26.0" apply false +} +``` + +### appモジュールのbuild.gradle.kts + +appモジュールの`build.gradle.kts`でRoborazziプラグインを適用し、次の設定を追加する。 + +- ユニットテスト実行時のシステムプロパティ `roborazzi.output.dir` には、スクリーンショットを保存するディレクトリを絶対パスで指定する +- 依存関係にはRoborazzi関連3つ、Composable Preview Scanner、Robolectricをそれぞれ指定する +- `roborazzi`ブロックに次の内容を指定する + - 出力先ディレクトリの指定 (システムプロパティ`roborazzi.output.dir`に指定したものと同じ内容) + - (この後で実装する)ComposePreviewTesterの実装クラスのFQCN + - スクリーンショットを撮る対象のプレビュー関数があるパッケージのリスト + +```kotlin +plugins { + id("io.github.takahirom.roborazzi") version "1.26.0" +} + +... + +android { + testOptions { + unitTests { + all { + it.systemProperties( + "roborazzi.output.dir" to rootProject.file("screenshots").absolutePath, + "robolectric.pixelCopyRenderMode" to "hardware" + ) + } + isIncludeAndroidResources = true + } + } +} + +... + +dependencies { + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.26.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.26.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support:1.26.0") + testImplementation("com.github.sergio-sastre.ComposablePreviewScanner:android:0.3.0") + testImplementation("org.robolectric:robolectric:4.13") +} + +roborazzi { + outputDir.set(rootProject.file("screenshots")) + @OptIn(ExperimentalRoborazziApi::class) + generateComposePreviewRobolectricTests { + enable = true + + // ComposePreviewTesterの実装クラスの名前(これから実装する) + testerQualifiedClassName = "com.google.samples.apps.nowinandroid.MyComposePreviewTester" + + // プレビュー関数を集めるパッケージ名。ここではfeature.interestsとfeature.foryouだけにしている + packages = listOf( + "com.google.samples.apps.nowinandroid.feature.interests", + "com.google.samples.apps.nowinandroid.feature.foryou" + ) + } +} + +``` + +## DelayedPreviewカスタムアノテーションの定義 + +プレビュー関数の撮影タイミングを遅らせる`@DelayedPreview`カスタムアノテーションを定義する。 +プレビュー関数が定義されているfeatureモジュールから参照できる必要があるため、ここでは`core:ui`モジュールで定義する。 + +```kotlin +package com.google.samples.apps.nowinandroid.core.ui + +annotation class DelayedPreview(val delay: Long) +``` +## ComposePreviewTesterの実装 + +ファイル名:[MyComposablePreviewTester.kt](../../app/src/testAnswer/java/com/google/samples/apps/nowinandroid/MyComposePreviewTester.kt) + +appモジュールのテストソースセット(ここでは`app/src/testAnswer`)に、ComposePreviewTesterインターフェイスを実装する + + +- ①にて、テストで利用したいJUnit 4 Ruleである `composeTestRule` を宣言する。 + インスタンスは`createAndroidComposeRule()`で生成する +- ②にて、`options()`メソッドをオーバーライドし、①の`composeTestRule`をJUnit 4 Ruleとして使うようにする。 + `testLifecycleOptions`プロパティの`testRuleFactory`に指定する +- ③にて、`previews()`メソッドをオーバーライドする。 + Composable Preview Scannerを使って集めたプレビュー関数と、 + そのメタデータが格納されているクラス`ComposablePreview`のリストを返すように実装する + - `scanPackageTrees()`にはプレビュー関数をスキャンしたいパッケージ名を指定する。 + その値はbuild.gradle.ktsの`roborazzi`ブロックに書かれているので、 + その指定内容を`options.scanOptions`から取ってきている + - 同時にカスタムアノテーション`@DelayedPreview`の情報もスキャンするように + `includeAnnotationInfoForAllOf`を使って、スキャンするカスタムアノテーションを指定する +- ④にて、`test()`メソッドをオーバーライドする。 + プレビュー関数個々の情報(`preview: ComposablePreview`)を受け取り、 + それに対してスクリーンショットを撮るコードを書く。 + - ④-1にて、`@DelayedPreview`アノテーションが指定されている場合は、その`delayed`プロパティを取得する + - ④-2にて、スクリーンショットファイル名が各プレビュー関数(引数として受け取っている`preview`)ごとに一意になるように工夫する + - ④-3にて、撮影環境を変えるのにRobolectricを使うものについて、環境を変えActivityを再生成する + (`myApplyToRobolectricConfiguration()`の内容は後述する) + - ④-4にて、`@DelayedPreview`アノテーションの`delayed`プロパティが指定されているときだけ、 + `composeTestRule`の`mainClock`を使って、スクリーンショットを撮るタイミングを操作する + - ④-5にて、LocalCompositionProviderを使って撮影環境を変える + (`ApplyToCompositionLocal`の内容は後述する) + + + +```kotlin +package com.google.samples.apps.nowinandroid + +@OptIn(ExperimentalRoborazziApi::class) +class MyComposePreviewTester : ComposePreviewTester { + // ① + val composeTestRule = createAndroidComposeRule() + + // ② + override fun options(): ComposePreviewTester.Options { + val testLifecycleOptions = ComposePreviewTester.Options.JUnit4TestLifecycleOptions( + testRuleFactory = { composeTestRule } + ) + return super.options().copy(testLifecycleOptions = testLifecycleOptions) + } + + // ③ + override fun previews(): List> { + val options = options() + return AndroidComposablePreviewScanner() + .scanPackageTrees(*options.scanOptions.packages.toTypedArray()) + .includeAnnotationInfoForAllOf(DelayedPreview::class.java) + .getPreviews() + } + + // ④ + override fun test(preview: ComposablePreview) { + // ④-1 + val delay = preview.getAnnotation()?.delay ?: 0L + + // ④-2 + val previewScannerFileName = + AndroidPreviewScreenshotIdBuilder(preview).build() + val fileName = + if (delay == 0L) previewScannerFileName else "${previewScannerFileName}_delay$delay" + val filePath = "$fileName.png" + + // ④-3 + preview.myApplyToRobolectricConfiguration() + composeTestRule.activityRule.scenario.recreate() + + + composeTestRule.apply { + try { + // ④-4 + if (delay != 0L) { + mainClock.autoAdvance = false + } + setContent { + // ④-5 + ApplyToCompositionLocal(preview) { + preview() + } + } + // ④-4 + if (delay != 0L) { + mainClock.advanceTimeBy(delay) + } + onRoot().captureRoboImage(filePath = filePath) + } finally { + // ④-4 + mainClock.autoAdvance = true + } + } + } +} +``` + +## uiModeと画面サイズへの対応 (`myApplyToRobolectricConfiguration()`の実装) + +`myApplyToRobolectricConfiguration()`にて、`uiMode`と画面サイズに対応する実装は次のとおり。 + +```kotlin +fun ComposablePreview.myApplyToRobolectricConfiguration() { + val preview = this + // ナイトモード + when (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") + Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") + else -> { /* do nothing */ + } + } + + // 画面サイズ + if (preview.previewInfo.widthDp != -1 && preview.previewInfo.heightDp != -1) { + setDisplaySize(preview.previewInfo.widthDp, preview.previewInfo.heightDp) + } +} + +private fun setDisplaySize(widthDp: Int, heightDp: Int) { + val context = ApplicationProvider.getApplicationContext() + val display = ShadowDisplay.getDefaultDisplay() + val density = context.resources.displayMetrics.density + widthDp.let { + val widthPx = (widthDp * density).roundToInt() + Shadows.shadowOf(display).setWidth(widthPx) + } + heightDp.let { + val heightPx = (heightDp * density).roundToInt() + Shadows.shadowOf(display).setHeight(heightPx) + } +} +``` + +## フォントスケールへの対応 (`ApplyToCompositionLocal()`の実装) + + +`ApplyToCompositionLocal()`にて、フォントスケールに対応する実装は次のとおり。 + +```kotlin +@Composable +fun ApplyToCompositionLocal( + preview: ComposablePreview, + content: @Composable () -> Unit +) { + val fontScale = preview.previewInfo.fontScale + val density = LocalDensity.current + val customDensity = + Density(density = density.density, fontScale = density.fontScale * fontScale) + CompositionLocalProvider(LocalDensity provides customDensity) { + content() + } +} +``` + +## テストの実行 + +### Android Studioからの実行 + +ここで作ったファイル`MyComposePreviewTester.kt`はテストクラスではないため、そのファイルを右クリックしてテストを実行できない。 + + +このファイルを実行するテストクラスは、Roborazziが自動生成する`com.github.takahirom.roborazzi.RoborazziPreviewParameterizedTests`だが、 +このファイルは`build/generated`に生成されるため、Android StudioがGradleのtestタスクで実行するテストだと認識しない。 + + +そのため、`RoborazziPreviewParameterizedTests`が所属するパッケージ`com.github.takahirom.roborazzi`を`app/src/testAnswer`にも作成し、 +それを右クリックしてテストを実行する(作成したパッケージの中身は空で良い)。 + +![Android Studioでの実行方法](images/vrt_roborazzi_generated_test.png) + +### コマンドラインからの実行 + +このテストだけを実行したいときは、次のコマンドを実行する。 + +```sh +./gradlew app:testDemoAnswerDebugUnitTest --tests com.github.takahirom.roborazzi.RoborazziPreviewParameterizedTests +``` + +### スクリーンショットやテストレポートの格納先 + +- スクリーンショットは、Gradleのルートプロジェクトのディレクトリ直下の `screenshots/`に保存される +- テストレポートは`app/build/reports/roborazzi/index.html`に生成される + +### スクリーンショットのクリーン + +テスト実行をやり直したいなどの理由で、保存されたスクリーンショットを削除したいときは、次のコマンドを実行する。 + +```sh +./gradlew clearRoborazziDemoAnswerDebug +``` + +## 用意されているプレビュー関数について + +ここでは、[ForYouScreen.kt](../../feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt)のプレビュー関数に対して、いくつかのプロパティを設定している。 + +`@Preview`のプロパティに指定している値や`@DelayedPreview`の`delay`プロパティに指定している値を変更し、 +スクリーンショットを撮りなおしてみて、どのように結果が変化するのか見てみるとより理解が深まる。 + +### ForYouScreenPopulatedFeed + +次のように、3つの`@Preview`アノテーションで、3枚のスクリーンショットを撮るように指定している。 + +- 画面サイズ「幅1280dp × 高さ300dp」 +- ナイトモード +- フォントスケール2倍 + +```kotlin +@Preview(widthDp = 1280, heightDp = 300) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(fontScale = 2f) +@Composable +fun ForYouScreenPopulatedFeed() { + ... +} +``` + +### ForYouScreenLoading + +このプレビュー画面は、ローディングアニメーションが無限にリピートするものだが、そこで2秒(2000msec)後のスクリーンショットを撮るように指定している。 + +``` +@Preview +@DelayedPreview(delay = 2000) +@Composable +fun ForYouScreenLoading() { + ... +} +``` diff --git a/docs/handson/images/vrt_roborazzi_generated_test.png b/docs/handson/images/vrt_roborazzi_generated_test.png new file mode 100644 index 0000000..cdf214a Binary files /dev/null and b/docs/handson/images/vrt_roborazzi_generated_test.png differ diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index ac65889..82b307a 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou import android.app.Activity +import android.content.res.Configuration import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -26,7 +27,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -87,7 +87,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewTopics -import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.DelayedPreview import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -390,10 +390,13 @@ fun TopicIcon( ) } -@Preview + +@Preview(widthDp = 1280, heightDp = 300) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(fontScale = 2f) @Composable fun ForYouScreenPopulatedFeed() { - BoxWithConstraints { + Box { NiaTheme { ForYouScreen( isSyncing = false, @@ -412,7 +415,7 @@ fun ForYouScreenPopulatedFeed() { @Preview @Composable private fun ForYouScreenOfflinePopulatedFeed() { - BoxWithConstraints { + Box { NiaTheme { ForYouScreen( isSyncing = false, @@ -431,7 +434,7 @@ private fun ForYouScreenOfflinePopulatedFeed() { @Preview @Composable fun ForYouScreenTopicSelection() { - BoxWithConstraints { + Box { NiaTheme { ForYouScreen( isSyncing = false, @@ -450,9 +453,10 @@ fun ForYouScreenTopicSelection() { } @Preview +@DelayedPreview(delay = 2000) @Composable -private fun ForYouScreenLoading() { - BoxWithConstraints { +fun ForYouScreenLoading() { + Box { NiaTheme { ForYouScreen( isSyncing = false, @@ -469,7 +473,7 @@ private fun ForYouScreenLoading() { @Preview @Composable private fun ForYouScreenPopulatedAndLoading() { - BoxWithConstraints { + Box { NiaTheme { ForYouScreen( isSyncing = true, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a66b9af..49c6503 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" -androidGradlePlugin = "8.2.0" +androidGradlePlugin = "8.5.1" androidxActivity = "1.6.1" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" @@ -48,9 +48,10 @@ retrofitKotlinxSerializationJson = "0.8.0" room = "2.5.2" secrets = "2.0.1" turbine = "0.12.1" -robolectric = "4.11.1" -showkase = "1.0.2" -roborazzi = "1.10.1" +robolectric = "4.13" +showkase = "1.0.3" +roborazzi = "1.26.0" +compose-preview-scanner = "0.3.0" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } @@ -132,6 +133,8 @@ showkase = { group = "com.airbnb.android", name = "showkase", version.ref = "sho showkase-processor = { group = "com.airbnb.android", name = "showkase-processor", version.ref = "showkase" } roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi"} roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi"} +roborazzi-compose-preview-scanner-support = {group = "io.github.takahirom.roborazzi", name = "roborazzi-compose-preview-scanner-support", version.ref = "roborazzi" } +compose-preview-scanner = { group = "com.github.sergio-sastre.ComposablePreviewScanner", name = "android", version.ref = "compose-preview-scanner"} # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 15de902..48c0a02 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 809efd2..89ec857 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI + /* * Copyright 2021 The Android Open Source Project * @@ -28,6 +30,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url = URI("https://jitpack.io") + } } } rootProject.name = "nowinandroid"