Skip to content

Commit 847bc7e

Browse files
Setup UI snapshot tests for CMP features (#404)
* Setup UI snapshot tests for CMP features * Add ci job for paparazzi verify * Add Paparazzi snapshot testing documentation to README (#405) * Initial plan * Add paparazzi snapshot testing documentation to README Co-authored-by: newmskywalker <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: newmskywalker <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 70a73e6 commit 847bc7e

18 files changed

+179
-4
lines changed

.github/workflows/pr-check.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ jobs:
6868
- name: Run Lint
6969
run: ./gradlew lintDebug
7070

71+
paparazzi-verify:
72+
runs-on: ubuntu-latest
73+
steps:
74+
- uses: actions/checkout@v4
75+
with:
76+
lfs: true
77+
- name: Set up JDK 17
78+
uses: actions/setup-java@v4
79+
with:
80+
java-version: '17'
81+
distribution: 'temurin'
82+
- name: Setup Gradle
83+
uses: gradle/actions/setup-gradle@v4
84+
- name: Accept Android SDK licenses
85+
run: yes | sdkmanager --licenses || true
86+
- name: Grant execute permission for gradlew
87+
run: chmod +x gradlew
88+
- name: Grant execute permission for lfs_check.sh
89+
run: chmod +x .github/scripts/lfs_check.sh
90+
- name: Run LFS Check
91+
run: ./.github/scripts/lfs_check.sh
92+
- name: Verify Paparazzi Snapshots
93+
run: ./gradlew verifyPaparazziDebug
94+
7195
unit-tests:
7296
runs-on: ubuntu-latest
7397
environment: staging

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,35 @@ To ensure code is formatted before every commit, we use a pre-commit hook. You c
7070
```
7171
This hook will automatically run `spotlessApply` and stage any formatting changes whenever you commit.
7272

73+
#### Git LFS
74+
We use **Git LFS** to manage snapshot images for Paparazzi testing. This ensures that the repository size remains small and binary files are handled efficiently.
75+
76+
To set up Git LFS:
77+
1. Install Git LFS: `brew install git-lfs` (on macOS) or follow [installation instructions](https://git-lfs.github.com/).
78+
2. Initialize Git LFS in the repository: `git lfs install`
79+
3. Pull the LFS assets: `git lfs pull`
80+
81+
#### UI Snapshot Testing
82+
We use **Paparazzi** for UI snapshot testing to catch unintended visual changes in our Compose UI components. Snapshot tests render UI components and compare them against baseline images.
83+
84+
**Recording Snapshots (Creating Baselines):**
85+
```bash
86+
./gradlew recordPaparazziDebug
87+
```
88+
This command generates baseline snapshot images for all your snapshot tests. Run this when:
89+
- Adding new snapshot tests
90+
- Intentionally updating UI components (after verifying the changes are correct)
91+
92+
**Verifying Snapshots (Running Tests):**
93+
```bash
94+
./gradlew verifyPaparazziDebug
95+
```
96+
This command runs all snapshot tests and compares the rendered output against the baseline images. If differences are detected, the test will fail and show you the differences.
97+
98+
**Note:** Snapshot images are stored in Git LFS, so make sure you have Git LFS set up before working with snapshots.
99+
73100
#### CI Integration
74-
Formatting is automatically verified on every Pull Request via GitHub Actions.
101+
Formatting and snapshot tests are automatically verified on every Pull Request via GitHub Actions.
75102

76103
<!-- GETTING STARTED -->
77104

android/core/test-utils/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
alias(libs.plugins.androidLibrary)
33
alias(libs.plugins.kotlinAndroid)
4+
alias(libs.plugins.compose.multiplatform)
45
}
56

67
apply(from = "../../../gradle_include/circuit.gradle")
@@ -27,6 +28,7 @@ kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarg
2728

2829
dependencies {
2930
implementation(libs.paparazzi)
31+
implementation(compose.components.resources)
3032
implementation(project(Modules.CORE_THEME))
3133

3234
testImplementation(libs.test.parameter.injector)

android/core/test-utils/src/main/kotlin/io/newm/core/test/utils/SnapshotTest.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package io.newm.core.test.utils
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.CompositionLocalProvider
5+
import androidx.compose.ui.platform.LocalInspectionMode
46
import app.cash.paparazzi.Paparazzi
57
import io.newm.core.theme.NewmTheme
8+
import org.jetbrains.compose.resources.PreviewContextConfigurationEffect
69
import org.junit.Rule
710

811
abstract class SnapshotTest(
@@ -19,6 +22,9 @@ abstract class SnapshotTest(
1922

2023
fun snapshot(content: @Composable () -> Unit) {
2124
paparazzi.snapshot {
25+
CompositionLocalProvider(LocalInspectionMode provides true) {
26+
PreviewContextConfigurationEffect()
27+
}
2228
NewmTheme(darkTheme = snapshotTestConfiguration.isDarkMode, content = content)
2329
}
2430
}

gradle/libs.versions.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ media3 = "1.9.0"
4747
mockk = "1.14.7"
4848
navigationCompose = "2.9.6"
4949
paletteKtx = "1.0.0"
50-
paparazzi = "1.3.5"
50+
paparazzi = "2.0.0-alpha02"
5151
playServicesAuth = "21.5.0"
5252
processPhoenix = "3.0.0"
5353
recaptcha = "18.8.0"
@@ -141,14 +141,12 @@ launchdarkly-client = { module = "com.launchdarkly:launchdarkly-android-client-s
141141
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
142142
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
143143
paparazzi = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazzi" }
144-
paparazzi-gradle-plugin = { module = "app.cash.paparazzi:paparazzi-gradle-plugin", version.ref = "paparazzi" }
145144
play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuth" }
146145
process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" }
147146
recaptcha = { module = "com.google.android.recaptcha:recaptcha", version.ref = "recaptcha" }
148147
soloader = { module = "com.facebook.soloader:soloader", version.ref = "soloader" }
149148
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
150149
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
151-
sqldelight-gradle-plugin = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" }
152150
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
153151
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
154152
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }

sharedfeatures/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
alias(libs.plugins.kotlinMultiplatform)
99
alias(libs.plugins.kotlin.plugin.parcelize)
1010
alias(libs.plugins.kotlin.serialization)
11+
alias(libs.plugins.paparazzi)
1112
}
1213

1314
android {
@@ -75,6 +76,13 @@ kotlin {
7576
}
7677
}
7778

79+
androidUnitTest {
80+
dependencies {
81+
implementation(project(Modules.TEST_UTILS))
82+
implementation(libs.test.parameter.injector)
83+
}
84+
}
85+
7886
targets.configureEach {
7987
if (platformType == KotlinPlatformType.androidJvm) {
8088
compilations.configureEach {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.newm.sharedfeatures.paparazzi
2+
3+
import androidx.compose.ui.Modifier
4+
import com.google.testing.junit.testparameterinjector.TestParameter
5+
import com.google.testing.junit.testparameterinjector.TestParameterInjector
6+
import io.newm.core.test.utils.SnapshotTest
7+
import io.newm.core.test.utils.SnapshotTestConfiguration
8+
import io.newm.sharedfeatures.login.EmailState
9+
import io.newm.sharedfeatures.login.LoginUi
10+
import io.newm.sharedfeatures.login.PasswordState
11+
import io.newm.sharedfeatures.screens.LoginScreen
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
15+
@RunWith(TestParameterInjector::class)
16+
class LoginUiTest(
17+
@TestParameter configuration: SnapshotTestConfiguration,
18+
) : SnapshotTest(configuration) {
19+
@Test
20+
fun defaultLoginUi() {
21+
snapshot {
22+
LoginUi(
23+
state =
24+
LoginScreen.UiState(
25+
emailState = EmailState(),
26+
passwordState = PasswordState(),
27+
submitButtonEnabled = true,
28+
errorMessage = null,
29+
isLoading = false,
30+
eventSink = {},
31+
),
32+
modifier = Modifier,
33+
)
34+
}
35+
}
36+
37+
@Test
38+
fun filledOutLoginUi() {
39+
snapshot {
40+
LoginUi(
41+
state =
42+
LoginScreen.UiState(
43+
emailState =
44+
EmailState().apply {
45+
46+
},
47+
passwordState = PasswordState().apply { text = "password" },
48+
submitButtonEnabled = true,
49+
// errorMessage =
50+
// Res.string.password_validation_error_message,
51+
errorMessage = null,
52+
isLoading = true,
53+
eventSink = {},
54+
),
55+
modifier = Modifier,
56+
)
57+
}
58+
}
59+
}
Lines changed: 3 additions & 0 deletions
Loading

sharedfeatures/src/commonMain/kotlin/io/newm/sharedfeatures/login/LoginUi.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.slack.circuit.runtime.CircuitContext
2222
import com.slack.circuit.runtime.screen.Screen
2323
import com.slack.circuit.runtime.ui.Ui
2424
import com.slack.circuit.runtime.ui.ui
25+
import io.newm.core.theme.NewmTheme
2526
import io.newm.core.theme.inter
2627
import io.newm.core.ui.ToastSideEffect
2728
import io.newm.core.ui.buttons.PrimaryButton
@@ -32,6 +33,7 @@ import newm_mobile.sharedfeatures.generated.resources.login
3233
import newm_mobile.sharedfeatures.generated.resources.password
3334
import newm_mobile.sharedfeatures.generated.resources.reset_password_forgot_your_password
3435
import org.jetbrains.compose.resources.stringResource
36+
import org.jetbrains.compose.ui.tooling.preview.Preview
3537

3638
@Composable
3739
fun LoginUi(
@@ -105,6 +107,25 @@ internal fun LoginScreenContent(
105107
}
106108
}
107109

110+
@Preview
111+
@Composable
112+
fun PreviewLoginUi() {
113+
NewmTheme {
114+
LoginUi(
115+
state =
116+
LoginScreen.UiState(
117+
emailState = EmailState(),
118+
passwordState = PasswordState(),
119+
submitButtonEnabled = true,
120+
errorMessage = null,
121+
isLoading = false,
122+
eventSink = {},
123+
),
124+
modifier = Modifier,
125+
)
126+
}
127+
}
128+
108129
class LoginUiFactory
109130
@Inject
110131
constructor() : Ui.Factory {
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)