[Functional Programming] 부록: 테스트 커버리지와 GitHub Workflows

2025. 4. 20. 08:00프로그래밍 공부/javascript & typescript

반응형
SMALL

자동으로 커버리지를 측정하고, PR마다 뱃지를 생성해주며, 커버리지 변화량을 댓글로 알려주는 개발 환경을 만들자

지연 평가(lazy evaluation)와 이터러블 기반 연산자의 안정성을 위하여 테스트 코드를 작성합니다. 테스트 코드와 더불어 테스트 커버리지를 기록하기 위해 github workflows를 작성합니다.

  • Jest기반 테스트 코드 작성
  • coverage-final.json을 파싱하여 SVG 뱃지 생성
  • 변경된 뱃지가 있을 경우 GitHub에 자동 커밋
  • PR에 커버리지 변화량을 자동으로 댓글로 남기기

1. jest 세팅

테스트 프레임워크로 Jest를 사용합니다. 타입스크립트 환경에서는 ts-jest 프리셋과 타입 정보를 함께 설정합니다.

// jest.config.ts
import type { Config } from "jest";

const config: Config = {
  preset: "ts-jest",
  testEnvironment: "node",
  testMatch: ["**/__tests__/**/*.(test|spce).[jt]s", "**/?(*.)+(spec|test).[jt]s"],
};

export default config;
//package.json
// ...
"scripts": {
  "test": "jest",
  "test:watch": "jest --watch",
  "test:coverage": "jest --coverage",
},
// ...

2. coverage-final.json → SVG 뱃지 생성

커버리지 요약 파일(coverage-final.json)을 파싱하여 statements, branches, functions, lines 각각에 대한 뱃지를 만들어주는 Node.js 스크립트입니다.

// .scripts/coverage-badge.js
import fs from "fs";
import path, { dirname } from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const COVERAGE_PATH = path.resolve(__dirname, "../coverage/coverage-final.json");
const OUTPUT_PATH = path.resolve(__dirname, "../badges/coverage.svg");

function loadCoverageData(filePath) {
  if (!fs.existsSync(filePath)) {
    console.error("❌ coverage-final.json 파일을 찾을 수 없습니다.");
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function getTotalCoverage(data) {
  let statements = { covered: 0, total: 0 };
  let branches = { covered: 0, total: 0 };
  let functions = { covered: 0, total: 0 };
  let lines = { covered: 0, total: 0 };

  for (const file of Object.values(data)) {
    for (const [k, v] of Object.entries(file.s || {})) {
      statements.covered += v > 0 ? 1 : 0;
      statements.total++;
    }
    for (const [k, v] of Object.entries(file.b || {})) {
      branches.covered += v.reduce((acc, b) => acc + (b > 0 ? 1 : 0), 0);
      branches.total += v.length;
    }
    for (const [k, v] of Object.entries(file.f || {})) {
      functions.covered += v > 0 ? 1 : 0;
      functions.total++;
    }
    // lines는 별도로 없으면 statements와 동일하게 처리
    lines = statements;
  }

  const toPercent = ({ covered, total }) => (total === 0 ? 100 : Math.round((covered / total) * 100));

  return {
    statements: toPercent(statements),
    branches: toPercent(branches),
    functions: toPercent(functions),
    lines: toPercent(lines),
  };
}

function getColor(percent) {
  if (percent >= 90) return "#4c1";
  if (percent >= 75) return "#97CA00";
  if (percent >= 60) return "#dfb317";
  if (percent >= 40) return "#fe7d37";
  return "#e05d44";
}

function generateSvg(label, value, color) {
  return `
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="20">
  <linearGradient id="b" x2="0" y2="100%">
    <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
    <stop offset="1" stop-opacity=".1"/>
  </linearGradient>
  <mask id="a">
    <rect width="140" height="20" rx="3" fill="#fff"/>
  </mask>
  <g mask="url(#a)">
    <rect width="75" height="20" fill="#555"/>
    <rect x="75" width="65" height="20" fill="${color}"/>
    <rect width="140" height="20" fill="url(#b)"/>
  </g>
  <g fill="#fff" text-anchor="middle" font-family="Verdana" font-size="11">
    <text x="37.5" y="14">${label}</text>
    <text x="105.5" y="14">${value}%</text>
  </g>
</svg>
`.trim();
}

function saveSvg(svg, filePath) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, svg, "utf8");
  console.log(`✅ Coverage SVG badge saved to: ${filePath}`);
}

// 실행
const coverage = loadCoverageData(COVERAGE_PATH);
const summary = getTotalCoverage(coverage);

