Add GitHub Action for unit test coverage delta tracking #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Unit Test Coverage | |
| on: | |
| pull_request: | |
| branches: [ master ] | |
| jobs: | |
| coverage-delta: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up JDK 17 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '17' | |
| distribution: 'temurin' | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v3 | |
| - name: Run unit tests and generate coverage for PR | |
| run: | | |
| # Run tests which generate coverage exec files | |
| ./gradle/gradlew -p apps :student:testQaDebugUnitTest :teacher:testQaDebugUnitTest | |
| cd libs && ../gradle/gradlew :pandautils:testDebugUnitTest && cd .. | |
| # Copy exec files to expected locations for jacoco.gradle | |
| mkdir -p apps/student/build/jacoco | |
| mkdir -p apps/teacher/build/jacoco | |
| mkdir -p libs/pandautils/build/jacoco | |
| cp apps/student/build/outputs/unit_test_code_coverage/qaDebugUnitTest/testQaDebugUnitTest.exec apps/student/build/jacoco/testQaDebugUnitTest.exec 2>/dev/null || echo "Student exec not found" | |
| cp apps/teacher/build/outputs/unit_test_code_coverage/qaDebugUnitTest/testQaDebugUnitTest.exec apps/teacher/build/jacoco/testQaDebugUnitTest.exec 2>/dev/null || echo "Teacher exec not found" | |
| cp libs/pandautils/build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec libs/pandautils/build/jacoco/testDebugUnitTest.exec 2>/dev/null || echo "Pandautils exec not found" | |
| # Generate JaCoCo reports | |
| ./gradle/gradlew -p apps :student:jacocoReport :teacher:jacocoReport | |
| cd libs && ../gradle/gradlew :pandautils:jacocoReport | |
| continue-on-error: false | |
| - name: Save PR coverage reports | |
| run: | | |
| mkdir -p coverage-reports/pr | |
| cp apps/student/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/pr/student.csv 2>/dev/null || echo "Student coverage not found" | |
| cp apps/teacher/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/pr/teacher.csv 2>/dev/null || echo "Teacher coverage not found" | |
| cp libs/pandautils/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/pr/pandautils.csv 2>/dev/null || echo "Pandautils coverage not found" | |
| - name: Checkout master branch | |
| run: | | |
| git fetch origin master | |
| git checkout master | |
| - name: Clean build directories | |
| run: | | |
| ./gradle/gradlew -p apps clean | |
| cd libs && ../gradle/gradlew clean | |
| - name: Run unit tests and generate coverage for master | |
| run: | | |
| # Run tests which generate coverage exec files | |
| ./gradle/gradlew -p apps :student:testQaDebugUnitTest :teacher:testQaDebugUnitTest | |
| cd libs && ../gradle/gradlew :pandautils:testDebugUnitTest && cd .. | |
| # Copy exec files to expected locations for jacoco.gradle | |
| mkdir -p apps/student/build/jacoco | |
| mkdir -p apps/teacher/build/jacoco | |
| mkdir -p libs/pandautils/build/jacoco | |
| cp apps/student/build/outputs/unit_test_code_coverage/qaDebugUnitTest/testQaDebugUnitTest.exec apps/student/build/jacoco/testQaDebugUnitTest.exec 2>/dev/null || echo "Student exec not found" | |
| cp apps/teacher/build/outputs/unit_test_code_coverage/qaDebugUnitTest/testQaDebugUnitTest.exec apps/teacher/build/jacoco/testQaDebugUnitTest.exec 2>/dev/null || echo "Teacher exec not found" | |
| cp libs/pandautils/build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec libs/pandautils/build/jacoco/testDebugUnitTest.exec 2>/dev/null || echo "Pandautils exec not found" | |
| # Generate JaCoCo reports | |
| ./gradle/gradlew -p apps :student:jacocoReport :teacher:jacocoReport | |
| cd libs && ../gradle/gradlew :pandautils:jacocoReport | |
| continue-on-error: true | |
| - name: Save master coverage reports | |
| run: | | |
| mkdir -p coverage-reports/master | |
| cp apps/student/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/master/student.csv 2>/dev/null || echo "Student coverage not found" | |
| cp apps/teacher/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/master/teacher.csv 2>/dev/null || echo "Teacher coverage not found" | |
| cp libs/pandautils/build/reports/jacoco/jacocoReport/jacocoReport.csv coverage-reports/master/pandautils.csv 2>/dev/null || echo "Pandautils coverage not found" | |
| - name: Calculate coverage delta | |
| id: coverage | |
| run: | | |
| python3 << 'EOF' | tee coverage-report.txt | |
| import csv | |
| import os | |
| from pathlib import Path | |
| def parse_jacoco_csv(file_path): | |
| """Parse JaCoCo CSV and return instruction coverage percentage""" | |
| if not Path(file_path).exists(): | |
| return None | |
| total_missed = 0 | |
| total_covered = 0 | |
| with open(file_path, 'r') as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| total_missed += int(row['INSTRUCTION_MISSED']) | |
| total_covered += int(row['INSTRUCTION_COVERED']) | |
| if total_missed + total_covered == 0: | |
| return 0.0 | |
| return (total_covered / (total_missed + total_covered)) * 100 | |
| modules = ['student', 'teacher', 'pandautils'] | |
| results = [] | |
| print("## 📊 Code Coverage Report\n") | |
| overall_pr_coverage = [] | |
| overall_master_coverage = [] | |
| for module in modules: | |
| pr_file = f'coverage-reports/pr/{module}.csv' | |
| master_file = f'coverage-reports/master/{module}.csv' | |
| pr_cov = parse_jacoco_csv(pr_file) | |
| master_cov = parse_jacoco_csv(master_file) | |
| if pr_cov is not None and master_cov is not None: | |
| delta = pr_cov - master_cov | |
| emoji = '✅' if delta >= 0 else '⚠️' | |
| sign = '+' if delta >= 0 else '' | |
| print(f"### {emoji} {module.capitalize()}") | |
| print(f"- **PR Coverage:** {pr_cov:.2f}%") | |
| print(f"- **Master Coverage:** {master_cov:.2f}%") | |
| print(f"- **Delta:** {sign}{delta:.2f}%\n") | |
| overall_pr_coverage.append(pr_cov) | |
| overall_master_coverage.append(master_cov) | |
| elif pr_cov is not None: | |
| print(f"### ℹ️ {module.capitalize()}") | |
| print(f"- **PR Coverage:** {pr_cov:.2f}%") | |
| print(f"- **Master Coverage:** N/A\n") | |
| else: | |
| print(f"### ⚠️ {module.capitalize()}") | |
| print(f"- Coverage data not available\n") | |
| if overall_pr_coverage and overall_master_coverage: | |
| avg_pr = sum(overall_pr_coverage) / len(overall_pr_coverage) | |
| avg_master = sum(overall_master_coverage) / len(overall_master_coverage) | |
| overall_delta = avg_pr - avg_master | |
| print("---") | |
| print(f"### 📈 Overall Average") | |
| print(f"- **PR Coverage:** {avg_pr:.2f}%") | |
| print(f"- **Master Coverage:** {avg_master:.2f}%") | |
| sign = '+' if overall_delta >= 0 else '' | |
| print(f"- **Delta:** {sign}{overall_delta:.2f}%") | |
| # Set output for potential failure condition | |
| with open(os.environ['GITHUB_OUTPUT'], 'a') as f: | |
| f.write(f"delta={overall_delta}\n") | |
| EOF | |
| - name: Comment PR (sticky) | |
| if: github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const output = fs.readFileSync('coverage-report.txt', 'utf8'); | |
| const marker = '<!-- unit-test-coverage-comment -->'; | |
| const body = marker + '\n' + output; | |
| // Find existing coverage comment | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const existingComment = comments.find(comment => | |
| comment.body.includes(marker) | |
| ); | |
| if (existingComment) { | |
| // Update existing comment | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existingComment.id, | |
| body: body | |
| }); | |
| } else { | |
| // Create new comment | |
| await github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: body | |
| }); | |
| } | |
| # Optional: Fail if coverage decreases by more than 1% | |
| # - name: Check coverage threshold | |
| # if: steps.coverage.outputs.delta < -1.0 | |
| # run: | | |
| # echo "Coverage decreased by more than 1%" | |
| # exit 1 |