From 3b453a2469588aa41991b1276df9c148ae8328b0 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 4 Jan 2025 10:11:01 +0100 Subject: [PATCH 1/9] migrate to gradle managed devices --- .github/actions/get-avd-info/action.yml | 22 ------------- .github/workflows/ci.yml | 30 ++++++++--------- app/build.gradle.kts | 44 ++++++++++++++++++++++++- gradle/libs.versions.toml | 2 ++ 4 files changed, 59 insertions(+), 39 deletions(-) delete mode 100644 .github/actions/get-avd-info/action.yml diff --git a/.github/actions/get-avd-info/action.yml b/.github/actions/get-avd-info/action.yml deleted file mode 100644 index 8e1f40320..000000000 --- a/.github/actions/get-avd-info/action.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: 'Get AVD Info' -description: 'Get the AVD info based on its API level.' -inputs: - api-level: - description: 'The API level of the AVD.' - required: true -outputs: - arch: - description: 'The architecture of the AVD.' - value: ${{ steps.get-avd-arch.outputs.arch }} - target: - description: 'The target of the AVD.' - value: ${{ steps.get-avd-target.outputs.target }} -runs: - using: "composite" - steps: - - id: get-avd-arch - run: echo "arch=$(if [ ${{ inputs.api-level }} -ge 30 ]; then echo x86_64; else echo x86; fi)" >> $GITHUB_OUTPUT - shell: bash - - id: get-avd-target - run: echo "target=$(echo default)" >> $GITHUB_OUTPUT - shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deb3d8757..f30e8b21f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,8 @@ jobs: timeout-minutes: 60 strategy: matrix: - api-level: [34] + api-level: [29, 30, 31, 33, 35] + fail-fast: false # make sure all tests finish, even if some fail steps: - uses: actions/checkout@v4 @@ -116,33 +117,30 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} cache-read-only: true - # API 30+ emulators only have x86_64 system images. - - name: Get AVD info - uses: ./.github/actions/get-avd-info - id: avd-info - with: - api-level: ${{ matrix.api-level }} - + - name: Accept licenses + run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses + - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - - name: Instrumentation tests - uses: reactivecircus/android-emulator-runner@v2.33.0 + + - name: Cache AVD + uses: actions/cache@v4 with: - api-level: ${{ matrix.api-level }} - arch: ${{ steps.avd-info.outputs.arch }} - target: ${{ steps.avd-info.outputs.target }} - script: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:connectedDebugAndroidTest + path: ~/.config/.android/avd + key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there + + - name: Run device tests + run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:api${{ matrix.api-level }}DebugAndroidTest --stacktrace - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: instrumentation-test-results-${{ matrix.api-level }} - path: app/build/reports/androidTests/connected/debug + path: app/build/reports/androidTests/managedDevice/debug/api${{ matrix.api-level }} final-check: name: CI checks passed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99865050a..08c5dacb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,10 +87,51 @@ android { targetCompatibility = javaVersion } - // needed for mockk testOptions { animationsDisabled = true + execution = "ANDROIDX_TEST_ORCHESTRATOR" + + managedDevices { + localDevices { + create("api29") { + device = "Pixel 6a" + apiLevel = 29 + systemImageSource = "aosp" + } + create("api30") { + device = "Pixel 6a" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + create("api31") { + device = "Pixel 6a" + apiLevel = 31 + systemImageSource = "aosp-atd" + } + create("api33") { + device = "Pixel 6a" + apiLevel = 33 + systemImageSource = "aosp-atd" + } + create("api35") { + device = "Pixel 6a" + apiLevel = 35 + systemImageSource = "aosp-atd" + } + } + groups { + create("all") { + targetDevices.add(devices["api29"]) + targetDevices.add(devices["api30"]) + targetDevices.add(devices["api31"]) + targetDevices.add(devices["api33"]) + targetDevices.add(devices["api35"]) + } + } + } + + // needed for mockk packaging { jniLibs { useLegacyPackaging = true } } @@ -318,6 +359,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) // Instrumentation tests + androidTestUtil(libs.androidx.test.orchestrator) androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.android.compiler) androidTestImplementation(libs.junit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0532f6233..c66acdf8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-test-runner = "1.6.1" androidx-test-rules = "1.6.1" androidx-test-espresso = "3.6.1" androidx-test-ext-junit = "1.2.1" +androidx-test-orchestrator = "1.5.1" androidx-hilt-navigation-compose = "1.2.0" # @keep compileSdk = "35" @@ -83,6 +84,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } androidx-test-navigation = { module = "androidx.navigation:navigation-testing", version.ref = "androidx-navigation" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdk-desugar" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt-formatting" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" } From 77466ad22ad12c6cbac32c06d275c46f1bf1f3ad Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 4 Jan 2025 10:14:02 +0100 Subject: [PATCH 2/9] add automated screenshots on test failure --- app/build.gradle.kts | 98 +++++++++++++++++++ .../androidTest/java/app/ScreenshotRule.kt | 69 +++++++++++++ .../presentation/ActiveSessionScreenTest.kt | 4 + .../LibraryFolderDetailsScreenTest.kt | 4 + .../presentation/LibraryIntegrationTest.kt | 4 + .../library/presentation/LibraryScreenTest.kt | 4 + 6 files changed, 183 insertions(+) create mode 100644 app/src/androidTest/java/app/ScreenshotRule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08c5dacb9..273896dfb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -261,6 +261,26 @@ tasks.register("fixLicense") { description = "Fixes the license header in all staged files." } +val embedScreenshotsTask = tasks.register("embedScreenshots") { + dependsOn("createDebugApkListingFileRedirect") + dependsOn("createDebugAndroidTestApkListingFileRedirect") + + buildIntermediatesDir.set( + project.layout.projectDirectory.dir("build/intermediates") + ) + managedDeviceReportDirectory.set( + project.layout.projectDirectory.dir("build/reports/androidTests/managedDevice/debug") + ) +} + +tasks.whenTaskAdded { + // Do not attempt to embed screenshots for API level < 29 since + // additional_test_output is not supported. + if (name.matches(regex = Regex("api[3-9][0-9]DebugAndroidTest"))) { + finalizedBy(embedScreenshotsTask) + } +} + dependencies { detektPlugins(libs.detekt.formatting) @@ -447,3 +467,81 @@ abstract class SetupMusikusTask @Inject constructor( println("Name stored for copyright header: $name\n") } } + +abstract class EmbedScreenshotsTask : DefaultTask() { + + @InputDirectory + val buildIntermediatesDir = project.objects.directoryProperty() + + @OutputDirectory + val managedDeviceReportDirectory = project.objects.directoryProperty() + + @get:Inject abstract val fs: FileSystemOperations + + init { + group = "verification" + description = "Embeds screenshots into JUnit test reports." + } + + @TaskAction + fun embedScreenshots() { + println("Embedding screenshots into JUnit reports...") + + val buildIntermediatesDirFile = buildIntermediatesDir.asFile.get() + val managedDeviceReportDirectoryFile = managedDeviceReportDirectory.asFile.get() + + val additionalTestOutputDirFile = File( + "$buildIntermediatesDirFile/managed_device_android_test_additional_output/debugAndroidTest" + ) + + val deviceDirectory = additionalTestOutputDirFile.listFiles()?.firstOrNull() + + if (deviceDirectory == null) { + println("No device directory found in '$additionalTestOutputDirFile'") + return + } + val deviceName = deviceDirectory.name.replace("DebugAndroidTest", "") + + println("Processing screenshots for device '$deviceName'...") + + val screenshotDirectory = File(managedDeviceReportDirectoryFile, "$deviceName/screenshots") + + println("Copying screenshots to '$screenshotDirectory'...") + + fs.copy { + from(deviceDirectory) + into(screenshotDirectory) + } + + screenshotDirectory.listFiles()?.forEach { failedTestClassDirectory -> + println("Processing screenshots for test class '${failedTestClassDirectory.name}'...") + + val failedTestClassName = failedTestClassDirectory.name + + failedTestClassDirectory.listFiles()?.forEach listFiles@{ failedTestFile -> + println("Embedding screenshot for test '$failedTestFile'...") + val failedTestName = failedTestFile.name + val failedTestNameWithoutExtension = failedTestName.substringBeforeLast('.') + val failedTestClassJunitReportFile = File( + managedDeviceReportDirectoryFile, + "$deviceName/$failedTestClassName.html" + ) + + if (!failedTestClassJunitReportFile.exists()) { + println("Could not find JUnit report file for test class '$failedTestClassJunitReportFile'") + return@listFiles + } + + var failedTestJunitReportContent = failedTestClassJunitReportFile.readText() + + val patternToFind = "