let total = 0;

for (const [key, value] of Object.entries(summary)) {
  console.log(`\nCoverage ${key}: ${value}%`);
  total += value;

  const percent = summary[key];
  const color = getColor(percent);
  const svg = generateSvg(key, percent, color);
  const outputPath = path.resolve(__dirname, `../badges/${key}.svg`);
  saveSvg(svg, outputPath);
}

const average = Math.round(total / Object.keys(summary).length);

console.log(`\nTotal Coverage: ${average}%`);

const color = getColor(average);
const svg = generateSvg("coverage", average, color);
const outputPath = path.resolve(__dirname, OUTPUT_PATH);
saveSvg(svg, outputPath);

3. 커버리지 요약을 출력하는 유틸 스크립트

GitHub Action 내부에서 사용할 커버리지 퍼센트를 계산해주는 간단한 CLI 유틸입니다.
이 값은 steps.pr_coverage.outputs.coverage로 넘겨져서 댓글 작성에 사용됩니다.

// .scripts/get-summary.js
import fs from "fs";
import path, { dirname } from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const file = path.resolve(__dirname, "../coverage/coverage-final.json");
const data = JSON.parse(fs.readFileSync(file, "utf-8"));

let covered = 0;
let total = 0;

for (const file of Object.values(data)) {
  const s = file.s || {};
  for (const hit of Object.values(s)) {
    total++;
    if (hit > 0) covered++;
  }
}

const percentage = total === 0 ? 100 : Math.round((covered / total) * 10000) / 100;
console.log(percentage);

4. github action

  1. pnpm install → test:coverage 실행
  2. SVG 뱃지 생성
  3. 커밋 및 푸시 (main 브랜치일 경우)
  4. PR이라면 커버리지 변화량 댓글 작성

📤 Save coverage from PR
get-summary.js를 통해 PR 브랜치의 커버리지 퍼센트를 계산합니다.

📥 Checkout main to get base coverage
main 브랜치의 커버리지 파일을 가져옵니다. (이때 main 브랜치의 최신 커버리지 결과가 Git에 커밋되어 있어야 합니다.)

💬 Post coverage change to PR
두 커버리지를 비교하여 증가 / 감소 / 동일 여부를 PR에 자동으로 댓글로 남깁니다.

🟢 Test coverage increased: 88.23% (+3.21%)
🔴 Test coverage decreased: 85.10% (-1.43%)
🟡 Test coverage unchanged: 86.00%
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: ✅ Checkout
        uses: actions/checkout@v3

      - name: Enable Corepack and Set Pnpm Version
        run: |
          npm install -g corepack@latest
          corepack enable

      - name: Setup Node.js 20.x for pnpm
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"
          cache: pnpm

      - name: Install dependencies
        run: pnpm install

      - name: 🧪 Run tests
        run: pnpm run test

      - name: 📊 Generate test coverage
        run: pnpm run ci

      - name: 📤 Save coverage from PR
        id: pr_coverage
        run: |
          COVERAGE=$(node .scripts/get-summary.js)
          echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT

      - name: 📥 Checkout main to get base coverage
        run: |
          git fetch origin main
          git checkout origin/main -- coverage/coverage-final.json

      - name: 📤 Save coverage from main
        id: base_coverage
        run: |
          COVERAGE=$(node .scripts/get-summary.js)
          echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT

      - name: 💬 Post coverage change to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const prCoverage = parseFloat('${{ steps.pr_coverage.outputs.coverage }}');
            const baseCoverage = parseFloat('${{ steps.base_coverage.outputs.coverage }}');
            const delta = (prCoverage - baseCoverage).toFixed(2);

            const comment = delta > 0
              ? `🟢 **Test coverage increased**: ${prCoverage}% (+${delta}%)`
              : delta < 0
              ? `🔴 **Test coverage decreased**: ${prCoverage}% (${delta}%)`
              : `🟡 **Test coverage unchanged**: ${prCoverage}%`;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });

5. Readme.md 에 뱃지 추가하기

![coverage](./badges/coverage.svg)
![branches](./badges/branches.svg)
![functions](./badges/functions.svg)
![lines](./badges/lines.svg)
![statements](./badges/statements.svg)

Readme.md

6. github action 실행 결과

🎯 실제 적용된 결과는 해당 pull request에서 확인할 수 있습니다. 테스트를 위하여 테스트 코드를 주석 후 push 한 결과입니다.

https://github.com/99mini/fx/pull/1

 

반응형
LIST