diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 20ff380..f0c8832 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumedWindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -140,7 +140,7 @@ fun NiaApp( Modifier .fillMaxSize() .padding(padding) - .consumedWindowInsets(padding) + .consumeWindowInsets(padding) .windowInsetsPadding( WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal diff --git a/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt index 9665208..8c58cb4 100644 --- a/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt +++ b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt @@ -62,11 +62,10 @@ class AllPreviewScreenshotTest( override fun toString() = showkaseBrowserComponent.componentKey } - @ParameterizedRobolectricTestRunner.Parameters(name = "[{index}] {0}") @JvmStatic fun components(): Iterable> = Showkase.getMetadata().componentList.map { arrayOf(TestCase(it)) } } -} +} \ No newline at end of file diff --git a/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt new file mode 100644 index 0000000..0e69c5a --- /dev/null +++ b/app/src/testAnswer/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt @@ -0,0 +1,136 @@ +package com.google.samples.apps.nowinandroid + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.unit.Density +import androidx.test.core.app.ApplicationProvider +import com.airbnb.android.showkase.models.Showkase +import com.airbnb.android.showkase.models.ShowkaseBrowserComponent +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.captureScreenRoboImage +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowDisplay +import kotlin.math.roundToInt + +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class VariousPreviewScreenshotTest( + private val testCase: TestCase +) { + @get:Rule + val composeTestRule = createAndroidComposeRule() + lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun testDefault() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png" + capturePreview(filePath = filePath) + } + + @Test + fun testFont2x() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}_font2x.png" + capturePreview(filePath = filePath, fontMagnification = 2f) + } + + @OptIn(ExperimentalRoborazziApi::class) + private fun capturePreview(filePath: String, fontMagnification: Float = 1f) { + setGroupParameterToQualifiers(group = testCase.showkaseBrowserComponent.group) + val widthDp = testCase.showkaseBrowserComponent.widthDp + val heightDp = testCase.showkaseBrowserComponent.heightDp + if (widthDp != null || heightDp != null) { + setDisplaySize(widthDp, heightDp) + } + // Activityを再生成して変更した画面サイズを反映させる + composeTestRule.activityRule.scenario.recreate() + + composeTestRule.setContent { + val density = LocalDensity.current + val customDensity = Density( + fontScale = density.fontScale * fontMagnification, + density = density.density, + ) + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalDensity provides customDensity, + ) { + testCase.showkaseBrowserComponent.component() + } + } + + kotlin.runCatching { + // 複数Windowがある場合、子コンポーネントがいる最初のものをとってくる + composeTestRule.onAllNodes(isRoot()) + .filter(hasAnyChild()) + .onFirst() + .assertExists() // 念のため存在していることをassertしている。assertに失敗したらnullを返し、captureScreenRoboImage()を使って全画面スクリーンショットを撮る + }.getOrNull()?.captureRoboImage(filePath) ?: captureScreenRoboImage(filePath) + } + + private fun setGroupParameterToQualifiers(group: String) { + if (group != "Default Group") { + val tags = testCase.showkaseBrowserComponent.group.split("-") + tags.forEach { tag -> + RuntimeEnvironment.setQualifiers("+$tag") + } + } + } + + private fun setDisplaySize(widthDp: Int?, heightDp: Int?) { + 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) + } + } + + fun hasAnyChild(): SemanticsMatcher { + return SemanticsMatcher("hasAnyChildThat") { + it.children.isNotEmpty() + } + } + + companion object { + class TestCase( + val showkaseBrowserComponent: ShowkaseBrowserComponent + ) { + override fun toString() = showkaseBrowserComponent.componentKey + } + + + @ParameterizedRobolectricTestRunner.Parameters(name = "[{index}] {0}") + @JvmStatic + fun components(): Iterable> = Showkase.getMetadata().componentList.map { + arrayOf(TestCase(it)) + } + } +} diff --git a/app/src/testExercise/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt b/app/src/testExercise/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt index bd23b2c..de92cd3 100644 --- a/app/src/testExercise/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt +++ b/app/src/testExercise/java/com/google/samples/apps/nowinandroid/AllPreviewScreenshotTest.kt @@ -1,3 +1,40 @@ package com.google.samples.apps.nowinandroid -class AllPreviewScreenshotTest() \ No newline at end of file +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import com.airbnb.android.showkase.models.Showkase +import com.airbnb.android.showkase.models.ShowkaseBrowserComponent +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class AllPreviewScreenshotTest( + private val showkaseBrowserComponent: ShowkaseBrowserComponent, +) { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun test() { + composeTestRule.setContent { + // showkaseBrowserComponent.component() がPreview指定されているComposable関数 + showkaseBrowserComponent.component() + } + // ここでスクリーンショットを取得する + composeTestRule.onRoot().captureRoboImage() + } + + companion object { + @ParameterizedRobolectricTestRunner.Parameters + @JvmStatic + fun components(): Iterable> = Showkase.getMetadata().componentList.map { arrayOf(it) } + } +} \ No newline at end of file diff --git a/app/src/testExercise/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt b/app/src/testExercise/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt new file mode 100644 index 0000000..9428de1 --- /dev/null +++ b/app/src/testExercise/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt @@ -0,0 +1,71 @@ +package com.google.samples.apps.nowinandroid + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onFirst +import com.airbnb.android.showkase.models.Showkase +import com.airbnb.android.showkase.models.ShowkaseBrowserComponent +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.captureScreenRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class VariousPreviewScreenshotTest( + private val testCase: TestCase +) { + @get:Rule + val composeTestRule = createComposeRule() + + @OptIn(ExperimentalRoborazziApi::class) + @Test + fun test() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png" + composeTestRule.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + testCase.showkaseBrowserComponent.component() + } + } + kotlin.runCatching { + // 複数Windowがある場合、子コンポーネントがいる最初のものをとってくる + composeTestRule.onAllNodes(isRoot()) + .filter(hasAnyChild()) + .onFirst() + .assertExists() // 念のため存在していることをassertしている。assertに失敗したらnullを返し、captureScreenRoboImage()を使って全画面スクリーンショットを撮る + }.getOrNull()?.captureRoboImage(filePath) ?: captureScreenRoboImage(filePath) + } + + fun hasAnyChild(): SemanticsMatcher { + return SemanticsMatcher("hasAnyChildThat") { + it.children.isNotEmpty() + } + } + + companion object { + class TestCase( + val showkaseBrowserComponent: ShowkaseBrowserComponent + ) { + override fun toString() = showkaseBrowserComponent.componentKey + } + + @ParameterizedRobolectricTestRunner.Parameters(name = "[{index}] {0}") + @JvmStatic + fun components(): Iterable> = Showkase.getMetadata().componentList.map { + arrayOf(TestCase(it)) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt index bb2b594..107633c 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt @@ -22,8 +22,15 @@ import androidx.compose.ui.tooling.preview.Preview * Multipreview annotation that represents various device sizes. Add this annotation to a composable * to render various devices. */ -@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") -@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") -@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") -@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") -annotation class DevicePreviews +//@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +//@Preview(group = "LandscapePreview", name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") +//@Preview(group = "FoldablePreview", name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +//@Preview(group = "TabletPreview", name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") +//annotation class DevicePreviews + +@Preview(name = "phone", group = "normal-xxhdpi", widthDp = 360, heightDp = 640) +@Preview(name = "landscape", group = "normal-xxhdpi", widthDp = 640, heightDp = 360) +@Preview(name = "foldable", group = "normal-xxhdpi", widthDp = 673, heightDp = 841) +@Preview(name = "tablet_ja", group = "ja-normal-xhdpi", widthDp = 1280, heightDp = 800) +@Preview(name = "tablet_ja_night", group = "ja-normal-night-xhdpi", widthDp = 1280, heightDp = 800) +annotation class DevicePreviews \ No newline at end of file diff --git a/docs/handson/UILayerTest.md b/docs/handson/UILayerTest.md index ef8e917..812897b 100644 --- a/docs/handson/UILayerTest.md +++ b/docs/handson/UILayerTest.md @@ -63,4 +63,4 @@ UIテストには、コード変更前後の画面スクリーンショットを - Jetpack Composeの画面スクリーンショットを使ってVisual Regression Testを実現する - [Composeのプレビュー画面でVisual Regression Testを行う](./VisualRegressionTest_Preview.md) - [Visual Regression TestをCIで実行する](./VisualRegressionTest_CI.md) - - プレビューでは確認できないComposeの画面のスクリーンショットを撮る + - [様々なケースでComposeの画面スクリーンショットを撮る](./VisualRegressionTest_Advanced.md) diff --git a/docs/handson/VisualRegressionTest_Advanced.md b/docs/handson/VisualRegressionTest_Advanced.md new file mode 100644 index 0000000..0df7d09 --- /dev/null +++ b/docs/handson/VisualRegressionTest_Advanced.md @@ -0,0 +1,566 @@ +# さまざまなケースでComposeの画面スクリーンショットを撮る + +ここまでで、Composeのプレビュー画面について、スクリーンショットを保存してVisual Regression Testを運用する基本的な方法を学んできた。 + +本節では、より応用的なトピックとして、次のようなケースでスクリーンショットを保存するテクニックを紹介する。 + +- 1つのプレビュー画面のスクリーンショットを、さまざまな環境の下で保存する + - [実現の仕組み](#mechanism) + - [スクリーンショット取得のための環境セットアップ](#environment-setup) + - [画面サイズでスクリーンショットを撮る](#screen-size) + - [さまざまなフォントスケールでスクリーンショットを撮る](#font-scale) + - [ナイトモード(ダークモード)でのスクリーンショットを撮る](#night-mode) +- [UIテストの中で画面のスクリーンショットを保存する](#ui-interaction) +- [Coilを使って画像を非同期のロードする画面のスクリーンショットを保存する](#coil) + +## 1つのプレビュー画面のスクリーンショットを、さまざまな環境の下で保存する + +### 実現の仕組み + +実現方法はユースケースによって2種類ある。 + +- 1つの`@Preview`アノテーションにつき、複数パターンのスクリーンショットを保存したい + - 例:すべてのプレビュー画面について、ダークモードのスクリーンショットとライトモードのスクリーンショットの両方を保存したい +- 特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存したい + - 例:`@Preview(widthDp = 800)` と指定されているときは、画面幅800dpのスクリーンショットを保存したい + +#### ユースケース1:1つの`@Preview`アノテーションにつき、複数パターンのスクリーンショットを保存する + +このユースケースは、「[Composeのプレビュー画面でVisual Regression Testを行う](VisualRegressionTest_Test.md)」で紹介した`ParameterizedRobolectricTestRunner`の仕組みを使うと自然に実現できる。 + +```kotlin +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class AllPreviewScreenshotTest( + private val testCase: TestCase +) { + + // あとで composeTestRule.activityRule が必要になるため createAndroidComposeRule を使う + @get:Rule + val composeTestRule = createAndroidComposeRule() + + ... + + @Test + fun testLightMode() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png" + // ライトモードでスクリーンショットを保存するように環境を設定する + // updateScreenshotEnvironmentの実装は後述 + updateScreenshotEnvironment("notnight") + + // 変更した環境を適用するためにActivityを再生成する + composeTestRule.activityRule.scenario.recreate() + + // スクリーンショットをとる + capturePreview(filePath) + } + + @Test + fun testDarkMode() { + // テストメソッドごとにスクリーンショットのファイル名が重複しないように末尾に`_night`を入れたファイル名にする + val filePath = "${testCase.showkaseBrowserComponent.componentKey}_night.png" + // ダークモードでスクリーンショットを保存するように環境を設定する + // updateScreenshotEnvironmentの実装は後述 + updateScreenshotEnvironment("night") + + // 変更した環境を適用するためにActivityを再生成する + composeTestRule.activityRule.scenario.recreate() + + // スクリーンショットをとる + capturePreview(filePath) + } + + private fun capturePreview(filePath: String) { + // 「Composeのプレビュー画面でVisual Regression Testを行う」で紹介したスクリーンショットを保存するコード + ... + } + + companion object { + class TestCase( + val showkaseBrowserComponent: ShowkaseBrowserComponent + ) { + override fun toString() = showkaseBrowserComponent.componentKey + } + + + @ParameterizedRobolectricTestRunner.Parameters(name = "[{index}] {0}") + @JvmStatic + fun components(): Iterable> = Showkase.getMetadata().componentList.map { + arrayOf(TestCase(it)) + } + } +} +``` + +Showkaseでは、次のコードで`@Preview`または`@ShowkaseComposable`が付いたComposable関数に関する情報(`ShowkaseBrowserComponent`)のリストが得られる。 +ひとつのアノテーションにつき、ひとつの要素(`ShowkaseBrowserComponent`インスタンス)が対応している。 + +```kotlin +val showkaseBrowserComponentList: List = Showkase.getMetadata().componentList +``` + +`ParameterizedRobolectricTestRunner`では、1つの`ShowkaseBrowserComponent`につき1回テストクラスが呼び出されるため、テストクラス内に複数のテストメソッドを定義すれば、それぞれのテストが呼び出されることになる。 +そのため、1つのプレビュー画面で撮りたい各バリエーションについて、それに対応するテストメソッドを定義していけばよい。 + +#### ユースケース2:特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存する + +`@Preview`または`@ShowkaseComposable`アノテーションのオプション引数に指定された情報は、前述の`ShowkaseBrowserComponent`クラスのプロパティから取得できる。 +`ShowkaseBrowserComponent`クラスには次のプロパティが定義されている。 + +```kotlin +data class ShowkaseBrowserComponent( + val componentKey: String, + val group: String, + val componentName: String, + val componentKDoc: String, + val component: @Composable () -> Unit, + val styleName: String? = null, + val isDefaultStyle: Boolean = false, + val widthDp: Int? = null, + val heightDp: Int? = null, + val tags: List = emptyList(), + val extraMetadata: List = emptyList() +) +``` + +これらのプロパティに格納されている情報を元に、テストコード側で条件分岐すれば、たとえば次のようなことが実現できる。 + +- `widthDp`と`heightDp`によって指定された画面サイズでスクリーンショットを撮る +- `group`に`"night"`という文字列が含まれるものについては、ナイトモード(ダークモード)でスクリーンショットを撮る + +ところが、`@Preview`アノテーションのパラメーターのうち、`ShowkaseBrowserComponent`のプロパティに反映されるのは次の4つしかない。 +そのため、この4つのパラメーターを使って「どのような環境(例:ダークモード)でスクリーンショットをとりたいのか」を表現しなければならない。 + +| `@Preview`アノテーションのパラメーター名 | 対応する`ShowkaseBrowserComponent`のプロパティ名 | +|----------------------------------------|--------------------------------------------------| +| `group` | `group` | +| `name` | `componentName` | +| `widthDp` | `widthDp` | +| `heightDp` | `heightDp` | + +たとえば、次のような2つの設定で、(Android Studio上に)プレビュー画面を表示しているケースを考える。 + +```kotlin +// 360x640の画面サイズ、ドイツ語、ナイトモードでプレビュー画面を表示する +@Preview(widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES) +// 1200x800の画面サイズ、日本語、ナイトモードでプレビュー画面を表示する +@Preview(widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun MyComposable() { ... } +``` + +同じ設定下で表示した画面をスクリーンショットテストでも保存したいものの、`locale`と`uiMode`に指定されている値は`ShowkaseBrowserComponent`に反映されない。 +そこで、`locale`と`uiMode`に指定されている値の情報を、(`ShowkaseBrowserComponent`に反映される)`group`パラメーターにも指定することを考える。 +それらの情報を`-`区切りでエンコードするのであれば、次のように`@Preview`アノテーションの宣言を書き換えるとよい。 + +```kotlin +@Preview(group = "de-night", widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(group = "ja-night", widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun MyComposable() { ... } +``` + +このようにすることで、Android Studioのプレビュー画面では `widthDp`・`heightDp`・`locale`・`uiMode`の情報を使って画面を表示し、 +スクリーンショットテストでは`widthDp`・`heightDp`・`group`の情報を使ってスクリーンショットを保存できるようになる。 + +何度も同じような宣言を書くのが大変な場合は、次のようにカスタムアノテーションを定義してもよい。 + +```kotlin +// MyCustomPreviewという名前のカスタムアノテーションを定義 +@Preview(group = "de-night", widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(group = "ja-night", widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class MyCustomPreviews + + +// カスタムアノテーションを、上記組み合わせでプレビューしたい関数に付与する +@MyCustomPreview +@Composable +fun MyComposable() { ... } +``` + +このユースケースのテストコードは次のようなイメージになる。 + +```kotlin +class AllPreviewScreenshotTest( + private val testCase: TestCase +) { + + // あとで composeTestRule.activityRule が必要になるため createAndroidComposeRule を使う + @get:Rule + val composeTestRule = createAndroidComposeRule() + lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + ... + + @Test + fun test() { + ... + val widthDp = testCase.showkaseBrowserComponent.widthDp + val heightDp = testCase.showkaseBrowserComponent.heightDp + // 画面サイズの変更 + if (widthDp != null || heightDp != null) { + // 画面サイズを変更する。setDisplaySizeの実装は後述。 + setDisplaySize(widthDpp, heightDp) + } + + // `group`が指定されていないときは"Default Group"という文字列になっている + // そのときは`group`のパースをスキップする + if (testCase.showkaseBrowser.group != "Default Group") { + // group に指定されたハイフン区切りの文字列をパースする + // たとえば + // group = "de-night" + // であれば + // tagsは ["de", "night"] となる + val tags = testCase.showkaseBrowser.group.split("-") + + // その他の情報の反映 + tags.forEach { tag -> + // tagの指定内容にあわせて環境を変更する。 + // updateScreenshotEnvironmentの実装は後述 + updateScreenshotEnvironment(tag) + } + } + // 変更した画面サイズや環境を適用するためにActivityを再生成する + composeTestRule.activityRule.scenario.recreate() + + // スクリーンショットを撮る + composeTestRule.setContent { + testCase.showkaseBrowserComponent.component() + } + composeTestRule.onRoot().captureRoboImage() + } +} +``` + +特に次のポイントに注意すること。 +- 画面サイズや環境を変更した後には`composeTestRule.activityRule.scenario.recreate()`を呼び出してActivityを再生成する必要がある +- `composeTestRule.activityRule`にアクセスするためには`createComposeRule()`ではなく`createAndroidComposeRule()`を使う必要がある + +なお、`group`パラメーターにスクリーンショット取得環境の指定をエンコードする方法は、厳密にいえば`group`パラメーターの用途外利用となる。 +とくに、ShowkaseブラウザでUIカタログを閲覧するときのグルーピングが意味をなさなくなってしまう点はデメリットかも知れない。 +もし、その点が許容できない場合は、`@Preview`アノテーションとあわせて(Showkaseからしか認識されない)`@ShowkaseComposable`アノテーションを併記する方法もある。 +詳細は割愛するが、その場合は画面サイズ以外の情報を`tags`パラメーターに指定するとよい。 + +### スクリーンショット取得のための環境セットアップ + +これまでに学んできた実現の仕組みに加えて、スクリーンショットを撮りたい環境別のセットアップ方法を学べば、特定の環境下でのスクリーンショット取得が実現できる。 +ここでは、次の3つのケースについてセットアップ方法を説明する。 + +- [特定の画面サイズでスクリーンショットを撮る](#screen-size) +- [特定のフォントスケールでスクリーンショットを撮る](#font-scale) +- [ナイトモード(ダークモード)でスクリーンショットを撮る](#night-mode) + +#### 特定の画面サイズでスクリーンショットを撮る + +スクリーンショットを撮りたい画面サイズ`widthDp`・`heightDp`が与えられたときに、画面サイズを変更する`setDisplaySize()`メソッドの実装を紹介する。 +「ユースケース2」で紹介したテストコードとあわせて使えば、`@Preview`アノテーションの`widthDp`と`heightDp`の指示どおりの画面サイズでスクリーンショットを取得できる。 + +Robolectricが提供している動的に画面サイズを変更する`ShadowDisplay` APIを使って実現できる。 + +```kotlin +private fun setDisplaySize(widthDp: Int?, heightDp: Int?) { + val display = ShadowDisplay.getDefaultDisplay() + // 「ユースケース2」のテストクラスで宣言されていたcontextプロパティを使っている + 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) + } +} +``` + +なお「ユースケース2」で紹介したテストコードでは、テストクラスの`@Config`アノテーションで、デバイスとして`RobolectricDeviceQualifiers.Pixel7`が指定されている。 +そのため、`widthDp`や`heightDp`が指定されていないときはPixel7の画面サイズでスクリーンショットが撮られることになる。 + +#### 特定のフォントスケールでスクリーンショットを撮る + +フォントスケールを変更した状態でスクリーンショットを撮るには、`CompositionLocalProvider`を使って`LocalDensity`を変更することで実現する。 +`CompositionLocalProvider`や`LocalDensity.current`はComposable関数であるため、`composeTestRule.setContent{ ... }`の中で宣言する必要がある。 + +次に、フォントスケールを2倍にする(本来のフォントの2倍の大きさでレンダリングする)例を示す。 + +```kotlin +composeTestRule.setContent { + val density = LocalDensity.current + val customDensity = Density(fontScale = density.fontScale * 2, density = density.density) + CompositionLocalProvider( + LocalDensity provides customDensity + ) { + testCase.showkaseBrowserComponent.component() + } +} +``` + +すべてのプレビュー画面について、通常のフォントサイズと、2倍のフォントサイズの2種類のスクリーンショットを撮りたいという「ユースケース1」のパターンでは、 +片方のテストメソッドについてだけ上記の設定を入れるとよい。 + +```kotlin +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class AllPreviewScreenshotTest( + private val testCase: TestCase +) { + + + @Test + fun defaultFontScaleTest() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png" + // デフォルトのフォントスケールでスクリーンショットをとる + takeScreenshot(filePath = filePath) + } + + @Test + fun doubledFontScaleTest() { + val filePath = "${testCase.showkaseBrowserComponent.componentKey}_font2x.png" + // 2倍のフォントスケールでスクリーンショットをとる + takeScreenshot(filePath = filePath, fontMagnification = 2.0f) + } + + private fun takeScreenshot(filePath: String, fontMagnification: Float = 1.0f) { + composeTestRule.setContent { + val density = LocalDensity.current + val customDensity = Density(fontScale = density.fontScale * fontMagnification, density = density.density) + CompositionLocalProvider( + // 以前に紹介したようにLocalInspectionModeをtrueにする + LocalInspectionMode provides true, + // フォントを拡大したcustomDensityでLocalDensityを上書きする + LocalDensity provides customDensity + ) { + testCase.showkaseBrowserComponent.component() + } + } + // captureRoboImage(filePath)でスクリーンショットを撮る + ... + } + + companion object { + ... + } +} +``` +#### ナイトモード(ダークモード)でスクリーンショットを撮る + +Robolectricでは、「[Specifying Device Configuration](https://robolectric.org/device-configuration/)」に列挙されている項目(qualifierと呼ぶ)であれば、 +`RuntimeEnvironment.setQualifiers()`を使うことで環境を一時的に変更できる。 +ナイトモードに関しては、デフォルトではライトモード(`notnight`)で、`night`を指定するとナイトモードとなる。 + +次のように、先頭に`+`を付けてqualifier(ここでは`notnight`や`night`)を指定すると、デフォルトで指定されているqualifiersに追加で環境を指定できる。 + +「デフォルトで指定されているqualifiers」というのは、テストクラスの`@Config`で指定されている`qualifiers`引数のことである。 +いままでのテストクラスでは`RobolectricDeviceQualifiers.Pixel7`を指定していたが、この具体的な値は次のように定義されている。 + +```kotlin +const val Pixel7 = "w411dp-h914dp-normal-long-notround-any-420dpi-keyshidden-nonav" +``` + +つまり +```kotlin +RuntimeEnvironment.setQualifiers("+night") +``` + +と指定することで、この`Pixel7`の設定に加えてナイトモードを指定したことになる。 + +前述の「ユースケース2」を想定して`@Preview`の`group`パラメーターに、ハイフン区切りのqualifierを指定することにすれば、 +「ユースケース2」で紹介したテストコード中の`updateScreenshotEnvironment`に関連する部分は次のようになる。 + +```kotlin +@Test +fun test() { + ... + // `group`が指定されていないときは"Default Group"という文字列になっている + // そのときは`group`のパースをスキップする + if (testCase.showkaseBrowser.group != "Default Group") { + // group に指定されたハイフン区切りの文字列をパースする + // たとえば + // group = "de-night" + // であれば + // tagsは ["de", "night"] となる + val tags = testCase.showkaseBrowser.group.split("-") + tags.forEach { tag -> + // tagの指定内容にあわせて環境を変更する。 + updateScreenshotEnvironment(tag) + } + } + ... +} + +fun updateScreenshotEnvironment(tag: String) { + RuntimeEnvironment.setQualifiers("+$tag") +} + +``` + +### ここまでのまとめ + +スクリーンショットを撮る2つのユースケース別に、テストの組み立て方を説明した。 +- 1つのアノテーション(1つの`ShowkaseBrowserComponent`)につき、複数パターンのスクリーンショットを保存する +- 特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存する + +続けて、スクリーンショットを撮りたい環境のセットアップ手段として使える3つの方法を具体例と共に説明した。 + +- RobolectricのShadow APIを使って画面サイズを変更する +- `CompositionLocalProvider`を使ってフォントスケールを変更する +- Robolectricの`RuntimeEnvironment.setQualifiers()`を使ってナイトモード(ダークモード)にする + +### 練習問題1〜3 + +- テストコード:[VariousPreviewScreenshotTest](../../app/src/testExercise/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt) (`demoExerciseDebug`バリアント) +- 解答例:[VariousPreviewScreenshotTest](../..//app/src/testAnswer/java/com/google/samples/apps/nowinandroid/VariousPreviewScreenshotTest.kt) (`demoAnswerDebug`バリアント) +- テストを実行したときのスクリーンショット格納先:[screenshots](../../screenshots)ディレクトリ + +前回作成した`AllPreviewScreenshotTest`を、`VariousPreviewScreenshotTest`というクラス名で`app/src/testExercise`配下にコピーしている。 +次の内容を実現できるように、そのテストコードを改造しよう。 + + +改造した結果は、プレビュー関数[InterestsScreenPopulated](../../feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt)の5枚のスクリーンショット(`screenshots/com.google.samples.apps.nowinandroid.feature.interests_InterestsScreenPopulated_*.png`)で判断できる。 + + +- 練習問題1:「ユースケース2」のテストコードを参考に、`@Preview`の`widthDp`と`heightDp`が指定されたときに画面サイズを変更してスクリーンショットを撮るようにしてみよう + - プレビュー関数`InterestsScreenPopulated`に付与されている[`@DevicePreviews`](../../core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt)アノテーション定義には`widthDp`や`heightDp`が指定されているにもかかわらず、改造前の状態では4枚とも同じ画面サイズのスクリーンショットになっている。 + テストコードを修正して、`widthDp`・`heightDp`の指示どおりの画面サイズでスクリーンショットを撮るようにしよう +- 練習問題2:いままで取得していた各スクリーンショットについて、フォントスケールを2倍にしたときのスクリーンショットも合わせて保存するようにしてみよう(「ユースケース1」のパターン) + - フォントスケール1倍となっている5枚のスクリーンショットに加えて、フォントスケール2倍のスクリーンショット5枚(合計10枚)を撮るようにしよう +- 練習問題3:練習問題1をさらに改良し、`@Preview`の`group`パラメーター(ハイフン区切り)に`night`が含まれていたらナイトモードでスクリーンショットを撮るようにしてみよう + - `@DevicePreviews`の定義内で`night`が含まれているものは1つだけである。 + その1つに対応するスクリーンショットをナイトモードのスクリーンショットにしよう + (ナイトモードのスクリーンショットは、練習問題2によってフォントスケール別に2枚保存されるはずである) + +## UIテストの中で画面のスクリーンショットを保存する + +Roborazziは、プレビュー画面以外であってもスクリーンショットを取得できる。 +たとえば、Jetpack Composeによって構築された画面のUIを`ComposeTestRule`で操作した結果をスクリーンショットとして保存できる(UI操作の方法は「[ViewModelを結合してComposeをテストする](./UIElementTest_ComposeWithViewModel.md)」参照)。 + + +```kotlin +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class UserInteractionTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun test() { + composeTestRule.setContent { + // テストしたいComposable関数 + MyComposable() + } + // OKボタンクリック + composeTestRule.onNodeWithText("OK").performClick() + // スクリーンショット取得 + composeTestRule.onRoot().captureRoboImage() + + } +} + +``` + +このコードでは、`MyComposable()`の画面を表示し、「OK」と書かれたコンポーネントをクリックした後の画面のスクリーンショットを保存している。 + + +### 練習問題4 + +- テストコード:[ForYouScreenVisualRegressionTest](../../feature/foryou/src/testExercise/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTest.kt) +- 解答例:[ForYouScreenVisualRegressionTestEx4](../../feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx4.kt) + + +現在のテストコードのままでテストを実行し、スクリーンショットが次のような結果になることを確認しよう。 + + + +次に、Content Descriptionに`Headlines`と書かれているコンポーネントをクリックしてから `captureRoboImage()` を呼ぶようにテストコードを書き換えよう。 +※ヒント:onNodeWithContentDescriptionメソッドとperformClickメソッドを利用する + + +最後に、再度テストを実行し、スクリーンショットが次のように変化することを確認しよう。 + + +## Coilを使って画像を非同期のロードする画面のスクリーンショットを保存する + +練習問題4で撮影した`ForYouScreen`は、本来であれば`Headlines`などの見出しの左側にアイコンが表示されるはずだが、スクリーンショットだとアイコンは表示されていない。 + +| 練習問題4で撮影した画像 | アプリとして動作させたときの画像 | +|----------------------------|----------------------------------------| +| | | + + +このアイコンはCoilを使ってネットワークから非同期にダウンロードしたものが表示されているが、 +RobolectricではCoilの非同期ダウンロードを正しく扱えないため、このようなスクリーンショットになってしまう。 + +この問題を解決するのが[`FakeImageLoaderEngine`](https://coil-kt.github.io/coil/testing/)である。 +`FakeImageLoaderEngine`はCoilから提供されており、次の依存関係を追加することで利用できるようになる。 + +``` +testImplementation("io.coil-kt:coil-test:2.6.0") +``` + +`FakeImageLoaderEngine`を使うと、画像をURLからダウンロードする代わりに、指定した画像(Androidの`Drawable`オブジェクト)を表示させることができる。 +代替画像は、URLごとに異なるものを指定したり、すべて同じ画像にしたりできる。 +Coilの公式ドキュメントに記載されている使用例は次のとおり(コメントは筆者追記)。 + +```kotlin +@Before +fun before() { + val engine = FakeImageLoaderEngine.Builder() + // 特定のURLと完全一致したときの代替画像を指定する + .intercept("https://www.example.com/image.jpg", ColorDrawable(Color.RED)) + + // URLが特定の条件を満たしたときの代替画像を指定する + .intercept({ it is String && it.endsWith("test.png") }, ColorDrawable(Color.GREEN)) + + // デフォルトの代替画像を指定する + .default(ColorDrawable(Color.BLUE)) + .build() + val imageLoader = ImageLoader.Builder(context) + .components { add(engine) } + .build() + // ここで作ったFake ImageLoaderに差し替える + Coil.setImageLoader(imageLoader) +} +``` + +なお、この方法でCoilのImageLoaderを差し替えたときは、`tearDown`メソッドで`Coil.reset()`を呼んで差し替えたImageLoaderを元に戻さなければならない点に注意すること。 + +```kotlin +@After +fun tearDown() { + Coil.reset() +} +``` + +### 練習問題5 + +- テストコード:練習問題4で作成した`ForYouScreenVisualRegressionTest` +- 解答例:[ForYouScreenVisualRegressionTestEx5](../../feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx5.kt) + +練習問題4で作成した`ForYouScreenVisualRegressionTest`について、次のようなルールで代替画像を設定し、 +アイコンが表示されるようにしてみよう。 + +※依存ライブラリ `io.coil-kt:coil-test:2.6.0` の追加は設定済み + +| URL | 代替画像 | +|-----|-----------| +| URLに`Android-Studio`が含まれているとき | `R.drawable.ic_topic_android_studio` | +| URLに`Compose`が含まれているとき | `R.drawable.ic_topic_compose` | +| URLに`Headlines`が含まれているとき | `R.drawable.ic_topic_headlines` | + +リソースID(`R.drawable.xxxx`)から`Drawable`オブジェクトを生成するには`context.getDrawable()`を使う。 + +```kotlin +val context = ApplicationProvider.getApplicationContext() +val drawable = context.getDrawable(R.drawable.xxxx) +``` + +うまくいけば、次のようなスクリーンショットが撮れるはずである。 + + diff --git a/docs/handson/images/vrt-ForYouScreen-product.png b/docs/handson/images/vrt-ForYouScreen-product.png new file mode 100644 index 0000000..d616a90 Binary files /dev/null and b/docs/handson/images/vrt-ForYouScreen-product.png differ diff --git a/docs/handson/images/vrt-ForYouScreen-result1.png b/docs/handson/images/vrt-ForYouScreen-result1.png new file mode 100644 index 0000000..4587d47 Binary files /dev/null and b/docs/handson/images/vrt-ForYouScreen-result1.png differ diff --git a/docs/handson/images/vrt-ForYouScreen-result2.png b/docs/handson/images/vrt-ForYouScreen-result2.png new file mode 100644 index 0000000..2a9ba98 Binary files /dev/null and b/docs/handson/images/vrt-ForYouScreen-result2.png differ diff --git a/docs/handson/images/vrt-ForYouScreen-result3.png b/docs/handson/images/vrt-ForYouScreen-result3.png new file mode 100644 index 0000000..c24157a Binary files /dev/null and b/docs/handson/images/vrt-ForYouScreen-result3.png differ diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index 0c01f0c..037dc66 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -37,6 +37,12 @@ android { } } unitTests { + all { + it.systemProperty( + "roborazzi.output.dir", + rootProject.file("screenshots").absolutePath + ) + } isIncludeAndroidResources = true } } @@ -57,4 +63,14 @@ dependencies { implementation(libs.showkase) ksp(libs.showkase.processor) + + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.robolectric) + + testImplementation(libs.coil.kt.test) } + +roborazzi { + outputDir.set(rootProject.file("screenshots")) +} \ No newline at end of file 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 d7065f6..ac65889 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.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -376,6 +377,7 @@ fun TopicIcon( imageUrl: String, modifier: Modifier = Modifier ) { + Log.d("ForYouScreen", "imageUrl = $imageUrl") AsyncImage( // TODO b/228077205, show loading image visual instead of static placeholder placeholder = painterResource(R.drawable.ic_icon_placeholder), diff --git a/feature/foryou/src/main/res/drawable/ic_topic_android_studio.xml b/feature/foryou/src/main/res/drawable/ic_topic_android_studio.xml new file mode 100644 index 0000000..6690d07 --- /dev/null +++ b/feature/foryou/src/main/res/drawable/ic_topic_android_studio.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/foryou/src/main/res/drawable/ic_topic_compose.xml b/feature/foryou/src/main/res/drawable/ic_topic_compose.xml new file mode 100644 index 0000000..062894f --- /dev/null +++ b/feature/foryou/src/main/res/drawable/ic_topic_compose.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + diff --git a/feature/foryou/src/main/res/drawable/ic_topic_headlines.xml b/feature/foryou/src/main/res/drawable/ic_topic_headlines.xml new file mode 100644 index 0000000..d64987b --- /dev/null +++ b/feature/foryou/src/main/res/drawable/ic_topic_headlines.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx4.kt b/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx4.kt new file mode 100644 index 0000000..8e8541e --- /dev/null +++ b/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx4.kt @@ -0,0 +1,93 @@ +package com.google.samples.apps.nowinandroid.feature.foryou + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData +import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor +import kotlinx.datetime.Instant +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@Suppress("NonAsciiCharacters", "TestFunctionName") +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class ForYouScreenVisualRegressionTestEx4 { + @get:Rule + val composeTestRule = createComposeRule() + + private val syncStatusMonitor = TestSyncStatusMonitor() + private val userDataRepository = TestUserDataRepository() + private val topicsRepository = TestTopicsRepository() + private val newsRepository = TestNewsRepository() + private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + newsRepository = newsRepository, + userDataRepository = userDataRepository + ) + private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( + topicsRepository = topicsRepository, + userDataRepository = userDataRepository + ) + private lateinit var viewModel: ForYouViewModel + + @Before + fun setup() { + userDataRepository.setUserData(emptyUserData) + topicsRepository.sendTopics(testTopics) + newsRepository.sendNewsResources(testNewsResource) + viewModel = ForYouViewModel( + syncStatusMonitor = syncStatusMonitor, + userDataRepository = userDataRepository, + getSaveableNewsResources = getUserNewsResourcesUseCase, + getFollowableTopics = getFollowableTopicsUseCase + ) + } + + @Test + fun Headlinesと書かれたトピックを選択するとDoneボタンがenabled状態になること() { + composeTestRule.setContent { + ForYouRoute( + viewModel = viewModel + ) + } + // Content Descriptionに `Headlines` と書かれているコンポーネントをクリックする + composeTestRule.onNodeWithContentDescription("Headlines").performClick() + composeTestRule.onRoot().captureRoboImage() + } +} + +private val testTopics = listOf( + Topic("1", "Headlines", "", "", "", "https://example.com/img/Headlines.svg"), + Topic("2", "Android Studio", "", "", "", "https://example.com/img/Android-Studio.svg"), + Topic("3", "Compose", "", "", "", "https://example.com/img/Compose.svg"), +) + +private val testNewsResource = listOf( + NewsResource( + "100", + "Pixel Watch", + "", + "", + null, + Instant.parse("2021-11-08T00:00:00.000Z"), + NewsResourceType.Article, + topics = listOf(Topic("1", "Headlines", "", "", "", "")) + ) +) \ No newline at end of file diff --git a/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx5.kt b/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx5.kt new file mode 100644 index 0000000..4a0fc20 --- /dev/null +++ b/feature/foryou/src/testAnswer/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTestEx5.kt @@ -0,0 +1,128 @@ +package com.google.samples.apps.nowinandroid.feature.foryou + +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.Coil +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.test.FakeImageLoaderEngine +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData +import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor +import kotlinx.datetime.Instant +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@Suppress("NonAsciiCharacters", "TestFunctionName") +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class ForYouScreenVisualRegressionTestEx5 { + + @get:Rule + val composeTestRule = createComposeRule() + + private val syncStatusMonitor = TestSyncStatusMonitor() + private val userDataRepository = TestUserDataRepository() + private val topicsRepository = TestTopicsRepository() + private val newsRepository = TestNewsRepository() + private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + newsRepository = newsRepository, + userDataRepository = userDataRepository + ) + private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( + topicsRepository = topicsRepository, + userDataRepository = userDataRepository + ) + private lateinit var viewModel: ForYouViewModel + + @OptIn(ExperimentalCoilApi::class) + @Before + fun setup() { + userDataRepository.setUserData(emptyUserData) + topicsRepository.sendTopics(testTopics) + newsRepository.sendNewsResources(testNewsResource) + viewModel = ForYouViewModel( + syncStatusMonitor = syncStatusMonitor, + userDataRepository = userDataRepository, + getSaveableNewsResources = getUserNewsResourcesUseCase, + getFollowableTopics = getFollowableTopicsUseCase + ) + // FakeImageLoaderEngineを使って、代替画像を設定する + val context = ApplicationProvider.getApplicationContext() + val engine = FakeImageLoaderEngine.Builder() + .intercept( + predicate = { it is String && it.contains("Android-Studio") }, + drawable = context.getDrawable(R.drawable.ic_topic_android_studio)!! + ) + .intercept( + predicate = { it is String && it.contains("Compose") }, + drawable = context.getDrawable(R.drawable.ic_topic_compose)!! + ) + .intercept( + predicate = { it is String && it.contains("Headlines") }, + drawable = context.getDrawable(R.drawable.ic_topic_headlines)!! + ) + .build() + val imageLoader = ImageLoader.Builder(context) + .components { add(engine) } + .build() + Coil.setImageLoader(imageLoader) + } + + // tearDownメソッドを宣言し、その内部でCoil.reset()を呼ぶ + @After + fun tearDown() { + Coil.reset() + } + + @Test + fun Headlinesと書かれたトピックを選択するとDoneボタンがenabled状態になること() { + composeTestRule.setContent { + ForYouRoute( + viewModel = viewModel + ) + } + // Content Descriptionに `Headlines` と書かれているコンポーネントをクリックする + composeTestRule.onNodeWithContentDescription("Headlines").performClick() + composeTestRule.onRoot().captureRoboImage() + } +} + +private val testTopics = listOf( + Topic("1", "Headlines", "", "", "", "https://example.com/img/Headlines.svg"), + Topic("2", "Android Studio", "", "", "", "https://example.com/img/Android-Studio.svg"), + Topic("3", "Compose", "", "", "", "https://example.com/img/Compose.svg"), +) + +private val testNewsResource = listOf( + NewsResource( + "100", + "Pixel Watch", + "", + "", + null, + Instant.parse("2021-11-08T00:00:00.000Z"), + NewsResourceType.Article, + topics = listOf(Topic("1", "Headlines", "", "", "", "")) + ) +) \ No newline at end of file diff --git a/feature/foryou/src/testExercise/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTest.kt b/feature/foryou/src/testExercise/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTest.kt new file mode 100644 index 0000000..3537b2b --- /dev/null +++ b/feature/foryou/src/testExercise/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenVisualRegressionTest.kt @@ -0,0 +1,108 @@ +package com.google.samples.apps.nowinandroid.feature.foryou + +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.Coil +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.test.FakeImageLoaderEngine +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData +import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor +import kotlinx.datetime.Instant +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@Suppress("NonAsciiCharacters", "TestFunctionName") +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) +class ForYouScreenVisualRegressionTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val syncStatusMonitor = TestSyncStatusMonitor() + private val userDataRepository = TestUserDataRepository() + private val topicsRepository = TestTopicsRepository() + private val newsRepository = TestNewsRepository() + private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + newsRepository = newsRepository, + userDataRepository = userDataRepository + ) + private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( + topicsRepository = topicsRepository, + userDataRepository = userDataRepository + ) + private lateinit var viewModel: ForYouViewModel + + @Before + fun setup() { + userDataRepository.setUserData(emptyUserData) + topicsRepository.sendTopics(testTopics) + newsRepository.sendNewsResources(testNewsResource) + viewModel = ForYouViewModel( + syncStatusMonitor = syncStatusMonitor, + userDataRepository = userDataRepository, + getSaveableNewsResources = getUserNewsResourcesUseCase, + getFollowableTopics = getFollowableTopicsUseCase + ) + // TODO: 練習問題5 + // FakeImageLoaderEngineを使って、代替画像を設定する + } + + // TODO: 練習問題5 + // tearDownメソッドを宣言し、その内部でCoil.reset()を呼ぶ + + @Test + fun Headlinesと書かれたトピックを選択するとDoneボタンがenabled状態になること() { + composeTestRule.setContent { + ForYouRoute( + viewModel = viewModel + ) + } + // TODO: 練習問題4 + // Content Descriptionに `Headlines` と書かれているコンポーネントをクリックする + // ヒント: onNodeWithContentDescriptionメソッドとperformClickメソッドを使う + + composeTestRule.onRoot().captureRoboImage() + } +} + +private val testTopics = listOf( + Topic("1", "Headlines", "", "", "", "https://example.com/img/Headlines.svg"), + Topic("2", "Android Studio", "", "", "", "https://example.com/img/Android-Studio.svg"), + Topic("3", "Compose", "", "", "", "https://example.com/img/Compose.svg"), +) + +private val testNewsResource = listOf( + NewsResource( + "100", + "Pixel Watch", + "", + "", + null, + Instant.parse("2021-11-08T00:00:00.000Z"), + NewsResourceType.Article, + topics = listOf(Topic("1", "Headlines", "", "", "", "")) + ) +) \ No newline at end of file diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index 477093d..70be404 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.ksp) } android { namespace = "com.google.samples.apps.nowinandroid.feature.interests" @@ -35,3 +35,12 @@ android { } } } + +ksp { + arg("skipPrivatePreviews", "true") +} + +dependencies { + implementation(libs.showkase) + ksp(libs.showkase.processor) +} diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index 1dbb821..39c00f0 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -16,12 +16,14 @@ package com.google.samples.apps.nowinandroid.feature.interests +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,7 +31,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackg import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel 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.model.data.previewTopics +import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews @Composable @@ -85,11 +87,12 @@ private fun InterestsEmptyScreen() { @DevicePreviews @Composable fun InterestsScreenPopulated() { + val context = LocalContext.current NiaTheme { NiaBackground { InterestsScreen( uiState = InterestsUiState.Interests( - topics = previewTopics.map { FollowableTopic(it, false) } + topics = getPreviewTopics(context).map { FollowableTopic(it, false) } ), followTopic = { _, _ -> }, navigateToTopic = {}, @@ -100,7 +103,7 @@ fun InterestsScreenPopulated() { @DevicePreviews @Composable -fun InterestsScreenLoading() { +private fun InterestsScreenLoading() { NiaTheme { NiaBackground { InterestsScreen( @@ -114,7 +117,7 @@ fun InterestsScreenLoading() { @DevicePreviews @Composable -fun InterestsScreenEmpty() { +private fun InterestsScreenEmpty() { NiaTheme { NiaBackground { InterestsScreen( @@ -125,3 +128,32 @@ fun InterestsScreenEmpty() { } } } + +fun getPreviewTopics(context: Context): List { + return listOf( + Topic( + id = "2", + name = context.getString(R.string.headlines), + shortDescription = context.getString(R.string.headlines_short_description), + longDescription = context.getString(R.string.headlines_long_description), + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", + url = "" + ), + Topic( + id = "3", + name = context.getString(R.string.ui), + shortDescription = context.getString(R.string.ui_short_description), + longDescription = context.getString(R.string.ui_long_description), + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", + url = "" + ), + Topic( + id = "4", + name = context.getString(R.string.testing), + shortDescription = context.getString(R.string.testing_short_description), + longDescription = context.getString(R.string.testing_long_description), + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", + url = "" + ), + ) +} \ No newline at end of file diff --git a/feature/interests/src/main/res/values-ja/strings.xml b/feature/interests/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..2c53b95 --- /dev/null +++ b/feature/interests/src/main/res/values-ja/strings.xml @@ -0,0 +1,14 @@ + + + ニュース + 皆に見てもらいたいニュース + アンドロイドの最新イベントや発表情報をお届けします + + ユーザーインターフェース + マテリアルデザイン、ナビゲーション、テキスト、ページング、アクセシビリティ(a11y)、国際化(i18n)、ローカライゼーション(l10n)、アニメーション、大画面、ウィジェット + アプリのユーザー・インターフェース(ユーザーが見て対話できるすべて)を最適化する方法を学びましょう。マテリアルデザイン、ナビゲーション、テキスト、ページング、コンポーズ、アクセシビリティ(a11y)、国際化(i18n)、ローカライゼーション(l10n)、アニメーション、大画面、ウィジェットなど、さまざまなトピックについて最新情報を入手できます! + + テスト + CI、Espresso、TestLabなど + テストは、アプリ開発プロセスに不可欠な要素です。アプリに対して一貫してテストを実行することで、公開する前にアプリの正しさ、機能的な動作、ユーザビリティを検証することができます。CI、Espresso、Firebase TestLab の最新のトリックをご覧ください。 + \ No newline at end of file diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 5b9ab83..6e3cde9 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -15,12 +15,24 @@ limitations under the License. --> - Interests - Loading data - "No available data" - Follow interest button - Unfollow interest button - Interests - Menu - Search + Interests + Loading data + "No available data" + Follow interest button + Unfollow interest button + Interests + Menu + Search + + Headlines + News we want everyone to see + Stay up to date with the latest events and announcements from Android! + + UI + Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets + Learn how to optimize your app\'s user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more! + + Testing + CI, Espresso, TestLab, etc + Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app\'s correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b680a4..a66b9af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidxTracing = "1.1.0" androidxUiAutomator = "2.2.0" androidxWindowManager = "1.0.0" androidxWork = "2.7.1" -coil = "2.2.2" +coil = "2.6.0" hilt = "2.48.1" hiltExt = "1.0.0" jacoco = "0.8.7" @@ -101,6 +101,7 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } +coil-kt-test = { group = "io.coil-kt", name = "coil-test", version.ref = "coil" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }