プログラミング学習 Day 12:Software Test-Coverage

ソフトウエアテストの技法で説明しましたホワイトボックスの網羅テスト用のツールがありますので、紹介します。

coverage(カバレッジ)網羅 

テストがソースコードのどの行や分岐を実行したかを測る指標です。一般に「行カバレッジ(line coverage)」や「分岐カバレッジ(branch coverage)」があり、例えば行カバレッジが 80% ならソースコードの行のうち 80% がテストで実行されたことを意味します。
- 「CI 上でカバレッジを計測する」意味  
継続的インテグレーション(CI)でテストを実行するときに、テスト実行と同時にカバレッジ測定ツール(pytest-cov / coverage.py 等)を動かして coverage レポート(XML/HTML/ターミナル出力)を生成することです。これにより pull request ごとにそのブランチのテスト網羅率が自動的に算出されます。

- 「カバレッジゲート(閾値未満なら CI を失敗させる)」の意味
あらかじめ定めた閾値(たとえば 80%)より総合カバレッジが低ければ、CI のジョブを失敗扱いにしてマージを防ぐ仕組みです。これが「ゲート」で、品質基準を下回る変更をブロックする目的があります。

実装の概略

  1. テスト実行時にカバレッジを出力する(例: pytest --cov=app --cov-report=xml:coverage.xml)。  
2. 生成された coverage.xml を CI 上で保存(artifact)するか直接読み取る。
3. 別ステップまたは別ジョブで coverage.xml を解析して総合カバレッジ(または行カバレッジ)を取り出す。
4. その値をあらかじめ決めた閾値と比較し、未満なら exit 1(CI を失敗)にする。
5. 閾値を満たしていれば正常終了(マージ可能)。

具体的なコマンド例

  テストと coverage の出力(ローカル/CI で):
pytest --cov=app --cov-report=xml:coverage.xml --junitxml=report.xml
閾値チェック(簡単なスクリプトを使う例):
python scripts/check_coverage.py coverage.xml 80

CI(GitHub Actions)での構成例

githubにテスト用のプログラムやスクリプトをいれることで、github actionsというコマンドを使って
テストが実行できます。
 - ジョブ1: tests(pytest 実行 → coverage.xml を upload-artifact)
- ジョブ2: coverage_gate(jobs.needs でジョブ1に依存)→ artifact をダウンロードして解析 → 閾値未満なら失敗

利点

- テスト品質の維持/改善に役立つ  
- リグレッション(テスト網羅の低下)の早期検出が可能
- チームでの品質ルールを自動化できる

注意点

  - 「高いカバレッジ = バグがない」ではない(テストの質も重要)  
- 閾値をいきなり高く設定すると既存コードで CI が大量に落ちる(段階的に引き上げるのがおすすめ)
- テストの書き方や測定対象(例: テストファイルを除外する、サードパーティを除外する)を適切に設定する必要あり(.coveragerc 等で除外を定義)

実運用での最適な方法

  - 初期は低め(例 60%)から始め、徐々に上げる  
- 行カバレッジだけでなく重要な機能については統合テスト・E2E テストも重視する
- カバレッジレポート(HTML)をアーティファクトとして保存し、PR で確認できるようにする
- branch(分岐)カバレッジを重視する場合は coverage.py の設定を行う

まとめ

- 「coverage を計測して CI 上でカバレッジゲートを実装する」とは、CI で自動的にテストの網羅率を測定し、あらかじめ決めた閾値を下回る変更が来たら自動的に CI を失敗させてマージを防ぐ仕組みを作る、ということです。品質担保の自動化手段の一つです。

参考:サンプルの例で実行

CI(GitHub Actions)の概要

  - `.github/workflows/ci.yml` にて:
- pytest を実行(`test` ジョブ)
- テスト成功後に Docker イメージをビルドし、簡単なスモークテストを行う(`build-and-smoke` ジョブ)
- 実運用では `build-and-smoke` の後にアーティファクト保存、レジストリに push、ステージング/本番環境へデプロイするステップを追加。

説明

- まずはローカルで `pytest` を実行してテストが通ることを確認してください。次に `docker build` → `docker run` で動作確認をしてください。GitHub に push すると `.github/workflows/ci.yml` が自動で走り、テスト→ビルド→簡易スモークテストが実行されます。

- 次にCIでのカバレッジ計測(coverage)、およびカバレッジゲートを実行。

ファイル:requirements.txt

fastapi==0.95.2
uvicorn[standard]==0.22.0
pytest==7.4.2
pytest-cov==4.1.0
httpx==0.25.0
coverage==7.2.6

ファイル:.github/workflows/ci.yml


