diff --git a/.github/workflows/build_and_health_check.yml b/.github/workflows/build_and_health_check.yml index 8ef1c49..9991fe0 100644 --- a/.github/workflows/build_and_health_check.yml +++ b/.github/workflows/build_and_health_check.yml @@ -24,15 +24,22 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle + + - name: setup gradle + uses: gradle/actions/setup-gradle@v5 - name: configure 1password + id: op-config uses: 1password/load-secrets-action/configure@v2 + continue-on-error: true with: service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - name: load secrets + id: op-load uses: 1password/load-secrets-action@v2 + continue-on-error: true + if: steps.op-config.outcome == 'success' with: export-env: true unset-previous: false @@ -40,7 +47,12 @@ jobs: ENV_FILE: op://instance/.env/.env - name: create env file - run: echo "${{ env.ENV_FILE }}" > .env + run: | + if [ -n "${{ env.ENV_FILE }}" ]; then + echo "${{ env.ENV_FILE }}" > .env + else + echo "# Empty .env file for tests" > .env + fi - name: run tests run: ./gradlew test @@ -66,7 +78,9 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle + + - name: setup gradle + uses: gradle/actions/setup-gradle@v5 - name: configure 1password uses: 1password/load-secrets-action/configure@v2 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..148490e --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,96 @@ +name: Code Coverage + +on: + pull_request: + branches: + - '**' + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v4 + + - name: setup java 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: setup gradle + uses: gradle/actions/setup-gradle@v5 + + - name: configure 1password + id: op-config + uses: 1password/load-secrets-action/configure@v2 + continue-on-error: true + with: + service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + + - name: load secrets + id: op-load + uses: 1password/load-secrets-action@v2 + continue-on-error: true + if: steps.op-config.outcome == 'success' + with: + export-env: true + unset-previous: false + env: + ENV_FILE: op://instance/.env/.env + + - name: create env file + run: | + if [ -n "${{ env.ENV_FILE }}" ]; then + echo "${{ env.ENV_FILE }}" > .env + else + echo "# Empty .env file for tests" > .env + fi + + - name: run tests with coverage + run: ./gradlew test jacocoRootReport checkOverallCoverageTarget + + - name: upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: | + **/build/reports/jacoco/ + build/reports/jacoco/jacocoRootReport/ + + - name: add coverage pr comment + uses: madrapps/jacoco-report@v1.6.1 + if: github.event_name == 'pull_request' + with: + paths: | + ${{ github.workspace }}/build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 75 + min-coverage-changed-files: 70 + title: 'Code Coverage Report' + update-comment: true + + - name: generate coverage badge + uses: cicirello/jacoco-badge-generator@v2 + if: github.ref == 'refs/heads/main' + with: + jacoco-csv-file: build/reports/jacoco/jacocoRootReport/jacocoRootReport.csv + badges-directory: .github/badges + generate-branches-badge: true + generate-summary: true + + - name: commit and push badge + if: github.ref == 'refs/heads/main' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add .github/badges + git diff --quiet && git diff --staged --quiet || (git commit -m "docs: update coverage badge [skip ci]" && git push) diff --git a/CLAUDE.md b/CLAUDE.md index a53f68e..2f1390a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,34 @@ docker-compose up -d ./gradlew test --tests "*.NormalProfanityFilterTest" ``` +### 코드 커버리지 +```bash +# 전체 프로젝트 커버리지 리포트 생성 (모듈별 + 통합) +./gradlew test jacocoRootReport checkOverallCoverageTarget + +# 특정 모듈 커버리지 리포트 생성 +./gradlew :profanity-api:jacocoTestReport +./gradlew :profanity-domain:jacocoTestReport + +# 모듈별 커버리지 목표치 확인 +./gradlew :profanity-api:checkCoverageTargets +./gradlew :profanity-domain:checkCoverageTargets + +# 커버리지 리포트 위치 +# - 통합 리포트: build/reports/jacoco/jacocoRootReport/html/index.html +# - 모듈별 리포트: {module}/build/reports/jacoco/test/html/index.html +``` + +#### 커버리지 목표치 +- **profanity-api**: 70% (Line), 65% (Branch), 70% (Instruction) +- **profanity-domain**: 80% (Line), 75% (Branch), 80% (Instruction) +- **profanity-shared**: 60% (Line), 55% (Branch), 60% (Instruction) +- **profanity-storage:rdb**: 70% (Line), 65% (Branch), 70% (Instruction) +- **profanity-storage:redis**: 70% (Line), 65% (Branch), 70% (Instruction) +- **전체 프로젝트**: 75% (Line), 70% (Branch), 75% (Instruction) + +*참고: 목표치는 현재 빌드 실패를 유발하지 않으며, 달성 여부만 체크합니다.* + ### 의존성 관리 ```bash # 의존성 확인 diff --git a/README.md b/README.md index 4f1e8b7..ca086c4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ # 한국어 비속어 필터 API 서비스 +[![Coverage](.github/badges/jacoco.svg)](https://github.com/Whale0928/profanity-filter-api/actions/workflows/coverage.yml) +[![Branches](.github/badges/branches.svg)](https://github.com/Whale0928/profanity-filter-api/actions/workflows/coverage.yml) + > API 인증 키 발급 후 사용 가능합니다. 문서 링크를 참조해 주세요 > > [API DOCS](https://whale0928.github.io/profanity-filter-api/) diff --git a/build.gradle b/build.gradle index f42f0b2..cf6a1f3 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' } configurations { @@ -28,6 +29,7 @@ subprojects { apply plugin: 'java-library' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' dependencies { // default spring boot dependencies @@ -54,6 +56,11 @@ subprojects { jar.enabled = true tasks.named('test') { useJUnitPlatform() } + // JaCoCo configuration + jacoco { + toolVersion = "0.8.11" + } + test { outputs.upToDateWhen { false } useJUnitPlatform() @@ -65,11 +72,198 @@ subprojects { showCauses = true showStackTraces = true } + finalizedBy jacocoTestReport + } + + jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + csv.required = false + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/config/**', + '**/entity/**', + '**/dto/**', + '**/*Application.class' + ]) + })) + } + } + + // Define coverage targets for each module + ext { + coverageTargets = [ + 'profanity-api': [ + line: 0.70, + branch: 0.65, + instruction: 0.70 + ], + 'profanity-domain': [ + line: 0.80, + branch: 0.75, + instruction: 0.80 + ], + 'profanity-shared': [ + line: 0.60, + branch: 0.55, + instruction: 0.60 + ], + 'rdb': [ + line: 0.70, + branch: 0.65, + instruction: 0.70 + ], + 'redis': [ + line: 0.70, + branch: 0.65, + instruction: 0.70 + ] + ] + } + + // Task to check coverage targets (without enforcing) + tasks.register('checkCoverageTargets') { + dependsOn jacocoTestReport + doLast { + def reportFile = file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml") + if (reportFile.exists()) { + try { + def parser = new XmlParser() + parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false) + parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + def report = parser.parse(reportFile) + + def moduleName = project.name + def targets = project.coverageTargets[moduleName] + + if (targets != null) { + def counters = report.counter + def lineCoverage = getCoverage(counters, 'LINE') + def branchCoverage = getCoverage(counters, 'BRANCH') + def instructionCoverage = getCoverage(counters, 'INSTRUCTION') + + println "\n==========================================" + println "📊 Coverage Report for ${moduleName}" + println "==========================================" + println "Line Coverage: ${String.format('%.2f%%', lineCoverage * 100)} (Target: ${String.format('%.0f%%', targets.line * 100)}) ${lineCoverage >= targets.line ? '✅' : '❌'}" + println "Branch Coverage: ${String.format('%.2f%%', branchCoverage * 100)} (Target: ${String.format('%.0f%%', targets.branch * 100)}) ${branchCoverage >= targets.branch ? '✅' : '❌'}" + println "Instruction Coverage: ${String.format('%.2f%%', instructionCoverage * 100)} (Target: ${String.format('%.0f%%', targets.instruction * 100)}) ${instructionCoverage >= targets.instruction ? '✅' : '❌'}" + println "==========================================\n" + } + } catch (Exception e) { + println "⚠️ Warning: Could not parse coverage report for ${project.name}: ${e.message}" + } + } else { + println "⚠️ Warning: Coverage report not found for ${project.name}" + } + } } } +// Helper function to calculate coverage +def getCoverage(counters, type) { + def counter = counters.find { it.'@type' == type } + if (counter != null) { + def missed = counter.'@missed'.toInteger() + def covered = counter.'@covered'.toInteger() + def total = missed + covered + return total > 0 ? covered / total : 0.0 + } + return 0.0 +} + // Root 프로젝트의 test task를 수정 test.dependsOn subprojects.test +// Unified coverage report for all modules +tasks.register('jacocoRootReport', JacocoReport) { + description = 'Generates an aggregate report from all subprojects' + group = 'Reporting' + + reports { + xml.required = true + html.required = true + csv.required = true + } + + doLast { + println "\n==========================================" + println "📊 Unified Coverage Report Generated" + println "==========================================" + println "HTML Report: ${reports.html.outputLocation.get()}/index.html" + println "XML Report: ${reports.xml.outputLocation.get()}" + println "==========================================\n" + } +} + +project.afterEvaluate { + tasks.named('jacocoRootReport').configure { + // Explicit dependencies on all test and jacocoTestReport tasks + dependsOn test // Root project test task + dependsOn subprojects.test + dependsOn subprojects.jacocoTestReport + + additionalSourceDirs.setFrom files(subprojects.sourceSets.main.allSource.srcDirs) + sourceDirectories.setFrom files(subprojects.sourceSets.main.allSource.srcDirs) + + def allClassDirs = files(subprojects.sourceSets.main.output).files.collect { + fileTree(dir: it, exclude: [ + '**/config/**', + '**/entity/**', + '**/dto/**', + '**/*Application.class' + ]) + } + classDirectories.setFrom files(allClassDirs) + + executionData.setFrom project.fileTree(dir: '.', include: '**/build/jacoco/test.exec') + } +} + +// Task to check overall coverage target +tasks.register('checkOverallCoverageTarget') { + dependsOn jacocoRootReport + doLast { + def reportFile = file("${buildDir}/reports/jacoco/jacocoRootReport/jacocoRootReport.xml") + if (reportFile.exists()) { + try { + def parser = new XmlParser() + parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false) + parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + def report = parser.parse(reportFile) + + def counters = report.counter + def lineCoverage = getCoverage(counters, 'LINE') + def branchCoverage = getCoverage(counters, 'BRANCH') + def instructionCoverage = getCoverage(counters, 'INSTRUCTION') + + // Overall project targets + def overallTargets = [ + line: 0.75, + branch: 0.70, + instruction: 0.75 + ] + + println "\n==========================================" + println "🎯 Overall Project Coverage Report" + println "==========================================" + println "Line Coverage: ${String.format('%.2f%%', lineCoverage * 100)} (Target: ${String.format('%.0f%%', overallTargets.line * 100)}) ${lineCoverage >= overallTargets.line ? '✅' : '❌'}" + println "Branch Coverage: ${String.format('%.2f%%', branchCoverage * 100)} (Target: ${String.format('%.0f%%', overallTargets.branch * 100)}) ${branchCoverage >= overallTargets.branch ? '✅' : '❌'}" + println "Instruction Coverage: ${String.format('%.2f%%', instructionCoverage * 100)} (Target: ${String.format('%.0f%%', overallTargets.instruction * 100)}) ${instructionCoverage >= overallTargets.instruction ? '✅' : '❌'}" + println "==========================================\n" + } catch (Exception e) { + println "⚠️ Warning: Could not parse overall coverage report: ${e.message}" + } + } else { + println "⚠️ Warning: Overall coverage report not found" + } + } +} + bootJar.enabled = false jar.enabled = true