Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Screenshot testing with Roborazzi #876

Merged
merged 25 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
679e326
Adds screenshot tests using Roborazzi (Robolectric Native Graphics)
JoseAlcerreca Jul 31, 2023
461f32a
CI and spotless
JoseAlcerreca Jul 31, 2023
773e2bd
Moves :app tests to testDemo and makes NiaAppScreenSizesScreenshotTes…
JoseAlcerreca Aug 1, 2023
9e6ddf8
CI: Moves local tests to their own step
JoseAlcerreca Aug 1, 2023
acb576e
CI: Adds --rerun to screenshot task
JoseAlcerreca Aug 1, 2023
5c9b27a
CI: Moves screenshots before local tests
JoseAlcerreca Aug 1, 2023
25c9977
CI: Fixes wrong if statement in workflow
JoseAlcerreca Aug 1, 2023
39e9e67
CI WIP: trying to trigger the push step
JoseAlcerreca Aug 1, 2023
2065bbd
CI: Re-enables roborazzi verification
JoseAlcerreca Aug 1, 2023
77cae41
Fixes flaky screenshot tests by setting LocalInspectionMode on
JoseAlcerreca Aug 1, 2023
dfd869e
CI: screenshot commits now use the original author intead of bot account
JoseAlcerreca Aug 2, 2023
643dd0c
CI: Disables globbing because file_pattern didn't work
JoseAlcerreca Aug 2, 2023
6603f51
CI: Trying new file pattern for png files
JoseAlcerreca Aug 2, 2023
1090f39
CI: Adds a check for forks
JoseAlcerreca Aug 2, 2023
851c55f
🤖 Updates screenshots
JoseAlcerreca Aug 2, 2023
bb9addf
Code review: toml cleanup, comments
JoseAlcerreca Aug 4, 2023
a24bedc
Use new github.event.pull_request.head.repo.fork
JoseAlcerreca Aug 17, 2023
67c0793
Uses Robolectric qualifiers to set the dpi, adds section to README
JoseAlcerreca Aug 18, 2023
5ae4952
Spotless
JoseAlcerreca Aug 18, 2023
0130553
Delegates creation of repository to Hilt in test
JoseAlcerreca Aug 18, 2023
fa5d7c6
Revert "Use new github.event.pull_request.head.repo.fork"
JoseAlcerreca Aug 18, 2023
573b827
🤖 Updates screenshots
JoseAlcerreca Aug 18, 2023
baf4623
Empty commit to trigger GHA on main branch
JoseAlcerreca Aug 18, 2023
5fbc43a
Makes time zones deterministic in screenshot tests
JoseAlcerreca Aug 21, 2023
bc25b6f
Increases GMD timeout to 90m, but it has to be reduced
JoseAlcerreca Aug 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions .github/workflows/Build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 90
JoseAlcerreca marked this conversation as resolved.
Show resolved Hide resolved

steps:
- name: Checkout
Expand Down Expand Up @@ -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:
Expand All @@ -59,6 +56,65 @@ jobs:
name: lint-reports
path: '**/build/reports/lint-results-*.html'

test:
JoseAlcerreca marked this conversation as resolved.
Show resolved Hide resolved
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
JoseAlcerreca marked this conversation as resolved.
Show resolved Hide resolved
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'
JoseAlcerreca marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
12 changes: 12 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
Original file line number Diff line number Diff line change
@@ -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<HiltComponentActivity>()

@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",
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")

val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
}
}

}
}
Loading