name: CI / Test / Coverage Gate

on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main", "develop"]
workflow_dispatch:

env:
COVERAGE_THRESHOLD: "80" # % - 必要に応じて調整

jobs:
test:
name: Run tests & produce coverage
runs-on: ubuntu-latest
outputs:
coverage_artifact: coverage-report
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"

- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Run pytest (with coverage)
env:
PYTHONPATH: ${{ github.workspace }}
run: |
# pytest.ini can include --cov settings; this runs tests and produces coverage.xml & report.xml
pytest --junitxml=report.xml --cov=app --cov-report=xml:coverage.xml

- name: Upload coverage xml
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml

- name: Upload test junit report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-report
path: report.xml

coverage_gate:
name: Coverage gate
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout repository (for script)
uses: actions/checkout@v4

- name: Download coverage artifact
uses: actions/download-artifact@v4
with:
name: coverage-report
path: ./coverage_artifacts

- name: Show coverage.xml (debug)
run: |
echo "---- coverage.xml ----"
sed -n '1,240p' coverage_artifacts/coverage.xml || true
echo "----------------------"

- name: Install python tools
run: |
python -m pip install --upgrade pip
pip install coverage

- name: Run coverage gate
run: |
python scripts/check_coverage.py coverage_artifacts/coverage.xml ${{ env.COVERAGE_THRESHOLD }}
```

ファイル:scripts/check_coverage.py

#!/usr/bin/env python3
"""
check_coverage.py

Usage:
python scripts/check_coverage.py <coverage.xml> [threshold_percent]

Parses coverage.xml produced by coverage.py (cobertura-like xml) and fails
(exit 1) when total line coverage < threshold_percent.
"""
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

def parse_coverage_xml(path: Path) -> float:
tree = ET.parse(path)
root = tree.getroot()
# coverage.py writes line-rate on root (value between 0..1)
line_rate = root.attrib.get("line-rate")
if line_rate is not None:
try:
return float(line_rate) * 100.0
except Exception:
pass
# fallback: search for any element with line-rate attribute
for elem in root.findall(".//"):
lr = elem.attrib.get("line-rate")
if lr is not None:
try:
return float(lr) * 100.0
except Exception:
continue
raise RuntimeError("coverage percent not found in XML")

def main():
if len(sys.argv) < 2:
print("Usage: scripts/check_coverage.py <coverage.xml> [threshold_percent]")
sys.exit(2)

cov_path = Path(sys.argv[1])
if not cov_path.exists():
print(f"coverage file not found: {cov_path}")
sys.exit(2)

threshold = float(sys.argv[2]) if len(sys.argv) >= 3 else 80.0
pct = parse_coverage_xml(cov_path)
print(f"Total coverage: {pct:.2f}% (threshold: {threshold}%)")
if pct + 1e-9 < threshold:
print("Coverage is below threshold -> FAIL")
sys.exit(1)
print("Coverage meets threshold -> OK")
sys.exit(0)

if __name__ == "__main__":
main()
```

ファイル:pytest.ini

```ini name=pytest.ini
[pytest]
minversion = 6.0
addopts = --maxfail=1 -q
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# optional: you can enable coverage by default locally by uncommenting:
# addopts = --maxfail=1 -q --cov=app --cov-report=xml:coverage.xml --cov-report=term
```

ファイル:.coveragerc

```ini name=.coveragerc
[run]
omit =
tests/*
*/__init__.py

[report]
exclude_lines =
pragma: no cover
if __name__ == .__main__.
```

ローカルでの動作確認手順(一連)

1. プロジェクトルートへ
- cd ~/ci-test

2. 必要なパッケージをインストール(仮想環境を有効にして)
- pip install -r requirements.txt
- requirements.txt に pytest-cov と coverage が入っていることを確認してください(例: pytest-cov==4.*, coverage==7.*)。

3. テスト実行(coverage を生成)
Cd ~/ci-test

- python -m pytest --junitxml=report.xml --cov=app --cov-report=xml:coverage.xml

4. 閾値チェック(ローカル)
- python scripts/check_coverage.py coverage.xml 80

- coverage.xml が空(No data was collected)になる場合:
- テストが実行されていない(カレントディレクトリが間違っている/テスト discovery が失敗している)
- ImportError でテスト収集が失敗している(上の対処参照)

- 閾値の運用:
- 既存コードではいきなり高い閾値を設定すると多くの PR が落ちます。初期は 60〜70 から始め、段階 的に上げるのがおすすめです。
- 閾値はワークフロー内環境変数 COVERAGE_THRESHOLD を変更するだけで調整できます。



著者:松尾正信
株式会社京都テキストラボ代表取締役