diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 04139b015..e9eee3f26 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -13,7 +13,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - name: Checkout @@ -43,9 +43,6 @@ jobs: - name: Build all build type and flavor permutations run: ./gradlew assemble - - name: Run local tests - run: ./gradlew testDemoDebug testProdDebug - - name: Upload build outputs (APKs) uses: actions/upload-artifact@v3 with: @@ -59,6 +56,65 @@ jobs: name: lint-reports path: '**/build/reports/lint-results-*.html' + test: + runs-on: ubuntu-latest + + permissions: + contents: write + + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Run all local screenshot tests (Roborazzi) + id: screenshotsverify + continue-on-error: true + run: ./gradlew verifyRoborazziDemoDebug + + - name: Prevent pushing new screenshots if this is a fork + id: checkfork + continue-on-error: false + if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::Screenshot tests failed, please create a PR in your fork first." && exit 1 + + # Runs if previous job failed + - name: Generate new screenshots if verification failed and it's a PR + id: screenshotsrecord + if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request' + run: | + ./gradlew recordRoborazziDemoDebug + + - name: Push new screenshots if available + uses: stefanzweifel/git-auto-commit-action@v4 + if: steps.screenshotsrecord.outcome == 'success' + with: + file_pattern: '*/*.png' + disable_globbing: true + commit_message: "🤖 Updates screenshots" + + # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. + - name: Run local tests + if: always() + run: ./gradlew testDemoDebug testProdDebug + - name: Upload test results (XML) if: always() uses: actions/upload-artifact@v3 @@ -77,7 +133,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - + - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties @@ -113,7 +169,7 @@ jobs: androidTest-GMD: needs: build runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 55 + timeout-minutes: 90 steps: - name: Checkout diff --git a/README.md b/README.md index 9ac61c0af..b71427dfe 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,13 @@ Examples: manipulate the state of the `Test` repository and verify the resulting behavior, instead of checking that specific repository methods were called. +## Screenshot tests + +**Now In Android** uses [Roborazzi](https://github.com/takahirom/roborazzi) to do screenshot tests +of certain screens and components. To run these tests, run the `verifyRoborazziDemoDebug` or +`recordRoborazziDemoDebug` tasks. Note that screenshots are recorded on CI, using Linux, and other +platforms might generate slightly different images, making the tests fail. + # UI The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and obtain the design files in the [Now in Android Material 3 Case Study](https://goo.gle/nia-figma) (design assets [also available as a PDF](docs/Now-In-Android-Design-File.pdf)). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bca633d5d..81947e641 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,4 +120,16 @@ dependencies { implementation(libs.androidx.profileinstaller) implementation(libs.kotlinx.coroutines.guava) implementation(libs.coil.kt) + + // Core functions + testImplementation(project(":core:testing")) + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:data-test")) + testImplementation(project(":core:network")) + testImplementation(libs.androidx.navigation.testing) + testImplementation(libs.accompanist.testharness) + testImplementation(kotlin("test")) + implementation(libs.work.testing) + kaptTest(libs.hilt.compiler) + } diff --git a/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt new file mode 100644 index 000000000..94563abe4 --- /dev/null +++ b/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.accompanist.testharness.TestHarness +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone +import javax.inject.Inject + +/** + * Tests that the navigation UI is rendered correctly on different screen sizes. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// Configure Robolectric to use a very large screen size that can fit all of the test sizes. +// This allows enough room to render the content under test without clipping or scaling. +@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +@HiltAndroidTest +class NiaAppScreenSizesScreenshotTests { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var userDataRepository: UserDataRepository + + @Inject + lateinit var topicsRepository: TopicsRepository + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + + @Before + fun setup() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager( + InstrumentationRegistry.getInstrumentation().context, + config, + ) + + hiltRule.inject() + + // Configure user data + runBlocking { + userDataRepository.setShouldHideOnboarding(true) + + userDataRepository.setFollowedTopicIds( + setOf(topicsRepository.getTopics().first().first().id), + ) + } + } + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun testNiaAppScreenshotWithSize(width: Dp, height: Dp, screenshotName: String) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(size = DpSize(width, height)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + } + + composeTestRule.onRoot() + .captureRoboImage( + "src/testDemo/screenshots/$screenshotName.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } + + @Test + fun compactWidth_compactHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 610.dp, + 400.dp, + "compactWidth_compactHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_compactHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 400.dp, + "mediumWidth_compactHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_compactHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 400.dp, + "expandedWidth_compactHeight_showsNavigationRail", + ) + } + + @Test + fun compactWidth_mediumHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 400.dp, + 500.dp, + "compactWidth_mediumHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_mediumHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 500.dp, + "mediumWidth_mediumHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_mediumHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 500.dp, + "expandedWidth_mediumHeight_showsNavigationRail", + ) + } + + @Test + fun compactWidth_expandedHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 400.dp, + 1000.dp, + "compactWidth_expandedHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_expandedHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 1000.dp, + "mediumWidth_expandedHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_expandedHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 1000.dp, + "expandedWidth_expandedHeight_showsNavigationRail", + ) + } +} diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png new file mode 100644 index 000000000..56b49457c Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png new file mode 100644 index 000000000..035cc24cf Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png new file mode 100644 index 000000000..7749199c5 Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png new file mode 100644 index 000000000..fe5b045aa Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png new file mode 100644 index 000000000..25283c111 Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png new file mode 100644 index 000000000..58d620f21 Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png new file mode 100644 index 000000000..56b49457c Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png new file mode 100644 index 000000000..15ddccf78 Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png new file mode 100644 index 000000000..d2e4bb8bc Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png differ diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index cf90b17af..bb79715e4 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -24,9 +24,12 @@ class AndroidApplicationComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("com.android.application") + // Screenshot Tests + pluginManager.apply("io.github.takahirom.roborazzi") + val extension = extensions.getByType() configureAndroidCompose(extension) } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index b98673619..a0e81a27c 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -33,9 +33,10 @@ class AndroidHiltConventionPlugin : Plugin { "implementation"(libs.findLibrary("hilt.android").get()) "kapt"(libs.findLibrary("hilt.compiler").get()) "kaptAndroidTest"(libs.findLibrary("hilt.compiler").get()) + "kaptTest"(libs.findLibrary("hilt.compiler").get()) } } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index ee6192e05..707ca8055 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -24,9 +24,12 @@ class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("com.android.library") + // Screenshot Tests + pluginManager.apply("io.github.takahirom.roborazzi") + val extension = extensions.getByType() configureAndroidCompose(extension) } } -} \ 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 7696c6b53..186f0b3d3 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 @@ -41,6 +41,18 @@ internal fun Project.configureAndroidCompose( val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) + // Add ComponentActivity to debug manfest + add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get()) + // Screenshot Tests on JVM + add("testImplementation", libs.findLibrary("robolectric").get()) + add("testImplementation", libs.findLibrary("roborazzi").get()) + } + + testOptions { + unitTests { + // For Robolectric + isIncludeAndroidResources = true + } } } diff --git a/build.gradle.kts b/build.gradle.kts index 1054e6be2..1efa3f8be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,5 +39,6 @@ plugins { alias(libs.plugins.gms) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.roborazzi) apply false alias(libs.plugins.secrets) apply false -} \ No newline at end of file +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index bae91fdb9..6cba0086d 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -24,6 +24,8 @@ android { } dependencies { + api(libs.accompanist.testharness) + api(libs.androidx.activity.compose) api(libs.androidx.compose.ui.test) api(libs.androidx.test.core) api(libs.androidx.test.espresso.core) @@ -32,6 +34,8 @@ dependencies { api(libs.hilt.android.testing) api(libs.junit4) api(libs.kotlinx.coroutines.test) + api(libs.roborazzi) + api(libs.robolectric.shadows) api(libs.turbine) debugApi(libs.androidx.compose.ui.testManifest) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt new file mode 100644 index 000000000..e0756d16d --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.util + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziOptions.CompareOptions +import com.github.takahirom.roborazzi.RoborazziOptions.RecordOptions +import com.github.takahirom.roborazzi.captureRoboImage +import org.robolectric.RuntimeEnvironment + +val DefaultRoborazziOptions = + RoborazziOptions( + compareOptions = CompareOptions(changeThreshold = 0f), // Pixel-perfect matching + recordOptions = RecordOptions(resizeScale = 0.5), // Reduce the size of the PNGs + ) + +fun AndroidComposeTestRule, A>.captureMultiDevice( + screenshotName: String, + body: @Composable () -> Unit, +) { + listOf( + "phone" to "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480", + "foldable" to "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480", + "tablet" to "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480", + ).forEach { + this.captureForDevice(it.first, it.second, screenshotName, body) + } +} + +fun AndroidComposeTestRule, A>.captureForDevice( + deviceName: String, + deviceSpec: String, + screenshotName: String, + body: @Composable () -> Unit, + roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, +) { + val (width, height, dpi) = extractSpecs(deviceSpec) + + // Set qualifiers from specs + RuntimeEnvironment.setQualifiers("w${width}dp-h${height}dp-${dpi}dpi") + + this.activity.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + body() + } + } + this.onRoot() + .captureRoboImage( + "src/test/screenshots/${screenshotName}_$deviceName.png", + roborazziOptions = roborazziOptions, + ) +} + +/** + * Extracts some properties from the spec string. Note that this function is not exhaustive. + */ +private fun extractSpecs(deviceSpec: String): TestDeviceSpecs { + val specs = deviceSpec.substringAfter("spec:") + .split(",").map { it.split("=") }.associate { it[0] to it[1] } + val width = specs["width"]?.toInt() ?: 640 + val height = specs["height"]?.toInt() ?: 480 + val dpi = specs["dpi"]?.toInt() ?: 480 + return TestDeviceSpecs(width, height, dpi) +} + +data class TestDeviceSpecs(val width: Int, val height: Int, val dpi: Int) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index b7280e757..044abedaf 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:domain")) implementation(project(":core:model")) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) implementation(libs.coil.kt) @@ -51,4 +52,4 @@ dependencies { implementation(libs.kotlinx.datetime) androidTestImplementation(project(":core:testing")) -} \ No newline at end of file +} diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt new file mode 100644 index 000000000..410619638 --- /dev/null +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.foryou + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.testing.util.captureMultiDevice +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success +import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider +import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Loading +import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone + +/** + * Screenshot tests for the [ForYouScreen]. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(application = HiltTestApplication::class, sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +class ForYouScreenScreenshotTests { + + /** + * Use a test activity to set the content on. + */ + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val userNewsResources = UserNewsResourcePreviewParameterProvider().values.first() + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun testForYouScreenPopulatedFeed() { + composeTestRule.captureMultiDevice("ForYouScreenPopulatedFeed") { + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = NotShown, + feedState = Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Test + fun testForYouScreenLoading() { + composeTestRule.captureMultiDevice("ForYouScreenLoading") { + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = Loading, + feedState = NewsFeedUiState.Loading, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Test + fun testForYouScreenTopicSelection() { + composeTestRule.captureMultiDevice("ForYouScreenTopicSelection") { + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = OnboardingUiState.Shown( + topics = userNewsResources.flatMap { news -> news.followableTopics } + .distinctBy { it.topic.id }, + ), + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Test + fun testForYouScreenPopulatedAndLoading() { + composeTestRule.captureMultiDevice("ForYouScreenPopulatedAndLoading") { + NiaTheme { + ForYouScreen( + isSyncing = true, + onboardingUiState = OnboardingUiState.Loading, + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } +} diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png new file mode 100644 index 000000000..92d2978e0 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png new file mode 100644 index 000000000..0e6aedd53 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png new file mode 100644 index 000000000..88b6ce240 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png new file mode 100644 index 000000000..aa2c12203 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png new file mode 100644 index 000000000..de141fa02 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png new file mode 100644 index 000000000..3fe5194d8 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png new file mode 100644 index 000000000..54ae3be02 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png new file mode 100644 index 000000000..0c36a8913 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png new file mode 100644 index 000000000..021958401 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png new file mode 100644 index 000000000..95333c1d1 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png new file mode 100644 index 000000000..ab86a2301 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png new file mode 100644 index 000000000..292fc22f6 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56e658285..1fc850d0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,8 @@ protobuf = "3.24.0" protobufPlugin = "0.9.4" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" +robolectric = "4.10.3" +roborazzi = "1.5.0-alpha-2" room = "2.5.2" secrets = "2.0.1" turbine = "0.12.1" @@ -125,6 +127,9 @@ protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin- protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +robolectric-shadows = { group = "org.robolectric", name = "shadows-framework", version.ref = "robolectric" } +roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } @@ -136,6 +141,7 @@ firebase-crashlytics-gradlePlugin = { group = "com.google.firebase", name = "fir firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +work-testing = { group = "androidx.work", name = "work-testing", version = "2.8.1" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -149,4 +155,5 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }