🙅‍♂️

【Python】GitHub Actionsでテストカバレッジ80%未満ならマージさせない設定

に公開

Github Actionsを使ってPythonのUnitTestを実行し、カバレッジ率が80%未満であればPRのマージを出来なくする設定のまとめ

プロジェクト構成

root/
├── .github/
│   └── workflows/
│       └── ci.yml          # Github Actionsの設定ファイル
├── program/                # Pythonのコード
│   ├── main.py
│   └── main2.py
├── unittest/               # UnitTestのコード
│   └── test_main.py
└── requirements.txt

・UnitTestはPython標準のunittestを使う
・カバレッジはcoverageモジュールを使う

各ファイルの中身

PythonのコードやUnitTestのコードは適当です。
main2.pyのUnitTestがないのは、UnitTest上で使われてないファイルがある場合もちゃんとカバレッジ率が下がるかどうか見たかったから。

ci.yml
name: PR Test & Coverage

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  issues: write
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11.0'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi

      - name: Run tests with coverage
        run: |
          python -m coverage run --source=program -m unittest discover -s unittest
          python -m coverage json -o coverage.json
          # 総合カバレッジが80%未満の場合にジョブを失敗させる
          python -m coverage report --fail-under=80

      - name: Post coverage to PR (single step, GH_PAT fallback)
        if: ${{ always() }}
        uses: actions/github-script@v6
        env:
          GH_PAT: ${{ secrets.GH_PAT }}
        with:
          script: |
            const fs = require('fs');
            const ev = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
            const headRepo = ev.pull_request && ev.pull_request.head && ev.pull_request.head.repo;
            // GH_PATが無く、PRがフォークからのものなら、GITHUB_TOKENが制限される可能性があるためスキップする
            if (!process.env.GH_PAT && headRepo && headRepo.fork) {
              core.info('PR is from a fork; skipping comment (GITHUB_TOKEN restricted for forks).');
              return;
            }
            let cov;
            try {
              cov = JSON.parse(fs.readFileSync('coverage.json', 'utf8'));
            } catch (e) {
              core.info('coverage.json not found or invalid: ' + e.message);
            }
            core.info('coverage.json content: ' + JSON.stringify(cov));

            let body;
            if (!cov || !cov.totals) {
              body = 'カバレッジ情報を取得できませんでした。\ncoverage.json contents: ' + JSON.stringify(cov);
            } else {
              const totals = cov.totals;
              // マークダウンの表を作成
              const header = '## Test Coverage\n\n| File | Covered / Total | % | Comment |\n|---|---:|---:|---:|';
              const rows = [];
              if (cov.files) {
                for (const [fname, fdata] of Object.entries(cov.files)) {
                  const s = fdata.summary || {};
                  const covered = s.covered_lines != null ? s.covered_lines : (s.covered || s.covered_lines_count || 0);
                  const total = s.num_statements != null ? s.num_statements : (s.statements || 0);
                  const pct = s.percent_covered != null ? Number(s.percent_covered) : (s.percent_statements_covered != null ? Number(s.percent_statements_covered) : null);
                  const pctStr = (pct == null || Number.isNaN(pct)) ? 'N/A' : `${pct.toFixed(2)}%`;
                  let comment = '';
                  // 欠落している行があれば補足として追加表示
                  if (fdata.missing_lines && fdata.missing_lines.length) {
                    comment = `Missing lines: ${fdata.missing_lines.join(', ')}`;
                  }
                  let row = `| ${fname} | ${covered}/${total} | ${pctStr} | ${comment} |`;

                  rows.push(row);
                }
              }
              const totalCovered = totals.covered_lines != null ? totals.covered_lines : totals.covered || 0;
              const totalNum = totals.num_statements != null ? totals.num_statements : totals.statements || 0;
              const totalPct = totals.percent_covered != null ? Number(totals.percent_covered) : (totals.percent_statements_covered != null ? Number(totals.percent_statements_covered) : null);
              const totalPctStr = (totalPct == null || Number.isNaN(totalPct)) ? 'N/A' : `${totalPct.toFixed(2)}%`;
              const totalRow = `| **Total** | ${totalCovered}/${totalNum} | ${totalPctStr} |`;
              body = header + '\n' + rows.join('\n') + '\n' + totalRow;
              // カバレッジ率が80%未満の場合、警告を追加
              if (totalPct != null && !Number.isNaN(totalPct) && totalPct < 80) {
                body += `\n\n<font color="red">⚠️ カバレッジが80%未満です: ${totalPctStr}</font>`;
              }
            }
            const pr = ev.pull_request.number;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr,
              body: body
            });
