Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/scripts/post_test_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Parse JUnit XML test results and print failed tests to stdout.

Prints nothing when all tests pass. Prints only failed tests when failures exist,
so the caller can decide whether to post a PR comment based on whether stdout is non-empty.
"""
import glob
import sys
import xml.etree.ElementTree as ET


def main() -> None:
title = sys.argv[1]
patterns = sys.argv[2:]

paths = []
for pattern in patterns:
paths.extend(sorted(glob.glob(pattern, recursive=True)))

if not paths:
return

failed_by_class: dict[str, list[str]] = {}
total = failed = 0

for path in paths:
root = ET.parse(path).getroot()
for case in root.findall("testcase"):
total += 1
if case.find("failure") is not None or case.find("error") is not None:
failed += 1
classname = case.get("classname", root.get("name", path))
failed_by_class.setdefault(classname, []).append(
f"- ❌ {case.get('name')}"
)

if failed == 0:
return

print(f"## ❌ {title} — {total}개 중 {failed}개 실패\n")
for classname in sorted(failed_by_class):
print(f"### {classname}")
print("\n".join(failed_by_class[classname]))
print()


if __name__ == "__main__":
main()
168 changes: 163 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ on:
branches: [ develop ]

pull_request:
branches: [ develop ]


permissions:
contents: read
actions: read
checks: write
pull-requests: write

defaults:
run:
shell: bash
Expand All @@ -20,10 +25,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: set up JDK 17
- name: set up JDK 21
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: temurin
cache: gradle

Expand Down Expand Up @@ -99,5 +104,158 @@ jobs:
- name: Change gradlew permissions
run: chmod +x ./gradlew

- name: Build
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace

- name: Comment unit test failures on PR
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
MARKER="<!-- unit-test-report -->"
REPORT="$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')"
existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
if [ -n "$REPORT" ]; then
BODY="$MARKER"$'\n'"$REPORT"
if [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY"
fi
elif [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE
fi

- name: Build
run: ./gradlew assembleDebug --stacktrace

android-test:
name: Run Compose UI Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: set up JDK 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: temurin
cache: gradle

- name: Write compile time google-services.json file
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $GOOGLE_SERVICES_JSON > app/google-services.json

- name: Touch local properties
run: touch local.properties

- name: Access NAVER_CLIENT_ID
env:
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
run: echo "NAVER_CLIENT_ID = \"$NAVER_CLIENT_ID\"" >> local.properties

- name: Access RUNNECT_NODE_URL
env:
RUNNECT_NODE_URL: ${{ secrets.RUNNECT_NODE_URL }}
run: echo "RUNNECT_NODE_URL=\"$RUNNECT_NODE_URL\"" >> local.properties

- name: Access RUNNECT_DEV_URL
env:
RUNNECT_DEV_URL: ${{ secrets.RUNNECT_DEV_URL }}
run: echo "RUNNECT_DEV_URL=\"$RUNNECT_DEV_URL\"" >> local.properties

- name: Access RUNNECT_PROD_URL
env:
RUNNECT_PROD_URL: ${{ secrets.RUNNECT_PROD_URL }}
run: echo "RUNNECT_PROD_URL=\"$RUNNECT_PROD_URL\"" >> local.properties

- name: Access TMAP_BASE_URL
env:
TMAP_BASE_URL: ${{ secrets.TMAP_BASE_URL }}
run: echo "TMAP_BASE_URL=\"$TMAP_BASE_URL\"" >> local.properties

- name: Access TMAP_API_KEY
env:
TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }}
run: echo "TMAP_API_KEY=\"$TMAP_API_KEY\"" >> local.properties

- name: Access GOOGLE_CLIENT_ID
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties

- name: Access REMOTE_KEY_APP_VERSION
env:
REMOTE_KEY_APP_VERSION: ${{ secrets.REMOTE_KEY_APP_VERSION }}
run: echo "REMOTE_KEY_APP_VERSION=\"$REMOTE_KEY_APP_VERSION\"" >> local.properties

- name: Access KAKAO_CHANNEL_ID
env:
KAKAO_CHANNEL_ID: ${{ secrets.KAKAO_CHANNEL_ID }}
run: echo "KAKAO_CHANNEL_ID=\"$KAKAO_CHANNEL_ID\"" >> local.properties

- name: Access REMOTE_KEY_FORCE_UPDATE
env:
REMOTE_KEY_FORCE_UPDATE: ${{ secrets.REMOTE_KEY_FORCE_UPDATE }}
run: echo "REMOTE_KEY_FORCE_UPDATE=\"$REMOTE_KEY_FORCE_UPDATE\"" >> local.properties

- name: Add kakao_strings.xml
env:
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
KAKAO_REDIRECTION_SCHEME: ${{ secrets.KAKAO_REDIRECTION_SCHEME }}
run: |
echo '<?xml version="1.0" encoding="utf-8"?>' > app/src/main/res/values/kakao_strings.xml
echo '<resources>' >> app/src/main/res/values/kakao_strings.xml
echo ' <string name="kakao_native_app_key">$KAKAO_NATIVE_APP_KEY</string>' >> app/src/main/res/values/kakao_strings.xml
echo ' <string name="kakao_redirection_scheme">$KAKAO_REDIRECTION_SCHEME</string>' >> app/src/main/res/values/kakao_strings.xml
echo '</resources>' >> app/src/main/res/values/kakao_strings.xml

- name: Change gradlew permissions
run: chmod +x ./gradlew

- 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: Run Compose UI tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
script: ./gradlew connectedDebugAndroidTest --stacktrace

- name: Comment Compose UI test failures on PR
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
MARKER="<!-- compose-ui-test-report -->"
REPORT="$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')"
existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
if [ -n "$REPORT" ]; then
BODY="$MARKER"$'\n'"$REPORT"
if [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY"
fi
elif [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE
fi

- name: Upload instrumented test results
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-results
path: app/build/outputs/androidTest-results/connected/**
7 changes: 7 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ android {
}
}

tasks.withType(Test).configureEach {
testLogging {
events "passed", "skipped", "failed"
}
}

tasks.register('renameReleaseBundle') {
def vName = libs.versions.versionName.get()
def bundlePath = layout.buildDirectory.dir("outputs/bundle/release")
Expand Down Expand Up @@ -191,6 +197,7 @@ dependencies {
testImplementation libs.mockk
testImplementation libs.turbine
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.core.testing
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.runnect", appContext.packageName)
assertEquals("com.runnect.runnect", appContext.packageName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.runnect.runnect.presentation.storage

import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.runnect.runnect.domain.entity.MyScrapCourse
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class StorageScrapScreenTest {

@get:Rule
val composeTestRule = createComposeRule()

private fun course(title: String) = MyScrapCourse(
courseId = 1,
id = 1,
publicCourseId = 10,
image = null,
city = "서울",
region = "강남",
title = title
)

@Test
fun `카드탭_시_onScrapItemClick이_해당_코스로_호출된다`() {
val testCourse = course(title = "테스트 코스")
var clicked: MyScrapCourse? = null

composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = listOf(testCourse)),
onRefresh = {},
onScrapItemClick = { clicked = it },
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("테스트 코스").performClick()

assertEquals(testCourse, clicked)
}

@Test
fun `로딩_중에는_스크랩이_없어도_빈_화면이_보이지_않는다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = emptyList(), isLoading = true),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true)
.assertDoesNotExist()
}

@Test
fun `로딩이_끝나고_스크랩이_없으면_빈_화면이_보인다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = emptyList(), isLoading = false),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true)
.assertIsDisplayed()
}

@Test
fun `isLoading이_true이면_로딩_인디케이터가_보인다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = listOf(course("코스")), isLoading = true),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.assertIsDisplayed()
}
}
Loading
Loading