diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml new file mode 100644 index 000000000..31be1465a --- /dev/null +++ b/.github/workflows/run-test.yml @@ -0,0 +1,178 @@ +name: Tests + +on: + pull_request: + push: + branches: + - "develop" + - "main" + schedule: + # Run twice a day the sanity check, at 9:13 and 21:13. + # You ask why 13? because probably less people schedule their tasks at exactly this time, so I + # guess CI is less occupied. And 13 is a lucky number ;) + - cron: "13 9,21 * * *" + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + checks: write + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-20.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - name: Prepare Android Environment + uses: ./.github/actions/prepare-android-env + + - name: Run test + run: ./gradlew test + + - name: Upload tests results + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: | + */build/test-results/** + */build/paparazzi/failures/** + retention-days: 5 + + android-tests: + name: Run UI tests on Firebase Test Lab + runs-on: ubuntu-20.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - name: LFS-warning - Prevent large files that are not LFS tracked + uses: ppremk/lfs-warning@v3.2 + + - name: Prepare Android Environment + uses: ./.github/actions/prepare-android-env + + - name: Assemble App Debug APK and Android Instrumentation Tests + run: ./gradlew assembleDebug assembleDebugAndroidTest + + - id: auth + name: Authenticate to Google Cloud + uses: google-github-actions/auth@v1 + with: + credentials_json: ${{ secrets.SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v1 + with: + install_components: "gsutil" + + - name: Generate random directory + id: generate-dir + run: |- + echo "results_dir=$(date +%F_%T)-${RANDOM}" >> "$GITHUB_OUTPUT" + echo "bucket=test-lab-07qs3ns6c51bi-iazpthysivhkq" >> "$GITHUB_OUTPUT" + + - name: Run tests on Firebase Test Lab + run: |- + gcloud firebase test android run ".github/tests.yml:android-pixel-2" --results-dir="${{ steps.generate-dir.outputs.results_dir }}" --results-bucket="${{ steps.generate-dir.outputs.bucket }}" + + - name: Download test results from Firebase Test Lab + if: always() + run: |- + mkdir "app/build/test-results" + gsutil cp -r "gs://${{ steps.generate-dir.outputs.bucket }}/${{ steps.generate-dir.outputs.results_dir }}/Pixel2-30-en-portrait/test_result_1.xml" "app/build/test-results/results.xml" + + - name: Upload tests results + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: | + */build/test-results/** + retention-days: 5 + + test-license-headers: + name: Ensure license headers are added + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Ensure license headers are added + run: python "build-tools/check-license-headers.py" + + test-results: + name: Upload tests results + runs-on: ubuntu-20.04 + if: always() + needs: + - android-tests + - unit-tests + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: build-tools/ + + - name: Download tests results for both jobs + uses: actions/download-artifact@v3 + with: + name: test-results + + - id: auth + name: Authenticate to Google Cloud + uses: google-github-actions/auth@v1 + with: + credentials_json: ${{ secrets.SERVICE_ACCOUNT }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install Python dependencies + uses: py-actions/py-dependency-install@v4 + with: + path: "build-tools/requirements.txt" + + - name: Upload to Big Query + run: |- + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + python "build-tools/upload-junit-to-cloud.py" --url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" --final --glob "*/build/test-results/**/*.xml" + else + python "build-tools/upload-junit-to-cloud.py" --url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" --glob "*/build/test-results/**/*.xml" + fi + + - name: Test Report + uses: dorny/test-reporter@v1 + with: + name: Tests Results + path: "*/build/test-results/**/*.xml" + reporter: java-junit + fail-on-error: "false" + + - name: Include Slack Notification + if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master' + uses: ./.github/actions/slack-notification + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_GIT_REF: ${{ github.ref }} + SLACK_WORKFLOW: ${{ github.workflow }} diff --git a/app-shared-tests/.gitignore b/app-shared-tests/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app-shared-tests/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-shared-tests/build.gradle b/app-shared-tests/build.gradle new file mode 100644 index 000000000..ad6102989 --- /dev/null +++ b/app-shared-tests/build.gradle @@ -0,0 +1,98 @@ +plugins { + id 'com.android.library' + id 'kotlin-kapt' + id 'org.jetbrains.kotlin.android' + id 'com.google.dagger.hilt.android' + id 'org.jlleitschuh.gradle.ktlint' version '11.2.0' +} + +android { + namespace 'com.appunite.loudius' + compileSdk 33 + + defaultConfig { + minSdk 24 + targetSdk 33 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose true + } + packagingOptions { + resources { + excludes += 'META-INF/{AL2.0,LGPL2.1}' + excludes += 'META-INF/LICENSE.md' + excludes += 'META-INF/LICENSE-notice.md' + } + } + + composeOptions { + kotlinCompilerExtensionVersion '1.4.2' + } + + buildTypes { + release { + minifyEnabled false + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + + implementation(project(":app")) + + // Hilt + implementation "com.google.dagger:hilt-android:2.45" + kapt "com.google.dagger:hilt-compiler:2.45" + api 'com.google.dagger:hilt-android-testing:2.45' + + // assertion library + // cannot use 0.34.0 due to an existing bug + // https://github.com/robfletcher/strikt/issues/259 + api 'io.strikt:strikt-core:0.33.0' + api 'io.strikt:strikt-mockk:0.33.0' + + // Setup Junit4 and Junit5 + api(platform("org.junit:junit-bom:5.10.0")) + api("org.junit.jupiter:junit-jupiter") { + because 'allows to write and run Jupiter tests' + } + api("junit:junit:4.13.2") + runtimeOnly("org.junit.vintage:junit-vintage-engine") { + because 'allows JUnit 3 and JUnit 4 tests to run' + } + runtimeOnly("org.junit.platform:junit-platform-launcher") { + because 'allows tests to run from IDEs that bundle older version of launcher' + } + + //testing + api 'androidx.test:core-ktx:1.5.0' + api 'org.robolectric:robolectric:4.10.3' + api 'androidx.test.ext:junit-ktx:1.1.5' + + api "io.mockk:mockk:1.13.3" + + api "com.squareup.okhttp3:mockwebserver:4.10.0" + + api 'androidx.test.ext:junit:1.1.5' + api 'androidx.test.espresso:espresso-core:3.5.1' + api "androidx.compose.ui:ui-test-junit4:$compose_version" + + api 'androidx.test.espresso:espresso-intents:3.5.1' + + // Firebase instrumentation lib + api 'com.google.firebase:testlab-instr-lib:0.2' + + // ktlint + ktlintRuleset project(":custom-ktlint-rules") +} \ No newline at end of file diff --git a/app-shared-tests/src/main/AndroidManifest.xml b/app-shared-tests/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/app-shared-tests/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/androidTest/java/com/appunite/loudius/LoginScreenTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsLoginScreenTest.kt similarity index 91% rename from app/src/androidTest/java/com/appunite/loudius/LoginScreenTest.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/AbsLoginScreenTest.kt index 9cdd4869d..5888bcd5a 100644 --- a/app/src/androidTest/java/com/appunite/loudius/LoginScreenTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsLoginScreenTest.kt @@ -31,31 +31,22 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasData import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.intent.rule.IntentsRule -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.appunite.loudius.components.theme.LoudiusTheme -import com.appunite.loudius.di.GithubHelperModule import com.appunite.loudius.ui.login.GithubHelper import com.appunite.loudius.ui.login.LoginScreen import com.appunite.loudius.util.ScreenshotTestRule -import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules import io.mockk.every import io.mockk.mockk import org.hamcrest.Matchers.allOf import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -@UninstallModules(GithubHelperModule::class) -@HiltAndroidTest -class LoginScreenTest { +abstract class AbsLoginScreenTest { @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) + val hiltRule by lazy { HiltAndroidRule(this) } @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() @@ -72,8 +63,6 @@ class LoginScreenTest { hiltRule.inject() } - @BindValue - @JvmField val githubHelper: GithubHelper = mockk().apply { every { shouldAskForXiaomiIntent() } returns false } @@ -90,6 +79,8 @@ class LoginScreenTest { } composeTestRule.onNodeWithText("Log in").performClick() + + composeTestRule.waitForIdle() intended( allOf( hasAction(Intent.ACTION_VIEW), @@ -113,6 +104,7 @@ class LoginScreenTest { composeTestRule.onNodeWithText("Log in").performClick() composeTestRule.onNodeWithText("I've already granted").performClick() + composeTestRule.waitForIdle() intended( allOf( hasAction(Intent.ACTION_VIEW), @@ -136,6 +128,7 @@ class LoginScreenTest { composeTestRule.onNodeWithText("Log in").performClick() composeTestRule.onNodeWithText("Grant permission").performClick() + composeTestRule.waitForIdle() intended( allOf( hasAction("miui.intent.action.APP_PERM_EDITOR"), diff --git a/app/src/androidTest/java/com/appunite/loudius/PullRequestsScreenTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt similarity index 85% rename from app/src/androidTest/java/com/appunite/loudius/PullRequestsScreenTest.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt index 9ebdbd54f..c36ca466d 100644 --- a/app/src/androidTest/java/com/appunite/loudius/PullRequestsScreenTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt @@ -18,23 +18,19 @@ package com.appunite.loudius import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.appunite.loudius.components.theme.LoudiusTheme import com.appunite.loudius.ui.pullrequests.PullRequestsScreen import com.appunite.loudius.util.IntegrationTestRule import com.appunite.loudius.util.Register -import dagger.hilt.android.testing.HiltAndroidTest +import com.appunite.loudius.util.waitUntilLoadingDoesNotExist import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -@HiltAndroidTest -class PullRequestsScreenTest { +abstract class AbsPullRequestsScreenTest { @get:Rule - val integrationTestRule = IntegrationTestRule(this) + val integrationTestRule by lazy { IntegrationTestRule(this) } @Before fun setUp() { @@ -53,6 +49,8 @@ class PullRequestsScreenTest { } } + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("First Pull-Request title").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/appunite/loudius/ReviewersScreenTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt similarity index 86% rename from app/src/androidTest/java/com/appunite/loudius/ReviewersScreenTest.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt index 69fbc1386..2fc4f33fd 100644 --- a/app/src/androidTest/java/com/appunite/loudius/ReviewersScreenTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt @@ -19,28 +19,24 @@ package com.appunite.loudius import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.appunite.loudius.components.theme.LoudiusTheme import com.appunite.loudius.ui.reviewers.ReviewersScreen import com.appunite.loudius.util.IntegrationTestRule import com.appunite.loudius.util.Register -import dagger.hilt.android.testing.HiltAndroidTest +import com.appunite.loudius.util.waitUntilLoadingDoesNotExist import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -@HiltAndroidTest -class ReviewersScreenTest { +abstract class AbsReviewersScreenTest { @get:Rule - val integrationTestRule = IntegrationTestRule(this) + val integrationTestRule by lazy { IntegrationTestRule(this) } @Before fun setUp() { - integrationTestRule.setUp() integrationTestRule.initTests() + integrationTestRule.setUp() } @Test @@ -51,6 +47,9 @@ class ReviewersScreenTest { ReviewersScreen { } } } + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("userLogin").assertIsDisplayed() } } @@ -65,7 +64,13 @@ class ReviewersScreenTest { ReviewersScreen { } } } + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("Notify").performClick() + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule .onNodeWithText("Awesome! Your collaborator have been pinged for some serious code review action! \uD83C\uDF89") .assertIsDisplayed() @@ -80,7 +85,13 @@ class ReviewersScreenTest { ReviewersScreen { } } } + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("Notify").performClick() + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule .onNodeWithText("Uh-oh, it seems that Loudius has taken a vacation. Don't worry, we're sending a postcard to bring it back ASAP!") .assertIsDisplayed() diff --git a/app/src/androidTest/java/com/appunite/loudius/WalkThroughAppTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt similarity index 79% rename from app/src/androidTest/java/com/appunite/loudius/WalkThroughAppTest.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt index a8293ba40..682009090 100644 --- a/app/src/androidTest/java/com/appunite/loudius/WalkThroughAppTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt @@ -24,27 +24,29 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.rule.IntentsRule -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.appunite.loudius.util.IntegrationTestRule import com.appunite.loudius.util.Register -import dagger.hilt.android.testing.HiltAndroidTest +import com.appunite.loudius.util.waitUntilLoadingDoesNotExist +import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -@HiltAndroidTest -class WalkThroughAppTest { +abstract class AbsWalkThroughAppTest { @get:Rule(order = 0) - val integrationTestRule = IntegrationTestRule(this, MainActivity::class.java) + val integrationTestRule by lazy { IntegrationTestRule(this, MainActivity::class.java) } @get:Rule(order = 1) val intents = IntentsRule() + @Before + fun setUp() { + integrationTestRule.setUp() + } + @Test fun whenLoginScreenIsVisible_LoginButtonOpensGithubAuth(): Unit = with(integrationTestRule) { Register.run { @@ -56,7 +58,7 @@ class WalkThroughAppTest { comment(mockWebServer) } - Intents.intending(IntentMatchers.hasData("https://github.com/login/oauth/authorize?client_id=91131449e417c7e29912&scope=repo")) + intending(IntentMatchers.hasData("https://github.com/login/oauth/authorize?client_id=91131449e417c7e29912&scope=repo")) .respondWithFunction { Instrumentation.ActivityResult(Activity.RESULT_OK, null) } @@ -73,9 +75,16 @@ class WalkThroughAppTest { }, ) + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("First Pull-Request title").performClick() + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule.onNodeWithText("Notify").performClick() + + composeTestRule.waitUntilLoadingDoesNotExist() + composeTestRule .onNodeWithText("Awesome! Your collaborator have been pinged for some serious code review action! \uD83C\uDF89") .assertIsDisplayed() diff --git a/app/src/debug/java/com/appunite/loudius/TestActivity.kt b/app-shared-tests/src/main/java/com/appunite/loudius/TestActivity.kt similarity index 100% rename from app/src/debug/java/com/appunite/loudius/TestActivity.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/TestActivity.kt diff --git a/app/src/androidTest/java/com/appunite/loudius/util/Assertions.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/Assertions.kt similarity index 100% rename from app/src/androidTest/java/com/appunite/loudius/util/Assertions.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/Assertions.kt diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/ComposeUiTestHelpers.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/ComposeUiTestHelpers.kt new file mode 100644 index 000000000..048fd584b --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/ComposeUiTestHelpers.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package com.appunite.loudius.util + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasStateDescription +import androidx.compose.ui.test.junit4.AndroidComposeTestRule + +fun AndroidComposeTestRule<*, *>.waitUntilLoadingDoesNotExist() { + waitUntilDoesNotExist(hasStateDescription("Loading data…"), 10_000L) +} diff --git a/app/src/androidTest/java/com/appunite/loudius/util/IdlingResourceExtensions.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/IdlingResourceExtensions.kt similarity index 100% rename from app/src/androidTest/java/com/appunite/loudius/util/IdlingResourceExtensions.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/IdlingResourceExtensions.kt diff --git a/app/src/androidTest/java/com/appunite/loudius/util/IntegrationTestRule.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt similarity index 100% rename from app/src/androidTest/java/com/appunite/loudius/util/IntegrationTestRule.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt diff --git a/app/src/androidTest/java/com/appunite/loudius/util/MockWebServerRule.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/MockWebServerRule.kt similarity index 100% rename from app/src/androidTest/java/com/appunite/loudius/util/MockWebServerRule.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/MockWebServerRule.kt diff --git a/app/src/androidTest/java/com/appunite/loudius/util/Register.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/Register.kt similarity index 100% rename from app/src/androidTest/java/com/appunite/loudius/util/Register.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/Register.kt diff --git a/app/src/androidTest/java/com/appunite/loudius/util/ScreenshotTestRule.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/ScreenshotTestRule.kt similarity index 94% rename from app/src/androidTest/java/com/appunite/loudius/util/ScreenshotTestRule.kt rename to app-shared-tests/src/main/java/com/appunite/loudius/util/ScreenshotTestRule.kt index 5725e2074..7b56f36ef 100644 --- a/app/src/androidTest/java/com/appunite/loudius/util/ScreenshotTestRule.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/ScreenshotTestRule.kt @@ -31,6 +31,10 @@ import java.util.concurrent.atomic.AtomicBoolean open class ScreenshotTestRule : TestRule { override fun apply(base: Statement, description: Description): Statement { + if (!isAndroidTest) { + // Only with AndroidTest we can do screenshots, otherwise ignore this rule + return base + } return object : Statement() { @Throws(Throwable::class) override fun evaluate() { diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/TestType.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/TestType.kt new file mode 100644 index 000000000..983015194 --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/TestType.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import java.util.Locale + +/** + * Returns true if test is androidTest, returns false if this is unit test or robolectric test + */ +val isAndroidTest = + System.getProperty("java.runtime.name") + ?.lowercase(Locale.US) + ?.contains("android") + ?: false diff --git a/app/src/androidTest/java/com/appunite/loudius/util/MockWebServerRuleTest.kt b/app-shared-tests/src/test/java/com/appunite/loudius/util/MockWebServerRuleTest.kt similarity index 94% rename from app/src/androidTest/java/com/appunite/loudius/util/MockWebServerRuleTest.kt rename to app-shared-tests/src/test/java/com/appunite/loudius/util/MockWebServerRuleTest.kt index b583cc3c1..03b499d6b 100644 --- a/app/src/androidTest/java/com/appunite/loudius/util/MockWebServerRuleTest.kt +++ b/app-shared-tests/src/test/java/com/appunite/loudius/util/MockWebServerRuleTest.kt @@ -16,11 +16,12 @@ package com.appunite.loudius.util -import androidx.test.ext.junit.runners.AndroidJUnit4 +import android.util.Log import com.appunite.loudius.di.TestInterceptor import io.mockk.CapturingSlot import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody @@ -32,7 +33,7 @@ import org.hamcrest.TypeSafeMatcher import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException -import org.junit.runner.RunWith +import org.junit.rules.TestWatcher import strikt.api.expectThat import strikt.assertions.contains import strikt.assertions.first @@ -45,7 +46,6 @@ import strikt.assertions.message import strikt.assertions.single import strikt.mockk.captured -@RunWith(AndroidJUnit4::class) class MockWebServerRuleTest { @Suppress("DEPRECATION") @@ -53,6 +53,9 @@ class MockWebServerRuleTest { val expectedException: ExpectedException = ExpectedException.none() @get:Rule(order = 1) + val loggerMockRule: LoggerMockRule = LoggerMockRule() + + @get:Rule(order = 2) val mockWebServerRule: MockWebServerRule = MockWebServerRule() @Test @@ -304,3 +307,14 @@ private fun matcher(check: (T) -> Unit): Matcher = object : TypeSafeMatch } } } + +class LoggerMockRule : TestWatcher() { + override fun starting(description: org.junit.runner.Description?) { + mockkStatic(Log::class) + every { Log.v(any(), any()) } returns 0 + every { Log.v(any(), any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + } +} diff --git a/app/build.gradle b/app/build.gradle index 50a1887a5..8545d93a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -64,6 +64,18 @@ android { useLegacyPackaging true } } + unitTests { + includeAndroidResources = true + animationsDisabled = true + all { + // Disable tests on release because on release we don't have TestActivity. + // We could also filter tasks by filter {}, but right now we don't need to test + // release version. + if (it.name == 'testReleaseUnitTest') { + it.enabled = false + } + } + } } kapt { arguments { @@ -73,7 +85,7 @@ android { } dependencies { - implementation project(':components') + api project(':components') //Desugaring for use of java.time in api lower then 26 coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' @@ -104,7 +116,6 @@ dependencies { implementation "androidx.hilt:hilt-navigation-compose:1.0.0" //DI - for local unit tests - testImplementation 'com.google.dagger:hilt-android-testing:2.45' kaptTest 'com.google.dagger:hilt-compiler:2.45' // DI - For instrumented tests. @@ -129,29 +140,11 @@ dependencies { //gson implementation 'com.google.code.gson:gson:2.10.1' - // assertion library - // cannot use 0.34.0 due to an existing bug - // https://github.com/robfletcher/strikt/issues/259 - testImplementation 'io.strikt:strikt-core:0.33.0' - testImplementation 'io.strikt:strikt-mockk:0.33.0' - androidTestImplementation 'io.strikt:strikt-core:0.33.0' - androidTestImplementation 'io.strikt:strikt-mockk:0.33.0' - - //testing - testImplementation "io.mockk:mockk:1.13.3" - testImplementation("com.squareup.okhttp3:mockwebserver:4.10.0") - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") - androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.11.0") - androidTestImplementation("io.mockk:mockk-android:1.13.3") - - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' - - // Firebase instrumentation lib - androidTestImplementation 'com.google.firebase:testlab-instr-lib:0.2' + testImplementation project(":app-shared-tests") + androidTestImplementation(project(":app-shared-tests")) { + exclude group: 'org.robolectric', module: 'robolectric' + } + androidTestImplementation "io.mockk:mockk-android:1.13.3" // ktlint ktlintRuleset project(":custom-ktlint-rules") diff --git a/app/src/androidTest/java/com/appunite/loudius/IntegrationLoginScreenTest.kt b/app/src/androidTest/java/com/appunite/loudius/IntegrationLoginScreenTest.kt new file mode 100644 index 000000000..3a1f089f8 --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/IntegrationLoginScreenTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.appunite.loudius.di.GithubHelperModule +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +@UninstallModules(GithubHelperModule::class) +class IntegrationLoginScreenTest : AbsLoginScreenTest() { + + @BindValue @JvmField + val githubHelperBind = githubHelper +} diff --git a/app/src/androidTest/java/com/appunite/loudius/IntegrationPullRequestsScreenTest.kt b/app/src/androidTest/java/com/appunite/loudius/IntegrationPullRequestsScreenTest.kt new file mode 100644 index 000000000..df95c10d1 --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/IntegrationPullRequestsScreenTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class IntegrationPullRequestsScreenTest : AbsPullRequestsScreenTest() diff --git a/app/src/androidTest/java/com/appunite/loudius/IntegrationReviewersScreenTest.kt b/app/src/androidTest/java/com/appunite/loudius/IntegrationReviewersScreenTest.kt new file mode 100644 index 000000000..f23e5c261 --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/IntegrationReviewersScreenTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 owner S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class IntegrationReviewersScreenTest : AbsReviewersScreenTest() diff --git a/app/src/androidTest/java/com/appunite/loudius/IntegrationWalkThroughAppTest.kt b/app/src/androidTest/java/com/appunite/loudius/IntegrationWalkThroughAppTest.kt new file mode 100644 index 000000000..24baaf7bc --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/IntegrationWalkThroughAppTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class IntegrationWalkThroughAppTest : AbsWalkThroughAppTest() diff --git a/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsScreen.kt b/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsScreen.kt index 71eaa7eed..4130cb24c 100644 --- a/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsScreen.kt +++ b/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -64,7 +63,7 @@ fun PullRequestsScreen( navigateToReviewers: NavigateToReviewers, ) { val state = viewModel.state - val refreshing by viewModel.isRefreshing.collectAsState() + val refreshing = viewModel.isRefreshing PullRequestsScreenStateless( state = state, diff --git a/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModel.kt b/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModel.kt index 3dce46ac3..7ef83c4a5 100644 --- a/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModel.kt +++ b/app/src/main/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModel.kt @@ -24,8 +24,6 @@ import androidx.lifecycle.viewModelScope import com.appunite.loudius.domain.repository.PullRequestRepository import com.appunite.loudius.network.model.PullRequest import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -60,8 +58,8 @@ class PullRequestsViewModel @Inject constructor( var state: PullRequestState by mutableStateOf(PullRequestState()) private set - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing = _isRefreshing.asStateFlow() + var isRefreshing: Boolean by mutableStateOf(false) + private set init { fetchData() @@ -69,14 +67,14 @@ class PullRequestsViewModel @Inject constructor( fun refreshData() { viewModelScope.launch { - _isRefreshing.value = true + isRefreshing = true pullRequestsRepository.getCurrentUserPullRequests() .onSuccess { state = state.copy(data = Data.Success(it.items)) }.onFailure { state = state.copy(data = Data.Error) } - _isRefreshing.value = false + isRefreshing = false } } diff --git a/app/src/test/java/com/appunite/loudius/ActivitySetupTest.kt b/app/src/test/java/com/appunite/loudius/ActivitySetupTest.kt new file mode 100644 index 000000000..4e2f20653 --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/ActivitySetupTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.os.Build +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q], application = HiltTestApplication::class) +@DisplayName("ensure activity tests are set correctly") +@HiltAndroidTest +class ActivitySetupTest { + + @get:Rule(order = 0) + val hiltRule by lazy { HiltAndroidRule(this) } + + @Before + fun setUp() { + Assume.assumeTrue(BuildConfig.DEBUG) + hiltRule.inject() + } + + @Test + fun `ensure test activity can be started during tests`() { + ActivityScenario.launch(TestActivity::class.java) + } +} diff --git a/app/src/test/java/com/appunite/loudius/RobolectricSetupTest.kt b/app/src/test/java/com/appunite/loudius/RobolectricSetupTest.kt new file mode 100644 index 000000000..b013eb636 --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/RobolectricSetupTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import strikt.api.expectThat +import strikt.assertions.contains + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +@DisplayName("ensure robolectric tests setup is set correctly") +class RobolectricSetupTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `ensure context is mocked correctly`() { + expectThat(context.packageName).contains("com.appunite.loudius") + } +} diff --git a/app/src/test/java/com/appunite/loudius/UnitLoginScreenTest.kt b/app/src/test/java/com/appunite/loudius/UnitLoginScreenTest.kt new file mode 100644 index 000000000..bdda93651 --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/UnitLoginScreenTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.appunite.loudius.di.GithubHelperModule +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import dagger.hilt.android.testing.UninstallModules +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q], application = HiltTestApplication::class) +@HiltAndroidTest +@UninstallModules(GithubHelperModule::class) +class UnitLoginScreenTest : AbsLoginScreenTest() { + + @BindValue @JvmField + val githubHelperBind = githubHelper +} diff --git a/app/src/test/java/com/appunite/loudius/UnitPullRequestsScreenTest.kt b/app/src/test/java/com/appunite/loudius/UnitPullRequestsScreenTest.kt new file mode 100644 index 000000000..9fedb0dce --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/UnitPullRequestsScreenTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package com.appunite.loudius + +import android.os.Build +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q], application = HiltTestApplication::class) +@HiltAndroidTest +class UnitPullRequestsScreenTest : AbsPullRequestsScreenTest() diff --git a/app/src/test/java/com/appunite/loudius/UnitReviewersScreenTest.kt b/app/src/test/java/com/appunite/loudius/UnitReviewersScreenTest.kt new file mode 100644 index 000000000..78425bfd3 --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/UnitReviewersScreenTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 owner S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +@Config(sdk = [Build.VERSION_CODES.Q], application = HiltTestApplication::class) +class UnitReviewersScreenTest : AbsReviewersScreenTest() diff --git a/app/src/test/java/com/appunite/loudius/UnitWalkThroughAppTest.kt b/app/src/test/java/com/appunite/loudius/UnitWalkThroughAppTest.kt new file mode 100644 index 000000000..ce9707794 --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/UnitWalkThroughAppTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +@Config(sdk = [Build.VERSION_CODES.Q], application = HiltTestApplication::class) +class UnitWalkThroughAppTest : AbsWalkThroughAppTest() diff --git a/app/src/test/java/com/appunite/loudius/fakes/FakeUserLocalDataSource.kt b/app/src/test/java/com/appunite/loudius/fakes/FakeUserLocalDataSource.kt index 67a5a8240..7f5860893 100644 --- a/app/src/test/java/com/appunite/loudius/fakes/FakeUserLocalDataSource.kt +++ b/app/src/test/java/com/appunite/loudius/fakes/FakeUserLocalDataSource.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.appunite.loudius.fakes import com.appunite.loudius.domain.store.UserLocalDataSource diff --git a/app/src/test/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModelTest.kt b/app/src/test/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModelTest.kt index b0b20502e..919f4cfad 100644 --- a/app/src/test/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModelTest.kt +++ b/app/src/test/java/com/appunite/loudius/ui/pullrequests/PullRequestsViewModelTest.kt @@ -52,7 +52,7 @@ class PullRequestsViewModelTest { viewModel.refreshData() - expectThat(viewModel.isRefreshing.value).isTrue() + expectThat(viewModel.isRefreshing).isTrue() } @Test @@ -61,7 +61,7 @@ class PullRequestsViewModelTest { viewModel.refreshData() - expectThat(viewModel.isRefreshing.value).isFalse() + expectThat(viewModel.isRefreshing).isFalse() } @Test diff --git a/build-tools/check-license-headers.py b/build-tools/check-license-headers.py new file mode 100644 index 000000000..1663bb98f --- /dev/null +++ b/build-tools/check-license-headers.py @@ -0,0 +1,70 @@ +# Copyright 2023 AppUnite S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import fnmatch +import re +import sys +import typing +import subprocess + + +def get_tracked_files() -> typing.List[str]: + """Returns a list of all files tracked by Git.""" + + cmd = ["git", "ls-files"] + output = subprocess.check_output(cmd).decode("utf-8") + return output.splitlines() + + +def check_license(file: str) -> bool: + with open(file, "r") as f: + contents = f.read() + return "Licensed under the Apache License" in contents + + +matchers = [ + re.compile(fnmatch.translate('*.kt')), + re.compile(fnmatch.translate('*.py')), +] + + +def should_check(file: str) -> bool: + for matcher in matchers: + if matcher.match(file): + return True + return False + + +def main(): + files = get_tracked_files() + + errors: typing.List[str] = [] + for file in files: + if should_check(file): + if not check_license(file): + errors.append(f"❌ File \"{file}\" does not contain the license phrase") + else: + print(f"ℹ️ Skipping check for \"{file}\"") + + for error in errors: + print(error, file=sys.stderr) + if errors: + exit(1) + else: + print("✅ All files contain the license phrase") + exit(0) + + +if __name__ == "__main__": + main() diff --git a/build-tools/upload-junit-to-cloud.py b/build-tools/upload-junit-to-cloud.py index d1fdba10f..be211955d 100644 --- a/build-tools/upload-junit-to-cloud.py +++ b/build-tools/upload-junit-to-cloud.py @@ -1,30 +1,66 @@ +# Copyright 2023 AppUnite S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + from google.cloud import bigquery import xml.etree.ElementTree as ET import argparse +from typing import List, Optional +import glob + # Uploading JUnit test results to BigQuery -def upload(final: bool): +def upload(final: bool, dummy: bool, url: Optional[str], files: List[typing.TextIO]): client = bigquery.Client() dataset_id = 'test_results' table_id = 'my_table' - tree = ET.parse('build/test-results/results.xml') - root = tree.getroot() - timestamp = root.attrib['timestamp'] - rows = [] - for testcase in root.iter('testcase'): - success = len(testcase.findall('failure')) == 0 - row = { - 'timestamp': timestamp, - 'testcase_final': final, - 'testcase_name': testcase.attrib['name'], - 'testcase_classname': testcase.attrib['classname'], - 'testcase_time': float(testcase.attrib['time']), - 'testcase_status_success': success - } - rows.append(row) + for file in files: + tree = ET.parse(file) + root = tree.getroot() + timestamp = root.attrib['timestamp'] + test_cases = 0 + + for testcase in root.iter('testcase'): + success = len(testcase.findall('failure')) == 0 + failures = [] + for failure in testcase.findall('failure'): + failures.append({ + 'message': failure.attrib.get('message', ''), + 'type': failure.attrib.get('type', ''), + 'content': failure.text, + }) + row = { + 'timestamp': timestamp, + 'testcase_url': url, + 'testcase_final': final, + 'testcase_name': testcase.attrib['name'], + 'testcase_classname': testcase.attrib['classname'], + 'testcase_time': float(testcase.attrib['time']), + 'testcase_status_success': success, + 'testcase_failures': failures, + } + rows.append(row) + test_cases += 1 + print(f"Read \"{file.name}\" file with {test_cases} tests") + + if dummy: + print("Exiting without actions") + return dataset_ref = client.dataset(dataset_id) dataset = bigquery.Dataset(dataset_ref) @@ -35,7 +71,13 @@ def upload(final: bool): bigquery.SchemaField('testcase_name', 'STRING', mode='REQUIRED'), bigquery.SchemaField('testcase_classname', 'STRING', mode='REQUIRED'), bigquery.SchemaField('testcase_time', 'FLOAT', mode='REQUIRED'), - bigquery.SchemaField('testcase_status_success', 'BOOLEAN', mode='REQUIRED') + bigquery.SchemaField('testcase_status_success', 'BOOLEAN', mode='REQUIRED'), + bigquery.SchemaField('testcase_url', 'STRING', mode='NULLABLE'), + bigquery.SchemaField('testcase_failures', 'RECORD', mode='REPEATED', fields=[ + bigquery.SchemaField('message', 'STRING', mode='NULLABLE'), + bigquery.SchemaField('type', 'STRING', mode='NULLABLE'), + bigquery.SchemaField('content', 'STRING', mode='NULLABLE'), + ]), ] table = bigquery.Table(table_ref, schema=schema) @@ -48,6 +90,7 @@ def table_exists(table_ref): return False else: raise e + if not table_exists(table_ref): client.create_table(table) @@ -60,6 +103,24 @@ def table_exists(table_ref): parser = argparse.ArgumentParser() parser.add_argument('--final', action='store_true', help='Enable final mode') +parser.add_argument('--dummy', action='store_true', help='Do not upload data') +parser.add_argument('--url') +parser.add_argument('--glob', type=str, required=False) +parser.add_argument('file', type=argparse.FileType('r'), nargs='*') args = parser.parse_args() -upload(args.final) \ No newline at end of file +all_files: List[typing.TextIO] = args.file +if args.glob: + glob_files = glob.glob(args.glob, recursive=True) + if not glob_files: + parser.error(f"Could not find any file matching {args.glob}") + glob_open_files = [open(file, "r") for file in glob_files] + all_files.extend(glob_open_files) +if not all_files: + parser.error(f"You need to specify --glob or file to upload") +upload( + final=args.final, + dummy=args.dummy, + url=args.url, + files=args.file +) diff --git a/components/build.gradle b/components/build.gradle index ec2b1dcbe..82446ec6c 100644 --- a/components/build.gradle +++ b/components/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.jetbrains.kotlin.android' id 'app.cash.paparazzi' id 'kotlin-kapt' + id 'org.jlleitschuh.gradle.ktlint' version '11.2.0' } android { @@ -57,4 +58,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' + + // ktlint + ktlintRuleset project(":custom-ktlint-rules") } diff --git a/components/src/main/java/com/appunite/loudius/components/components/LoudiusLoadingIndicator.kt b/components/src/main/java/com/appunite/loudius/components/components/LoudiusLoadingIndicator.kt index 412cc8b24..d06a5aae9 100644 --- a/components/src/main/java/com/appunite/loudius/components/components/LoudiusLoadingIndicator.kt +++ b/components/src/main/java/com/appunite/loudius/components/components/LoudiusLoadingIndicator.kt @@ -23,6 +23,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.airbnb.lottie.compose.LottieAnimation @@ -41,8 +44,13 @@ fun LoudiusLoadingIndicator(modifier: Modifier = Modifier) { composition = composition, iterations = LottieConstants.IterateForever, ) + val loadingContentDescription = stringResource(R.string.components_loading_indicator_content_description) Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .semantics(mergeDescendants = true) { + stateDescription = loadingContentDescription + }, ) { LottieAnimation( composition = composition, diff --git a/components/src/main/java/com/appunite/loudius/components/components/utils/ReferenceDevices.kt b/components/src/main/java/com/appunite/loudius/components/components/utils/ReferenceDevices.kt index 4615af329..d8214f2ef 100644 --- a/components/src/main/java/com/appunite/loudius/components/components/utils/ReferenceDevices.kt +++ b/components/src/main/java/com/appunite/loudius/components/components/utils/ReferenceDevices.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.appunite.loudius.components.components.utils import androidx.compose.ui.tooling.preview.Devices diff --git a/components/src/main/res/values/strings.xml b/components/src/main/res/values/strings.xml index 6fc2a5bd4..440a9cf55 100644 --- a/components/src/main/res/values/strings.xml +++ b/components/src/main/res/values/strings.xml @@ -2,6 +2,9 @@ Back button + + Loading data… + Error Something went wrong… diff --git a/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/CustomRuleSetProvider.kt b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/CustomRuleSetProvider.kt index 0fc49db48..fceb5c34c 100644 --- a/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/CustomRuleSetProvider.kt +++ b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/CustomRuleSetProvider.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.appunite.loudius.rules import com.pinterest.ktlint.core.RuleProvider @@ -9,5 +25,6 @@ class CustomRuleSetProvider : RuleSetProviderV2(id = RULE_SET_ID, about = NO_ABO override fun getRuleProviders(): Set = setOf( RuleProvider { UseStriktAssertionLibrary() }, + RuleProvider { DoNotMixJunitVersions() }, ) } diff --git a/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersions.kt b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersions.kt new file mode 100644 index 000000000..55465e3ec --- /dev/null +++ b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersions.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.rules + +import com.pinterest.ktlint.core.Rule +import com.pinterest.ktlint.core.ast.ElementType +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.psi.KtImportDirective + +class DoNotMixJunitVersions : Rule("do-not-mix-junit-versions") { + val junit4Annotations = listOf( + "org.junit.Test", + "org.junit.Before", + "org.junit.After", + "org.junit.Ignore", + "org.junit.runner.RunWith", + "org.junit.runners.Parameterized", + "org.junit.runners.Theory", + ) + val junit5Annotations = listOf( + "org.junit.jupiter.api.Test", + "org.junit.jupiter.api.BeforeEach", + "org.junit.jupiter.api.AfterEach", + "org.junit.jupiter.api.Disabled", + "org.junit.jupiter.api.extension.ExtendWith", + "org.junit.jupiter.params.ParameterizedTest", + "org.junit.jupiter.params.provider.ValueSource", + ) + + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType == ElementType.IMPORT_LIST) { + val children = node.getChildren(null) + if (children.isNotEmpty()) { + val imports = children + .filter { it.elementType == ElementType.IMPORT_DIRECTIVE } + .mapNotNull { it.psi as? KtImportDirective } + .mapNotNull { it.importPath?.pathStr } + + val junit4Imports = imports.filter { junit4Annotations.contains(it) } + val junit5Imports = imports.filter { junit5Annotations.contains(it) } + + if (junit4Imports.isNotEmpty() && junit5Imports.isNotEmpty()) { + val errorMessage = "${junit4Imports.joinToString(separator = ",")} " + + "and ${junit5Imports.joinToString(separator = ",")} " + + "packages are from different JUnit versions. Don't mix Junit4 with Junit5 in a single test." + emit( + node.startOffset, + errorMessage, + false, + ) + } + } + } + } +} diff --git a/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibrary.kt b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibrary.kt index e84378dea..2ce456173 100644 --- a/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibrary.kt +++ b/custom-ktlint-rules/src/main/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibrary.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.appunite.loudius.rules import com.pinterest.ktlint.core.Rule diff --git a/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersionsTest.kt b/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersionsTest.kt new file mode 100644 index 000000000..6607d1b1e --- /dev/null +++ b/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/DoNotMixJunitVersionsTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.rules + +import com.pinterest.ktlint.test.KtLintAssertThat +import org.junit.Test + +class DoNotMixJunitVersionsTest { + + private val wrappingRuleAssertThat = + KtLintAssertThat.assertThatRule { DoNotMixJunitVersions() } + + @Test + fun `allows standard imports`() { + //language=kotlin + val code = + """ + import a.b.c + import foo.bar + """.trimIndent() + + wrappingRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `allows Junit4 imports`() { + //language=kotlin + val code = + """ + import org.junit.Test + import org.junit.Before + import org.junit.After + import org.junit.Ignore + import org.junit.runner.RunWith + import org.junit.runners.Parameterized + import org.junit.runners.Theor + """.trimIndent() + + wrappingRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `allows Junit5 imports`() { + //language=kotlin + val code = + """ + import org.junit.jupiter.api.Test + import org.junit.jupiter.api.BeforeEach + import org.junit.jupiter.api.AfterEach + import org.junit.jupiter.api.Disabled + import org.junit.jupiter.api.extension.ExtendWith + import org.junit.jupiter.params.ParameterizedTest + import org.junit.jupiter.params.provider.ValueSourc + """.trimIndent() + + wrappingRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `fail if Junit4 is mixed with Junit5`() { + //language=kotlin + val code = + """ + import org.junit.Test + import org.junit.jupiter.api.Test + """.trimIndent() + + wrappingRuleAssertThat(code) + .hasLintViolationWithoutAutoCorrect( + 1, + 1, + "org.junit.Test and org.junit.jupiter.api.Test packages are from different JUnit versions. Don't mix Junit4 with Junit5 in a single test.", + ) + } + + @Test + fun `fail if multiple Junit4 is mixed with Junit5`() { + //language=kotlin + val code = + """ + import org.junit.Test + import org.junit.Before + import org.junit.jupiter.api.Test + import org.junit.jupiter.api.BeforeEach + """.trimIndent() + + wrappingRuleAssertThat(code) + .hasLintViolationWithoutAutoCorrect( + 1, + 1, + "org.junit.Test,org.junit.Before and org.junit.jupiter.api.Test,org.junit.jupiter.api.BeforeEach packages are from different JUnit versions. Don't mix Junit4 with Junit5 in a single test.", + ) + } +} diff --git a/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibraryTest.kt b/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibraryTest.kt index ac678534b..eca4d7f13 100644 --- a/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibraryTest.kt +++ b/custom-ktlint-rules/src/test/kotlin/com/appunite/loudius/rules/UseStriktAssertionLibraryTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.appunite.loudius.rules import com.pinterest.ktlint.test.KtLintAssertThat.Companion.assertThatRule diff --git a/settings.gradle b/settings.gradle index 7e7a41fb0..8d7232340 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,3 +16,4 @@ rootProject.name = "Loudius" include ':app' include ':custom-ktlint-rules' include ':components' +include ':app-shared-tests'