main.py
def add_one(value):
    if value == 10:
        return 11
    if value == 11:
        return 12
    if value == 13:
        return 14
    return value + 1

def multiply_by_two(value):
    return value * 2
main2.py
def add_three(value):
    return value + 3
test_main.py
import unittest
from program.main import add_one
from program.main import multiply_by_two


class TestAddOne(unittest.TestCase):
    def test_add_one_int(self):
        self.assertEqual(add_one(1), 2)
        self.assertEqual(add_one(10), 11)
        self.assertEqual(add_one(11), 12)
        self.assertEqual(add_one(13), 14)

    def test_multiply_by_two(self):
        self.assertEqual(multiply_by_two(2), 4)
        self.assertEqual(multiply_by_two(0), 0)
        self.assertEqual(multiply_by_two(-3), -6)

    def test_add_one_zero(self):
        self.assertEqual(add_one(0), 1)

    def test_add_one_negative(self):
        self.assertEqual(add_one(-2), -1)

    def test_add_one_float(self):
        self.assertAlmostEqual(add_one(1.5), 2.5)

    def test_add_one_type_error(self):
        with self.assertRaises(TypeError):
            add_one("a")


if __name__ == "__main__":
    unittest.main()
requirements.txt
coverage>=6.5

ci.ymlの説明

  1. ubuntu環境を用意する
  2. Pythonをセットアップする
  3. requirements.txtに書かれているモジュールをインストールする
  4. python -m coverage run --source=program -m unittest discover -s unittest
    ・ UnitTestの実行とカバレッジ率を計算
    --source=program 「計測対象」を program ディレクトリに限定するオプション
    (これがないと、テストコード自体(unittest/)や、インストールされている外部ライブラリまで計測対象に含まれてしまい、正確なカバレッジ率(%)が出せなくなる)
    -m unittest Python標準のunittestモジュールを使ってUnitTestを実行するオプション
    discover テストファイルを自動的に探し出す(ディスカバリ)サブコマンド、デフォルトでは test*.py という名前のファイルを探す
    -s unittest UnitTestファイルの検索開始位置、この場合は「unittest というディレクトリの中からテストファイルを探し始めてね」という指示になる
  5. python -m coverage json -o coverage.json
    ・ カバレッジ率などのレポートをjsonとして出力する
  6. python -m coverage report --fail-under=80
    ・ カバレッジ率が80%未満であればこのActionをエラーで終了させる
  7. 出力されたcoverage.jsonからカバレッジ率などを取り出し、markdown形式で表にしてPRのコメントに書き込む

リポジトリの設定

このままではカバレッジ率が80%未満でもマージできてしまいます。
ここではci.ymlのActionsが正常に完了しなければマージできないように設定していきます。

  1. リポジトリのSettingsを開く
  2. Rules -> Rulesets
  3. New rulese -> New branch ruleset
  4. Ruleset Nameを適当に入れる
  5. Require status checks to passにチェックを入れ、Add checksからtestを検索してチェックを入れる
  6. Enforcement statusをActiveにしCreateを押す

動作確認

  • 80%未満
  • 80%以上

これでカバレッジ率が80%未満であればマージができなくなります

Discussion