$failedTestNameWithoutExtension

" + val patternToReplace = + "$patternToFind
" + + failedTestJunitReportContent = failedTestJunitReportContent.replace(patternToFind, patternToReplace) + + failedTestClassJunitReportFile.writeText(failedTestJunitReportContent) + } + } + } +} diff --git a/app/src/androidTest/java/app/ScreenshotRule.kt b/app/src/androidTest/java/app/ScreenshotRule.kt new file mode 100644 index 000000000..e158fde4c --- /dev/null +++ b/app/src/androidTest/java/app/ScreenshotRule.kt @@ -0,0 +1,69 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2024 Matthias Emde + */ + +package app + +import android.graphics.Bitmap +import android.util.Log +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File + +// Store screenshots in "/sdcard/Android/media/app.musikus/additional_test_output" +// This way, the files will get automatically synced to app/build/outputs/managed_device_android_test_additional_output +// before the emulator gets shut down. +// Source: https://stackoverflow.com/questions/74069309/copy-data-from-an-android-emulator-that-is-run-by-gradle-managed-devices + +class ScreenshotRule( + private val composeTestRule: ComposeTestRule, +) : TestWatcher() { + + var outputDir: File + + init { + @Suppress("Deprecation") + outputDir = File( + InstrumentationRegistry.getInstrumentation().targetContext.externalMediaDirs.first(), + "additional_test_output" + ) + + // Ensure the directory exists + if (!outputDir.exists()) { + outputDir.mkdirs() + } + } + + override fun failed(e: Throwable?, description: Description) { + val testClassDir = File( + outputDir, + description.className + ) + + if (!testClassDir.exists()) { + testClassDir.mkdirs() + } + + val screenshotName = "${description.methodName}.png" + val screenshotFile = File(testClassDir, screenshotName) + Log.d("ComposeScreenshotRule", "Saving screenshot to ${screenshotFile.absolutePath}") + + // Capture the screenshot and save it + composeTestRule.onRoot().captureToImage().asAndroidBitmap().apply { + screenshotFile.outputStream().use { outputStream -> + compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + } + +// runBlocking { delay(100000) } + } +} diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 1d71f7aa5..e3c55d353 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.navigation.NavHostController +import app.ScreenshotRule import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -65,6 +66,9 @@ class ActiveSessionScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + lateinit var navController: NavHostController lateinit var mainViewModel: MainViewModel diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index 9ee511771..1aab3d813 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.hilt.navigation.compose.hiltViewModel +import app.ScreenshotRule import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -56,6 +57,9 @@ class LibraryFolderDetailsScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + @Before fun setUp() { hiltRule.inject() diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt index 7f777f140..522d86f65 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt @@ -29,6 +29,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute +import app.ScreenshotRule import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeTab import app.musikus.core.presentation.HomeTabNavType @@ -56,6 +57,9 @@ class LibraryIntegrationTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + lateinit var navController: TestNavHostController @Before diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index 5e3982ad2..9c2ab970c 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.core.app.ApplicationProvider +import app.ScreenshotRule import app.musikus.R import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -52,6 +53,9 @@ class LibraryScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + @Before fun setUp() { hiltRule.inject() From 4dd24650c2768c80189c5ad161f4dde3dd5d5249 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 10:35:11 +0100 Subject: [PATCH 3/9] add minSdk decorators to incompatible tests --- .../presentation/ActiveSessionScreenTest.kt | 3 +++ .../goals/data/daos/GoalDescriptionDaoTest.kt | 15 ++++++++----- .../goals/data/daos/GoalInstanceDaoTest.kt | 7 ++++-- .../library/data/daos/LibraryFolderDaoTest.kt | 16 +++++++++----- .../library/data/daos/LibraryItemDaoTest.kt | 22 +++++++++++++------ .../LibraryFolderDetailsScreenTest.kt | 3 +++ .../sessionslist/data/daos/SectionDaoTest.kt | 7 ++++-- .../sessionslist/data/daos/SessionDaoTest.kt | 18 ++++++++++----- 8 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index e3c55d353..e631b12bc 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.navigation.NavHostController +import androidx.test.filters.SdkSuppress import app.ScreenshotRule import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem @@ -216,6 +217,7 @@ class ActiveSessionScreenTest { } // @Test +// @SdkSuppress(minSdkVersion = 30) // fun deleteAndRedoSection() = runTest { // // Start session // composeRule.onNodeWithContentDescription("Start practicing").performClick() @@ -279,6 +281,7 @@ class ActiveSessionScreenTest { * Section: 00000000-0000-0000-0000-000000000007 */ @Test + @SdkSuppress(minSdkVersion = 29) // somehow crashes on API < 29 fun finishSession() = runTest { // Start session composeRule.onNodeWithContentDescription("Start practicing").performClick() diff --git a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt index b49145419..1a5eedca4 100644 --- a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.goals.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.GoalDescriptionWithInstancesAndLibraryItems import app.musikus.core.data.MusikusDatabase @@ -54,7 +55,7 @@ class GoalDescriptionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -363,12 +364,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateGoalDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.update(UUIDConverter.fromInt(1), GoalDescriptionUpdateAttributes()) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -423,12 +425,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteGoalDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.delete(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -514,12 +517,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.restore(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -596,12 +600,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } diff --git a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt index d92e05866..ef3b84e6e 100644 --- a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.goals.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.GoalDescriptionWithLibraryItems import app.musikus.core.data.GoalInstanceWithDescription @@ -53,7 +54,7 @@ class GoalInstanceDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -432,6 +433,7 @@ class GoalInstanceDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateInstance() = runTest { val updateAttributes = GoalInstanceUpdateAttributes( endTimestamp = Nullable(fakeTimeProvider.now()), @@ -445,7 +447,7 @@ class GoalInstanceDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -758,6 +760,7 @@ class GoalInstanceDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificInstance() = runTest { val goalInstanceDaoSpy = spyk(goalInstanceDao) diff --git a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt index d626d7f95..79189509c 100644 --- a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.library.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.LibraryFolderWithItems import app.musikus.core.data.MusikusDatabase @@ -46,7 +47,7 @@ class LibraryFolderDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -93,6 +94,7 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun insertFolder() = runTest { val folder = LibraryFolderCreationAttributes(name = "TestFolder") @@ -149,6 +151,7 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateFolder() = runTest { val updateAttributes = LibraryFolderUpdateAttributes("UpdatedFolder1") @@ -159,7 +162,7 @@ class LibraryFolderDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -213,12 +216,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.delete(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -288,12 +292,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.restore(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -354,12 +359,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } diff --git a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt index d404bb815..d6975749d 100644 --- a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt @@ -9,6 +9,7 @@ package app.musikus.library.data.daos import android.database.sqlite.SQLiteConstraintException +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -48,7 +49,7 @@ class LibraryItemDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -114,6 +115,7 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun insertItem() = runTest { val item = LibraryItemCreationAttributes( name = "TestItem", @@ -142,7 +144,8 @@ class LibraryItemDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } @@ -209,6 +212,7 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateItem() = runTest { val updateAttributes = LibraryItemUpdateAttributes( name = "UpdatedItem1", @@ -223,7 +227,7 @@ class LibraryItemDaoTest { UUIDConverter.fromInt(2), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -274,7 +278,8 @@ class LibraryItemDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } @@ -312,12 +317,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.delete(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -399,12 +405,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.restore(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -481,12 +488,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index 1aab3d813..ad135aeb9 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.hilt.navigation.compose.hiltViewModel +import androidx.test.filters.SdkSuppress import app.ScreenshotRule import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider @@ -171,6 +172,7 @@ class LibraryFolderDetailsScreenTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun editItem() { // Add an item from inside the folder (folder should be pre-selected) composeRule.onNodeWithContentDescription("Add item").performClick() @@ -205,6 +207,7 @@ class LibraryFolderDetailsScreenTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun deleteItem() = runTest { // Add an item composeRule.onNodeWithContentDescription("Add item").performClick() diff --git a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt index bf6e16a38..9cebc791b 100644 --- a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.sessionslist.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -48,7 +49,7 @@ class SectionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -185,6 +186,7 @@ class SectionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateSection() = runTest { val updateAttributes = SectionUpdateAttributes(duration = 5.minutes) @@ -195,7 +197,7 @@ class SectionDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -327,6 +329,7 @@ class SectionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificSection() = runTest { val sectionDaoSpy = spyk(sectionDao) diff --git a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt index 5922eb14f..4c549431d 100644 --- a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt @@ -9,6 +9,7 @@ package app.musikus.sessionslist.data.daos import android.database.sqlite.SQLiteConstraintException +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -55,7 +56,7 @@ class SessionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -246,12 +247,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.update(UUIDConverter.fromInt(1), SessionUpdateAttributes()) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -299,12 +301,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.delete(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -387,12 +390,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.restore(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -446,12 +450,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -599,7 +604,8 @@ class SessionDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } From 52b110261a3adcb089fc4e18474fb82787b98559 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 10:37:54 +0100 Subject: [PATCH 4/9] implement asserWithLease() to include retries for assertions --- app/src/androidTest/java/app/ComposeRule.kt | 76 +++++++++++++++++++ .../presentation/ActiveSessionScreenTest.kt | 13 ++-- .../core/presentation/MusikusBottomBarTest.kt | 33 +++++--- .../core/presentation/MusikusNavHostTest.kt | 33 ++++---- .../LibraryFolderDetailsScreenTest.kt | 12 +-- .../presentation/LibraryIntegrationTest.kt | 5 +- .../library/presentation/LibraryScreenTest.kt | 17 +++-- 7 files changed, 143 insertions(+), 46 deletions(-) create mode 100644 app/src/androidTest/java/app/ComposeRule.kt diff --git a/app/src/androidTest/java/app/ComposeRule.kt b/app/src/androidTest/java/app/ComposeRule.kt new file mode 100644 index 000000000..e85664faf --- /dev/null +++ b/app/src/androidTest/java/app/ComposeRule.kt @@ -0,0 +1,76 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2025 Matthias Emde + */ + +package app + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import kotlin.time.Duration.Companion.milliseconds + +val LeaseSleepDuration = 500.milliseconds +const val LeaseDefaultAttempts = 10 + +fun AndroidComposeTestRule<*, *>.assertWithLease( + attempts: Int = LeaseDefaultAttempts, + assertion: () -> Unit +) { + try { + assertion() + } catch (e: Throwable) { + if (attempts > 0) { + Thread.sleep(LeaseSleepDuration.inWholeMilliseconds) + assertWithLease(attempts - 1, assertion) + } else { + throw e + } + } +} + +fun SemanticsNodeInteraction.assertWithLease( + attempts: Int = LeaseDefaultAttempts, + assertion: SemanticsNodeInteraction.() -> Unit +): SemanticsNodeInteraction { + try { + assertion() + } catch (e: Throwable) { + if (attempts > 0) { + Thread.sleep(LeaseSleepDuration.inWholeMilliseconds) + assertWithLease(attempts - 1, assertion) + } else { + throw e + } + } + + return this +} + +/** + * Asserts that the given SemanticsNodeInteractions are vertically ordered on the screen. + * + * This function iterates through the provided nodes and verifies that each node is + * positioned above the subsequent node in the list. It checks if the bottom bound + * of the current node is less than or equal to the top bound of the next node. + * + * If any two consecutive nodes violate this vertical order, an assertion error is thrown. + * + * @param nodes The SemanticsNodeInteractions to be checked for vertical order. + * + * @throws AssertionError If any two consecutive nodes are not vertically ordered as expected. + */ +fun assertNodesInVerticalOrder(vararg nodes: SemanticsNodeInteraction) { + for (i in 0 until nodes.size - 1) { + val currentBounds = nodes[i].getBoundsInRoot() + val nextBounds = nodes[i + 1].getBoundsInRoot() + + check(currentBounds.bottom <= nextBounds.top) { + "Expected node ${i + 1} to be above node ${i + 2}, but was not. " + + "Bounds: current = $currentBounds, next = $nextBounds" + } + } +} diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index e631b12bc..e5ef24082 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performTextInput import androidx.navigation.NavHostController import androidx.test.filters.SdkSuppress import app.ScreenshotRule +import app.assertWithLease import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -128,7 +129,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithContentDescription("Start practicing").performClick() composeRule.onNodeWithText("TestItem1").performClick() - composeRule.onNodeWithContentDescription("Next item").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Next item").assertWithLease { assertIsDisplayed() } } @Test @@ -141,13 +142,13 @@ class ActiveSessionScreenTest { composeRule.onNodeWithContentDescription("Pause").performClick() // Pause timer is displayed - composeRule.onNodeWithText("Paused 00:00").assertIsDisplayed() + composeRule.onNodeWithText("Paused 00:00").assertWithLease { assertIsDisplayed() } fakeTimeProvider.advanceTimeBy(90.seconds) // Pause timer shows correct time composeRule.onNodeWithText("Paused 01:30") - .assertIsDisplayed() + .assertWithLease { assertIsDisplayed() } .performClick() // Resume session // Pause timer is hidden @@ -170,7 +171,7 @@ class ActiveSessionScreenTest { matcher = hasText("TestItem3") and hasAnySibling(hasText("00:00")) - ).assertIsDisplayed() + ).assertWithLease { assertIsDisplayed() } } @Test @@ -182,7 +183,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("TestItem1").performClick() // Item is selected - composeRule.onNodeWithText("TestItem1").assertIsDisplayed() + composeRule.onNodeWithText("TestItem1").assertWithLease { assertIsDisplayed() } // Open item selector again composeRule.onNodeWithContentDescription("Next item").performClick() @@ -191,7 +192,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("TestItem2").performClick() // Item is selected - composeRule.onNodeWithText("TestItem2").assertIsDisplayed() + composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } } @Test diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt index 94628e78f..97e7fabed 100644 --- a/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt @@ -10,15 +10,19 @@ package app.musikus.core.presentation import androidx.activity.compose.setContent import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController import app.musikus.core.domain.FakeTimeProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest @@ -38,7 +42,7 @@ class MusikusBottomBarTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() - lateinit var navController: NavHostController + lateinit var onTabSelectedMock: (HomeTab) -> Unit @Before fun setUp() { @@ -49,15 +53,16 @@ class MusikusBottomBarTest { val mainUiState by mainViewModel.uiState.collectAsStateWithLifecycle() val eventHandler = mainViewModel::onUiEvent - navController = mockk(relaxed = true) + var currentTab: HomeTab by remember { mutableStateOf(HomeTab.Sessions) } + + onTabSelectedMock = mockk() + every { onTabSelectedMock(any()) } answers { currentTab = firstArg() } MusikusBottomBar( mainUiState = mainUiState, mainEventHandler = eventHandler, - currentTab = HomeTab.Sessions, - onTabSelected = { selectedTab -> - navController.navigate(Screen.Home(selectedTab)) - }, + currentTab = currentTab, + onTabSelected = onTabSelectedMock, ) } } @@ -68,7 +73,7 @@ class MusikusBottomBarTest { // Since we are already on the sessions tab, we should not navigate verify(exactly = 0) { - navController.navigate(any()) + onTabSelectedMock(any()) } } @@ -77,8 +82,10 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Goals").performClick() verify(exactly = 1) { - navController.navigate(Screen.Home(HomeTab.Goals)) + onTabSelectedMock(HomeTab.Goals) } + + composeRule.onNodeWithText("Goals").assertIsSelected() } @Test @@ -86,8 +93,10 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Statistics").performClick() verify(exactly = 1) { - navController.navigate(Screen.Home(HomeTab.Statistics)) + onTabSelectedMock(HomeTab.Statistics) } + + composeRule.onNodeWithText("Statistics").assertIsSelected() } @Test @@ -95,7 +104,9 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Library").performClick() verify(exactly = 1) { - navController.navigate(Screen.Home(HomeTab.Library)) + onTabSelectedMock(HomeTab.Library) } + + composeRule.onNodeWithText("Library").assertIsSelected() } } diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt index b3dfdcb82..9ecd4abd7 100644 --- a/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt @@ -19,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController +import app.assertWithLease import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.theme.MusikusTheme @@ -85,7 +86,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Sessions) - composeRule.onNodeWithText("Sessions").assertIsDisplayed() + composeRule.onNodeWithText("Sessions").assertWithLease { assertIsDisplayed() } } @Test @@ -102,7 +103,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Goals) - composeRule.onNodeWithText("Goals").assertIsDisplayed() + composeRule.onNodeWithText("Goals").assertWithLease { assertIsDisplayed() } } @Test @@ -118,7 +119,7 @@ class MusikusNavHostTest { assertThat(screen).isInstanceOf(Screen.Home::class.java) require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Statistics) - composeRule.onNodeWithText("Statistics").assertIsDisplayed() + composeRule.onNodeWithText("Statistics").assertWithLease { assertIsDisplayed() } } @Test @@ -135,7 +136,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Library) - composeRule.onNodeWithText("Library").assertIsDisplayed() + composeRule.onNodeWithText("Library").assertWithLease { assertIsDisplayed() } } @Test @@ -149,7 +150,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.LibraryFolderDetails::class.java) - composeRule.onNodeWithText("Folder not found").assertIsDisplayed() + composeRule.onNodeWithText("Folder not found").assertWithLease { assertIsDisplayed() } } @Test @@ -163,7 +164,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.ActiveSession::class.java) - composeRule.onNodeWithText("Practice Time").assertIsDisplayed() + composeRule.onNodeWithText("Practice Time").assertWithLease { assertIsDisplayed() } } @Test @@ -177,7 +178,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SessionStatistics::class.java) - composeRule.onNodeWithText("Session History").assertIsDisplayed() + composeRule.onNodeWithText("Session History").assertWithLease { assertIsDisplayed() } } @Test @@ -191,7 +192,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.GoalStatistics::class.java) - composeRule.onNodeWithText("Goal History").assertIsDisplayed() + composeRule.onNodeWithText("Goal History").assertWithLease { assertIsDisplayed() } } @Test @@ -205,7 +206,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Settings::class.java) - composeRule.onNodeWithText("Settings").assertIsDisplayed() + composeRule.onNodeWithText("Settings").assertWithLease { assertIsDisplayed() } } @Test @@ -219,7 +220,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Donate::class.java) - composeRule.onNodeWithText("Support us!").assertIsDisplayed() + composeRule.onNodeWithText("Support us!").assertWithLease { assertIsDisplayed() } } @Test @@ -233,7 +234,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.About::class.java) - composeRule.onNodeWithText("About").assertIsDisplayed() + composeRule.onNodeWithText("About").assertWithLease { assertIsDisplayed() } } @Test @@ -247,7 +248,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Help::class.java) - composeRule.onNodeWithText("Help").assertIsDisplayed() + composeRule.onNodeWithText("Help").assertWithLease { assertIsDisplayed() } } @Test @@ -261,7 +262,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Backup::class.java) - composeRule.onNodeWithText("Backup and restore").assertIsDisplayed() + composeRule.onNodeWithText("Backup and restore").assertWithLease { assertIsDisplayed() } } @Test @@ -275,7 +276,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Export::class.java) - composeRule.onNodeWithText("Export session data").assertIsDisplayed() + composeRule.onNodeWithText("Export session data").assertWithLease { assertIsDisplayed() } } @Test @@ -289,7 +290,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Appearance::class.java) - composeRule.onNodeWithText("Appearance").assertIsDisplayed() + composeRule.onNodeWithText("Appearance").assertWithLease { assertIsDisplayed() } } @Test @@ -303,6 +304,6 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.License::class.java) - composeRule.onNodeWithText("Licenses").assertIsDisplayed() + composeRule.onNodeWithText("Licenses").assertWithLease { assertIsDisplayed() } } } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index ad135aeb9..1f5be2c6c 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -9,9 +9,7 @@ package app.musikus.library.presentation import androidx.activity.compose.setContent -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasContentDescription @@ -28,6 +26,8 @@ import androidx.compose.ui.test.performTouchInput import androidx.hilt.navigation.compose.hiltViewModel import androidx.test.filters.SdkSuppress import app.ScreenshotRule +import app.assertNodesInVerticalOrder +import app.assertWithLease import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -90,7 +90,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithContentDescription("Create").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem2").assertIsDisplayed() + composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } } private fun clickSortMode( @@ -168,7 +168,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if folder name is displayed - composeRule.onNodeWithText("TestFolder2").assertIsDisplayed() + composeRule.onNodeWithText("TestFolder2").assertWithLease { assertIsDisplayed() } } @Test @@ -188,7 +188,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if edited item name is displayed - composeRule.onNodeWithText("TestItem2").assertIsDisplayed() + composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } // Edit item using action mode composeRule.onNodeWithText("TestItem2").performTouchInput { longClick() } @@ -203,7 +203,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if edited item name is displayed - composeRule.onNodeWithText("TestItem3").assertIsDisplayed() + composeRule.onNodeWithText("TestItem3").assertWithLease { assertIsDisplayed() } } @Test diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt index 522d86f65..e902a7a1c 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt @@ -30,6 +30,7 @@ import androidx.navigation.compose.composable import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute import app.ScreenshotRule +import app.assertWithLease import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeTab import app.musikus.core.presentation.HomeTabNavType @@ -126,7 +127,7 @@ class LibraryIntegrationTest { composeRule.onNodeWithText("TestFolder").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem1").assertIsDisplayed() + composeRule.onNodeWithText("TestItem1").assertWithLease { assertIsDisplayed() } // Add an item from inside the folder (folder should be pre-selected) composeRule.onNodeWithContentDescription("Add item").performClick() @@ -134,6 +135,6 @@ class LibraryIntegrationTest { composeRule.onNodeWithContentDescription("Create").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem2").assertIsDisplayed() + composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } } } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index 9c2ab970c..d8e819753 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -30,6 +30,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.core.app.ApplicationProvider import app.ScreenshotRule +import app.assertWithLease import app.musikus.R import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -80,8 +81,8 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Add folder or item").performClick() - composeRule.onNodeWithContentDescription("Add folder").assertIsDisplayed() - composeRule.onNodeWithContentDescription("Add item").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Add folder").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithContentDescription("Add item").assertWithLease { assertIsDisplayed() } } @Test @@ -89,7 +90,9 @@ class LibraryScreenTest { val context = ApplicationProvider.getApplicationContext() // Check if hint is displayed initially - composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() + composeRule.onNodeWithText( + context.getString(R.string.library_screen_hint) + ).assertWithLease { assertIsDisplayed() } // Add a folder composeRule.onNodeWithContentDescription("Add folder or item").performClick() @@ -106,7 +109,9 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Delete forever (1)").performClick() // Check if hint is displayed again - composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() + composeRule.onNodeWithText( + context.getString(R.string.library_screen_hint) + ).assertWithLease { assertIsDisplayed() } // Add an item composeRule.onNodeWithContentDescription("Add folder or item").performClick() @@ -123,7 +128,9 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Delete forever (1)").performClick() // Check if hint is displayed again - composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() + composeRule.onNodeWithText( + context.getString(R.string.library_screen_hint) + ).assertWithLease { assertIsDisplayed() } } private fun clickSortMode( From 8d030c184348d4778d002bb0ad9c31f68033a527 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 10:38:36 +0100 Subject: [PATCH 5/9] rewrite checkSorting test to reduce flakyness --- .../LibraryFolderDetailsScreenTest.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index 1f5be2c6c..fc29a00fc 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -124,36 +124,36 @@ class LibraryFolderDetailsScreenTest { } // Check if items are displayed in correct order - var itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(3) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains(namesAndColors[namesAndColors.lastIndex - i].first) + composeRule.assertWithLease { + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem3") + ) } // Change sorting mode to name descending clickSortMode("items", "Name") // Check if items are displayed in correct order - itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(namesAndColors.size) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains("TestItem${namesAndColors.size - i}") + composeRule.assertWithLease { + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem3"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1") + ) } // Change sorting mode to name ascending clickSortMode("items", "Name") // Check if items are displayed in correct order - itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(namesAndColors.size) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains("TestItem${i + 1}") + composeRule.assertWithLease { + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem3") + ) } } From 160484a8925e6b767bff822865ed137797e5a9bb Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 22:55:47 +0100 Subject: [PATCH 6/9] fix race condition --- .../presentation/LibraryFolderDetailsScreenTest.kt | 6 +++++- .../library/presentation/LibraryScreenTest.kt | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index fc29a00fc..b10b81c12 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -38,6 +38,7 @@ import app.musikus.library.domain.usecase.LibraryUseCases import app.musikus.library.presentation.libraryfolder.LibraryFolderDetailsScreen import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before @@ -114,12 +115,15 @@ class LibraryFolderDetailsScreenTest { "TestItem2" to 5, ) - namesAndColors.forEach { (name, color) -> + namesAndColors.forEachIndexed { index, (name, color) -> composeRule.onNodeWithContentDescription("Add item").performClick() composeRule.onNodeWithTag(TestTags.ITEM_DIALOG_NAME_INPUT).performTextInput(name) composeRule.onNodeWithContentDescription("Color $color").performClick() composeRule.onNodeWithContentDescription("Create").performClick() + // Suspend execution until the item is added to avoid race conditions + libraryUseCases.getAllItems().first { it.size == index + 1 } + fakeTimeProvider.advanceTimeBy(1.seconds) } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index d8e819753..929235d84 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -36,8 +36,11 @@ import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity import app.musikus.core.presentation.MainViewModel import app.musikus.core.presentation.utils.TestTags +import app.musikus.library.domain.usecase.LibraryUseCases import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -48,6 +51,8 @@ import kotlin.time.Duration.Companion.seconds class LibraryScreenTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider + @Inject lateinit var libraryUseCases: LibraryUseCases + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @@ -147,20 +152,23 @@ class LibraryScreenTest { } @Test - fun saveNewItems_checkDefaultSortingThenNameSortingDescAndAsc() { + fun saveNewItems_checkDefaultSortingThenNameSortingDescAndAsc() = runTest { val namesAndColors = listOf( "TestItem3" to 3, "TestItem1" to 9, "TestItem2" to 5, ) - namesAndColors.forEach { (name, color) -> + namesAndColors.forEachIndexed { index, (name, color) -> composeRule.onNodeWithContentDescription("Add folder or item").performClick() composeRule.onNodeWithContentDescription("Add item").performClick() composeRule.onNodeWithTag(TestTags.ITEM_DIALOG_NAME_INPUT).performTextInput(name) composeRule.onNodeWithContentDescription("Color $color").performClick() composeRule.onNodeWithContentDescription("Create").performClick() + // Suspend execution until the item is added to avoid race conditions + libraryUseCases.getAllItems().first { it.size == index + 1 } + fakeTimeProvider.advanceTimeBy(1.seconds) } From 462fa4a7fb0310aca21024f4a72df0f45439cc2f Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 12 Jan 2025 12:22:47 +0100 Subject: [PATCH 7/9] revert 'assertWithLease' --- app/src/androidTest/java/app/ComposeRule.kt | 39 ---------------- .../presentation/ActiveSessionScreenTest.kt | 13 +++--- .../core/presentation/MusikusBottomBarTest.kt | 33 +++++--------- .../core/presentation/MusikusNavHostTest.kt | 33 +++++++------- .../LibraryFolderDetailsScreenTest.kt | 45 ++++++++----------- .../presentation/LibraryIntegrationTest.kt | 5 +-- .../library/presentation/LibraryScreenTest.kt | 17 +++---- 7 files changed, 59 insertions(+), 126 deletions(-) diff --git a/app/src/androidTest/java/app/ComposeRule.kt b/app/src/androidTest/java/app/ComposeRule.kt index e85664faf..ebe368328 100644 --- a/app/src/androidTest/java/app/ComposeRule.kt +++ b/app/src/androidTest/java/app/ComposeRule.kt @@ -10,45 +10,6 @@ package app import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import kotlin.time.Duration.Companion.milliseconds - -val LeaseSleepDuration = 500.milliseconds -const val LeaseDefaultAttempts = 10 - -fun AndroidComposeTestRule<*, *>.assertWithLease( - attempts: Int = LeaseDefaultAttempts, - assertion: () -> Unit -) { - try { - assertion() - } catch (e: Throwable) { - if (attempts > 0) { - Thread.sleep(LeaseSleepDuration.inWholeMilliseconds) - assertWithLease(attempts - 1, assertion) - } else { - throw e - } - } -} - -fun SemanticsNodeInteraction.assertWithLease( - attempts: Int = LeaseDefaultAttempts, - assertion: SemanticsNodeInteraction.() -> Unit -): SemanticsNodeInteraction { - try { - assertion() - } catch (e: Throwable) { - if (attempts > 0) { - Thread.sleep(LeaseSleepDuration.inWholeMilliseconds) - assertWithLease(attempts - 1, assertion) - } else { - throw e - } - } - - return this -} /** * Asserts that the given SemanticsNodeInteractions are vertically ordered on the screen. diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index e5ef24082..e631b12bc 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.test.performTextInput import androidx.navigation.NavHostController import androidx.test.filters.SdkSuppress import app.ScreenshotRule -import app.assertWithLease import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -129,7 +128,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithContentDescription("Start practicing").performClick() composeRule.onNodeWithText("TestItem1").performClick() - composeRule.onNodeWithContentDescription("Next item").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithContentDescription("Next item").assertIsDisplayed() } @Test @@ -142,13 +141,13 @@ class ActiveSessionScreenTest { composeRule.onNodeWithContentDescription("Pause").performClick() // Pause timer is displayed - composeRule.onNodeWithText("Paused 00:00").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Paused 00:00").assertIsDisplayed() fakeTimeProvider.advanceTimeBy(90.seconds) // Pause timer shows correct time composeRule.onNodeWithText("Paused 01:30") - .assertWithLease { assertIsDisplayed() } + .assertIsDisplayed() .performClick() // Resume session // Pause timer is hidden @@ -171,7 +170,7 @@ class ActiveSessionScreenTest { matcher = hasText("TestItem3") and hasAnySibling(hasText("00:00")) - ).assertWithLease { assertIsDisplayed() } + ).assertIsDisplayed() } @Test @@ -183,7 +182,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("TestItem1").performClick() // Item is selected - composeRule.onNodeWithText("TestItem1").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem1").assertIsDisplayed() // Open item selector again composeRule.onNodeWithContentDescription("Next item").performClick() @@ -192,7 +191,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("TestItem2").performClick() // Item is selected - composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem2").assertIsDisplayed() } @Test diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt index 97e7fabed..94628e78f 100644 --- a/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt @@ -10,19 +10,15 @@ package app.musikus.core.presentation import androidx.activity.compose.setContent import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController import app.musikus.core.domain.FakeTimeProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest @@ -42,7 +38,7 @@ class MusikusBottomBarTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() - lateinit var onTabSelectedMock: (HomeTab) -> Unit + lateinit var navController: NavHostController @Before fun setUp() { @@ -53,16 +49,15 @@ class MusikusBottomBarTest { val mainUiState by mainViewModel.uiState.collectAsStateWithLifecycle() val eventHandler = mainViewModel::onUiEvent - var currentTab: HomeTab by remember { mutableStateOf(HomeTab.Sessions) } - - onTabSelectedMock = mockk() - every { onTabSelectedMock(any()) } answers { currentTab = firstArg() } + navController = mockk(relaxed = true) MusikusBottomBar( mainUiState = mainUiState, mainEventHandler = eventHandler, - currentTab = currentTab, - onTabSelected = onTabSelectedMock, + currentTab = HomeTab.Sessions, + onTabSelected = { selectedTab -> + navController.navigate(Screen.Home(selectedTab)) + }, ) } } @@ -73,7 +68,7 @@ class MusikusBottomBarTest { // Since we are already on the sessions tab, we should not navigate verify(exactly = 0) { - onTabSelectedMock(any()) + navController.navigate(any()) } } @@ -82,10 +77,8 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Goals").performClick() verify(exactly = 1) { - onTabSelectedMock(HomeTab.Goals) + navController.navigate(Screen.Home(HomeTab.Goals)) } - - composeRule.onNodeWithText("Goals").assertIsSelected() } @Test @@ -93,10 +86,8 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Statistics").performClick() verify(exactly = 1) { - onTabSelectedMock(HomeTab.Statistics) + navController.navigate(Screen.Home(HomeTab.Statistics)) } - - composeRule.onNodeWithText("Statistics").assertIsSelected() } @Test @@ -104,9 +95,7 @@ class MusikusBottomBarTest { composeRule.onNodeWithText("Library").performClick() verify(exactly = 1) { - onTabSelectedMock(HomeTab.Library) + navController.navigate(Screen.Home(HomeTab.Library)) } - - composeRule.onNodeWithText("Library").assertIsSelected() } } diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt index 9ecd4abd7..b3dfdcb82 100644 --- a/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt @@ -19,7 +19,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController -import app.assertWithLease import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.theme.MusikusTheme @@ -86,7 +85,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Sessions) - composeRule.onNodeWithText("Sessions").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Sessions").assertIsDisplayed() } @Test @@ -103,7 +102,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Goals) - composeRule.onNodeWithText("Goals").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Goals").assertIsDisplayed() } @Test @@ -119,7 +118,7 @@ class MusikusNavHostTest { assertThat(screen).isInstanceOf(Screen.Home::class.java) require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Statistics) - composeRule.onNodeWithText("Statistics").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Statistics").assertIsDisplayed() } @Test @@ -136,7 +135,7 @@ class MusikusNavHostTest { require(screen is Screen.Home) assertThat(screen.tab).isEqualTo(HomeTab.Library) - composeRule.onNodeWithText("Library").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Library").assertIsDisplayed() } @Test @@ -150,7 +149,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.LibraryFolderDetails::class.java) - composeRule.onNodeWithText("Folder not found").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Folder not found").assertIsDisplayed() } @Test @@ -164,7 +163,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.ActiveSession::class.java) - composeRule.onNodeWithText("Practice Time").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Practice Time").assertIsDisplayed() } @Test @@ -178,7 +177,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SessionStatistics::class.java) - composeRule.onNodeWithText("Session History").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Session History").assertIsDisplayed() } @Test @@ -192,7 +191,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.GoalStatistics::class.java) - composeRule.onNodeWithText("Goal History").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Goal History").assertIsDisplayed() } @Test @@ -206,7 +205,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Settings::class.java) - composeRule.onNodeWithText("Settings").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Settings").assertIsDisplayed() } @Test @@ -220,7 +219,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Donate::class.java) - composeRule.onNodeWithText("Support us!").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Support us!").assertIsDisplayed() } @Test @@ -234,7 +233,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.About::class.java) - composeRule.onNodeWithText("About").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("About").assertIsDisplayed() } @Test @@ -248,7 +247,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.MainMenuEntry.Help::class.java) - composeRule.onNodeWithText("Help").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Help").assertIsDisplayed() } @Test @@ -262,7 +261,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Backup::class.java) - composeRule.onNodeWithText("Backup and restore").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Backup and restore").assertIsDisplayed() } @Test @@ -276,7 +275,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Export::class.java) - composeRule.onNodeWithText("Export session data").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Export session data").assertIsDisplayed() } @Test @@ -290,7 +289,7 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.SettingsOption.Appearance::class.java) - composeRule.onNodeWithText("Appearance").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Appearance").assertIsDisplayed() } @Test @@ -304,6 +303,6 @@ class MusikusNavHostTest { val screen = navController.currentBackStackEntry?.toScreen() assertThat(screen).isInstanceOf(Screen.License::class.java) - composeRule.onNodeWithText("Licenses").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("Licenses").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index b10b81c12..db116a96b 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -27,7 +27,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.test.filters.SdkSuppress import app.ScreenshotRule import app.assertNodesInVerticalOrder -import app.assertWithLease import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -91,7 +90,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithContentDescription("Create").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem2").assertIsDisplayed() } private fun clickSortMode( @@ -128,37 +127,31 @@ class LibraryFolderDetailsScreenTest { } // Check if items are displayed in correct order - composeRule.assertWithLease { - assertNodesInVerticalOrder( - composeRule.onNodeWithText("TestItem2"), - composeRule.onNodeWithText("TestItem1"), - composeRule.onNodeWithText("TestItem3") - ) - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem3") + ) // Change sorting mode to name descending clickSortMode("items", "Name") // Check if items are displayed in correct order - composeRule.assertWithLease { - assertNodesInVerticalOrder( - composeRule.onNodeWithText("TestItem3"), - composeRule.onNodeWithText("TestItem2"), - composeRule.onNodeWithText("TestItem1") - ) - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem3"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1") + ) // Change sorting mode to name ascending clickSortMode("items", "Name") // Check if items are displayed in correct order - composeRule.assertWithLease { - assertNodesInVerticalOrder( - composeRule.onNodeWithText("TestItem1"), - composeRule.onNodeWithText("TestItem2"), - composeRule.onNodeWithText("TestItem3") - ) - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem3") + ) } @Test @@ -172,7 +165,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if folder name is displayed - composeRule.onNodeWithText("TestFolder2").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestFolder2").assertIsDisplayed() } @Test @@ -192,7 +185,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if edited item name is displayed - composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem2").assertIsDisplayed() // Edit item using action mode composeRule.onNodeWithText("TestItem2").performTouchInput { longClick() } @@ -207,7 +200,7 @@ class LibraryFolderDetailsScreenTest { composeRule.onNodeWithText("Edit").performClick() // Check if edited item name is displayed - composeRule.onNodeWithText("TestItem3").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem3").assertIsDisplayed() } @Test diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt index e902a7a1c..522d86f65 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt @@ -30,7 +30,6 @@ import androidx.navigation.compose.composable import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute import app.ScreenshotRule -import app.assertWithLease import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeTab import app.musikus.core.presentation.HomeTabNavType @@ -127,7 +126,7 @@ class LibraryIntegrationTest { composeRule.onNodeWithText("TestFolder").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem1").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem1").assertIsDisplayed() // Add an item from inside the folder (folder should be pre-selected) composeRule.onNodeWithContentDescription("Add item").performClick() @@ -135,6 +134,6 @@ class LibraryIntegrationTest { composeRule.onNodeWithContentDescription("Create").performClick() // Check if item is displayed - composeRule.onNodeWithText("TestItem2").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText("TestItem2").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index 929235d84..064612ec5 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -30,7 +30,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.core.app.ApplicationProvider import app.ScreenshotRule -import app.assertWithLease import app.musikus.R import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -86,8 +85,8 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Add folder or item").performClick() - composeRule.onNodeWithContentDescription("Add folder").assertWithLease { assertIsDisplayed() } - composeRule.onNodeWithContentDescription("Add item").assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithContentDescription("Add folder").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Add item").assertIsDisplayed() } @Test @@ -95,9 +94,7 @@ class LibraryScreenTest { val context = ApplicationProvider.getApplicationContext() // Check if hint is displayed initially - composeRule.onNodeWithText( - context.getString(R.string.library_screen_hint) - ).assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() // Add a folder composeRule.onNodeWithContentDescription("Add folder or item").performClick() @@ -114,9 +111,7 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Delete forever (1)").performClick() // Check if hint is displayed again - composeRule.onNodeWithText( - context.getString(R.string.library_screen_hint) - ).assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() // Add an item composeRule.onNodeWithContentDescription("Add folder or item").performClick() @@ -133,9 +128,7 @@ class LibraryScreenTest { composeRule.onNodeWithContentDescription("Delete forever (1)").performClick() // Check if hint is displayed again - composeRule.onNodeWithText( - context.getString(R.string.library_screen_hint) - ).assertWithLease { assertIsDisplayed() } + composeRule.onNodeWithText(context.getString(R.string.library_screen_hint)).assertIsDisplayed() } private fun clickSortMode( From 0db85e4844ee4e6f28ca06011646efd40dd30cbd Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 12 Jan 2025 15:33:39 +0100 Subject: [PATCH 8/9] remove debug comments --- app/src/androidTest/java/app/ScreenshotRule.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/androidTest/java/app/ScreenshotRule.kt b/app/src/androidTest/java/app/ScreenshotRule.kt index e158fde4c..e0824d2f5 100644 --- a/app/src/androidTest/java/app/ScreenshotRule.kt +++ b/app/src/androidTest/java/app/ScreenshotRule.kt @@ -9,7 +9,6 @@ package app import android.graphics.Bitmap -import android.util.Log import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.ComposeTestRule @@ -55,7 +54,6 @@ class ScreenshotRule( val screenshotName = "${description.methodName}.png" val screenshotFile = File(testClassDir, screenshotName) - Log.d("ComposeScreenshotRule", "Saving screenshot to ${screenshotFile.absolutePath}") // Capture the screenshot and save it composeTestRule.onRoot().captureToImage().asAndroidBitmap().apply { @@ -63,7 +61,5 @@ class ScreenshotRule( compress(Bitmap.CompressFormat.PNG, 100, outputStream) } } - -// runBlocking { delay(100000) } } } From 3fbccb1a8518b5fa6411f50b24b67bb83211017e Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 12 Jan 2025 18:05:58 +0100 Subject: [PATCH 9/9] disable flaky test for api level 29 --- .../app/musikus/library/presentation/LibraryIntegrationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt index 522d86f65..4e051a920 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt @@ -29,6 +29,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute +import androidx.test.filters.SdkSuppress import app.ScreenshotRule import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeTab @@ -102,6 +103,7 @@ class LibraryIntegrationTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun addItemToFolderFromInsideAndOutside() { // Add a folder composeRule.onNodeWithContentDescription("Add folder or item").performClick()