カバレッジとCI — テストが一度も実行しなかった行はどれか?
pytest-covでカバレッジを測り、Missing列を読み、ブランチカバレッジ、最小値の強制、pytest-xdistでテストを並列実行、GitHub Actionsであらゆるプッシュでスイート全体を実行。そしてなぜ100%カバレッジでも正しいコードとは限らないか。
どうやって学びますか?
解決する問題
小さな荷物の送料を計算する関数と、その2つのテストがあります。
shop/shipping.py:
def shipping_cost(weight_kg: float, country: str) -> float:
if weight_kg <= 0:
raise ValueError(f"weight must be positive: {weight_kg}")
if country == "BD":
base = 60.0
elif country == "IN":
base = 90.0
else:
base = 500.0
if weight_kg > 5:
base += (weight_kg - 5) * 20
return basetests/test_shipping.py:
from shop.shipping import shipping_cost
def test_light_parcel_to_bangladesh():
assert shipping_cost(1, "BD") == 60.0
def test_heavy_parcel_to_bangladesh():
assert shipping_cost(7, "BD") == 100.0$ pytest -q
.. [100%]
2 passed in 0.07sグリーンです。しかし、そのグリーンは、テストが一度も触れなかった関数の部分については何も教えてくれません。インドへの荷物が90で済むのでしょうか? 重さゼロで本当に例外が発生するのでしょうか? 誰も尋ねていません。"IN"ブランチのタイポは、お客様が見つけるまでそのままパスし続けます。
12行なら目で確認できます。1万2千行ならできません。コンピューターに1つの質問に答えてもらう必要があります:テストが一度も実行しなかった行はどれか? この測定をカバレッジと呼び、この章ではそれを取得し、読み、それに騙されない方法、そしてスイート全体を高速かつ自動的にあらゆるプッシュで実行する方法を扱います。
この章を終える頃には
pytest-covでカバレッジを測り、Missing列を読める- HTMLレポートを開いて、未カバーの行を文脈で見られる
- なぜ行カバレッジが100%でもブランチが未実行のままなのかを説明し、ブランチカバレッジをオンにできる
- カバレッジが閾値を下回ったら実行を失敗させ、意図的にコードを除外できる
- これらすべてを
pyproject.tomlに置ける - なぜ100%カバレッジでもコードが正しいとは限らないのかを説明できる
pytest-xdistでテストを並列実行し、なぜテストが互いに依存してはいけないのかを説明できる- GitHub Actionsのワークフローを書いて、複数のPythonバージョンでスイートを実行できる
前提条件: 設定と警告。
テストを書く前に
カバレッジの間違った使い方は、まずレポートを開いて、次の赤い行をグリーンにするテストを何でも書くことです。するとコードの形をしたテストができます — コードが約束することの形ではありません。そしてこれから分かるように、それ自体の行を持たないバグを見逃します。正しい順序は:契約から計画し、テストを書き、それからだけカバレッジに計画が何を忘れたかを尋ねることです。
契約。 shipping_costをコードではなく約束として読みます:
- 重さゼロ以下 → メッセージに"positive"を含む
ValueError - バングラデシュは60、インドは90、それ以外は500
- 5kg超過で、追加キログラムごとに20加算;ちょうど5kgなら何も加算されない
- 副作用なし:数値を返すだけで、他には何も触れない
用意が必要なもの。 pytestとpytest-covがインストールされた仮想環境(後でpytest-xdistも)。パッケージがテストからインポート可能であること — ここではshop、インストール済みか、前章のpythonpath = ["src"]で到達可能。そして測定対象のコードのインポート名を知っていること、それが--cov=の後に来るからです。
計画。 1つの振る舞いにつき1行、境界と無効な入力を意図的に含めて:
| ケース | 入力 | 期待 | |---|---|---| | ハッピーパス、国内 | 1, "BD" | 60.0 | | 2つ目の国 | 1, "IN" | 90.0 | | それ以外の国 | 1, "US" | 500.0 | | 境界:ちょうど5kg | 5, "BD" | 60.0(追加料金なし) | | 制限超過 | 7, "BD" | 100.0(60 + 2 × 20) | | 無効:重さゼロ | 0, "BD" | ValueError、"positive" |
テストしてはいけないもの。 あなたが書いていないコード(Pythonのround、coverageライブラリ自身)。人間がファイルを直接実行したときだけ動く__main__ブロック。そして意味のあるassertを持たず、行を通すことだけが仕事のテスト — そういうテストはパーセンテージを上げるだけで、何もチェックしません。
2つの境界の行に注目してください。5kgのテストは1kgのテストがすでに通る行以外を通らないので、どんなカバレッジレポートもそれを要求しません。無効なケースでは、-1のテストも0と同様にraise行をカバーします — しかし誰かが<= 0の代わりに< 0とタイプしたら、0のテストだけが失敗します。カバレッジはこの2つのテストを区別できません。契約なら区別できます。それが全体の関係です:計画が何をテストするかを決め、カバレッジは計画が何を見逃したかを報告します。
冒頭の2つのテストは行1と5をカバーします。この章の残りはそのギャップを測り、埋め、そして測定を自動化します。
pytest-covで測る
カバレッジはcoverageライブラリで測られます;pytest-covはそれをpytestのコマンドラインからオンにするプラグインです。開発依存としてインストールします:
$ uv add --dev pytest-covそれから--cov=でどのパッケージを監視するかを指定します:
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmtsはファイル内の実行可能ステートメント数です。Missはそのうち一度も実行されなかった数。Coverは実行された割合。11ステートメント、4つ未実行:64%。
--cov=の後の値は測定したいコードであり、テストではありません。テストは常に自分の行をすべて実行するので、テストを数えるとカバレッジ率を甘く見せるだけです。
Missing列を読む
パーセンテージはどのくらいを教えます。どこを教えません。--cov-report=term-missingを追加します:
$ pytest -q --cov=shop --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64% 3, 6-9
------------------------------------------------
TOTAL 11 4 64%
2 passed in 0.09s3, 6-9はshipping.pyの行番号です。ファイルを数えてみます:
- 行3は
raise ValueError(...)— ゼロ以下の重さを渡したテストがありません。 - 行6から9は
elif country == "IN":、base = 90.0、else:、base = 500.0— バングラデシュ以外に荷物を送ったテストがありません。
行8のelse:はそれ自体ステートメントではないので、11の数には入っていません;カバレッジはリストを短く保つために6-9の範囲に折り込みます。
この列がやるべきことのリストです。各数字は、完全に間違っていてもスイートがグリーンのままである行です。
HTMLレポート
大きなファイルでは数字のリストを追うのは大変です。--cov-report=htmlは小さなウェブサイトを書き出します:
$ pytest -q --cov=shop --cov-report=html
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Coverage HTML written to dir htmlcov
2 passed in 0.11sブラウザでhtmlcov/index.htmlを開きます。各ファイルがパーセンテージ付きでリストされ、クリックするとソースが表示され、実行された行がグリーン、見逃した行がレッドでマークされます。Missingと同じ情報が、コードの上に重ねて示されます。
htmlcov/と.coverageデータファイルはどちらも生成された出力です。.gitignoreに入れてください。
ブランチカバレッジ — なぜ行カバレッジは嘘をつくのか
行カバレッジがすべて問題ないと言う関数を示します:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)from shop.coupons import final_price
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0$ pytest -q --cov=shop --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/coupons.py 4 0 100%
------------------------------------------------
TOTAL 4 0 100%
1 passed in 0.09s100%。すべての行が実行されました。しかし誰もクーポンなしでfinal_priceを呼んだことはありません。elseのないifには2つの出口があり — 本体へ、またはそのまま通り過ぎる — そのうち1つしか試されていません。後で誰かがクーポンなしのパスを壊しても、このレポートでは気づけません。
行カバレッジは「この行は実行されたか?」と尋ねます。ブランチカバレッジは「各決定が両方向に行ったか?」と尋ねます。--cov-branchでオンにします:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10s2つの新しい列です。Branchは可能なジャンプの数(行2のifは行3または行4へ行ける:2つ)。BrPartは片方向しか行かなかった決定の数。そしてMissingには2->4が表示されます:行2から行4へ直接ジャンプする — 「クーポンなし」パス — が一度も実行されなかったことを意味します。
ブランチカバレッジはより厳しく、真実に近いです。常にオンにしておく理由はほとんどありません。
数値をゲートにする:--cov-fail-under
誰も読まないレポートは何も変えません。--cov-fail-underは合計をパス/フェイル条件に変えます:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing --cov-fail-under=90
.
ERROR: Coverage failure: total of 83 is less than fail-under=90
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
FAIL Required test coverage of 90% not reached. Total coverage: 83.33%
1 passed in 0.12s最後の行をよく読んでください:1 passed。すべてのテストがパスしたのに、コマンドはステータス1で終了します。CIならレッドビルドです。ポイントは90が魔法の数字だということではなく、誰かがテストなしでコードを追加したときにカバレッジが静かに下がらなくなることです。
今日の数値より少し下の閾値を選び、時間をかけて上げていきます。初日から100に設定すると、たいてい数値を満たすためだけの無意味なテストを書くことになります。
意図的にコードを除外する:# pragma: no cover
テストする価値のない行もあります — 手動でファイルを実行したときだけ動くブロックなど。2つ目のテストassert final_price(200.0, None) == 200.0を追加してfinal_priceを完全にカバーし、それからモジュールに__main__ブロックを加えます:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11s行8のprintはpytestでは決して実行されません — 今後も実行されません。カバレッジが理解するコメントでifをマークします:
if __name__ == "__main__": # pragma: no cover
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 0 100%
--------------------------------------------------------------
TOTAL 4 0 2 0 100%
2 passed in 0.08sブロックを開く行にプラグマがあれば、ブロック全体が除外されます。控えめに、正直に使ってください。すべてのプラグマはあなたがチェックしないと決めた行です;難しいブランチを「隠す」ためにプラグマを追加しているなら、そのブランチこそが最もテストを必要としている可能性があります。
pyproject.tomlに置く
毎回4つの--covフラグを打つのは、忘れる原因です。カバレッジは独自の設定をpyproject.tomlから読み、前章のaddoptsでpytestがそれをオンにします:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true
[tool.coverage.report]
fail_under = 90
exclude_also = [
'if __name__ == "__main__":',
][tool.coverage.run]は測定を制御:sourceは監視対象(なので値なしの素の--covで十分)、branch = trueは--cov-branch。[tool.coverage.report]は報告を制御:fail_underは--cov-fail-under、exclude_alsoは# pragma: no coverに加えて除外するパターンを列挙 — ここではすべての__main__ブロック、コードにコメントは不要。
これで素のpytestが測定、報告、強制をすべて行います。完全な例で確認できます。
100%カバレッジでも正しいとは限らない
カバレッジは「この行は実行されたか?」に答えます。「誰かが結果が正しいかチェックしたか?」には答えません。小さな例:
def is_leap_year(year: int) -> bool:
return year % 4 == 0from shop.calendar_rules import is_leap_year
def test_2024_is_a_leap_year():
assert is_leap_year(2024)
def test_2023_is_not():
assert not is_leap_year(2023)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
2 passed in 0.12s満点です。しかし関数は間違っています:100で割り切れる年は、400でも割り切れない限り閏年ではありません。行ではなくルールを考えて選んだテストを1つ追加します:
def test_1900_is_not():
assert not is_leap_year(1900)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
..F [100%]
=================================== FAILURES ===================================
_______________________________ test_1900_is_not _______________________________
def test_1900_is_not():
> assert not is_leap_year(1900)
E assert not True
E + where True = is_leap_year(1900)
tests/test_calendar_rules.py:13: AssertionError
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
=========================== short test summary info ============================
FAILED tests/test_calendar_rules.py::test_1900_is_not - assert not True
1 failed, 2 passed in 0.12s依然として100% — そして今度は失敗です。カバレッジはこのバグを指摘できませんでした。なぜなら欠けていた行がなかったからです;欠けていたのはケースでした。カバレッジを、どこを確実に見ていないかの地図として扱ってください。見たところが正しいという証明として扱うことは決してありません。
pytest-xdistでテストを並列実行
スイートが大きくなると遅くなり、遅いスイートは実行頻度が下がります。pytest-xdistはテストを複数プロセスに分散します。それぞれ0.5秒かかる8つのテスト:
import time
import pytest
@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
time.sleep(0.5)
assert n >= 0$ pytest -q test_slow.py
........ [100%]
8 passed in 4.10s
$ pytest -n auto test_slow.py
============================= test session starts ==============================
created: 4/4 workers
4 workers [8 items]
........ [100%]
============================== 8 passed in 1.52s ===============================-n autoはCPUコアごとに1ワーカー(このマシンでは4)を起動し、それぞれにテストを割り当てます。--covと併用できます;すべてのワーカーのカバレッジデータが1つのレポートに結合されます。
しかしトレードオフがあります。各ワーカーは別プロセスであり、テストの実行順序はファイルの順序ではなくなります。別のテストが先に実行されていたことを暗黙に頼っていたテストは壊れます:
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1$ pytest -q test_cart.py
.. [100%]
2 passed in 0.07s
$ pytest -n 2 test_cart.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.F [100%]
=================================== FAILURES ===================================
____________________________ test_cart_has_one_item ____________________________
[gw1] linux -- Python 3.12.3 /path/to/venv/bin/python
def test_cart_has_one_item():
> assert len(CART) == 1
E assert 0 == 1
E + where 0 = len([])
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_cart_has_one_item - assert 0 == 1
========================= 1 failed, 1 passed in 0.37s ==========================2つ目のテストがパスしたのは、1つ目がすでに共有リストにアイテムを入れていたからです。別のワーカーではリストは空でした。pytest test_cart.py::test_cart_has_one_itemを単独で実行しても同じように失敗します — xdistがバグを作ったのではなく、明らかにしただけです。
修正はフィクスチャ章のものです:各テストに自分の状態を与えます。
import pytest
@pytest.fixture
def cart():
return ["pen"]
def test_add_item(cart):
cart.append("bag")
assert cart == ["pen", "bag"]
def test_cart_has_one_item(cart):
assert len(cart) == 1$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.. [100%]
============================== 2 passed in 0.39s ===============================テストは単独で、どんな順序でも、どのワーカーでもパスするべきです。この考えに完全に基づいたプラグインpytest-randomlyがあります:毎回テストの順序をシャッフルし、隠れた依存をあなたのマシンで早期に明らかにします — 数ヶ月後のCIではなく。このコースを進めるのに必要ではありません;「ファイル順でしかパスしないスイート」はバグだと知っていれば十分です。
あらゆるプッシュで実行:GitHub Actions
最後のステップは、人々がスイートを実行するのを覚えておくのに頼るのをやめることです。テストツールを開発グループとしてpyproject.tomlに宣言し、uv syncでインストールされるようにします:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]それから.github/workflows/tests.ymlを追加します:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest -n auto --cov-report=xml
- name: Keep the coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-xml
path: coverage.xml各部分の役割:
on:—mainへのあらゆるプッシュと、あらゆるプルリクエストで実行。matrix:— ジョブが3回実行され、Pythonバージョンごとに並行して1回ずつ。fail-fast: falseで3つすべてが完了し、失敗が特定のバージョンに限定されるかどうかが分かります。setup-uvがuvをインストールし、各ジョブでPythonバージョンを固定。uv sync --lockedがuv.lockの内容を正確にインストールし、ロックファイルが古ければ失敗 — したがってCIはあなたがコミットしたものをテストします。uv run pytest -n auto --cov-report=xmlが並列実行;カバレッジ設定(fail_underを含む)はpyproject.tomlから来るので、カバレッジの低下でビルドが失敗します。XMLレポートはターミナル出力と並んで置かれ、カバレッジサービス用やダウンロード用に準備されます。
ローカルではuv syncの後、同じコマンドで同じ結果が得られます(出力の最後の行):
$ uv run pytest -n auto --cov-report=xml
TOTAL 15 0 10 1 96%
Coverage XML written to file coverage.xml
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.43s ===============================完全な例
srcレイアウトのプロジェクト、この章の両モジュール、すべての設定を1か所に。
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.tomlは上の設定のものです。coupons.pyは__main__ブロックを保持し、プラグマなし — exclude_alsoが処理します。test_coupons.pyにはSAVE10のテストだけが残っています。そしてtest_shipping.pyは冒頭の計画を行ごとに書いたものになります:
import pytest
from shop.shipping import shipping_cost
@pytest.mark.parametrize(
("weight", "country", "expected"),
[
(1, "BD", 60.0),
(1, "IN", 90.0),
(1, "US", 500.0),
(5, "BD", 60.0),
(7, "BD", 100.0),
],
)
def test_shipping_cost(weight, country, expected):
assert shipping_cost(weight, country) == expected
def test_zero_weight_is_rejected():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "BD")$ pytest
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
collected 7 items
tests/test_coupons.py . [ 14%]
tests/test_shipping.py ...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/coupons.py 4 0 2 1 83% 2->4
src/shop/shipping.py 11 0 8 0 100%
------------------------------------------------------------------
TOTAL 15 0 10 1 96%
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.12s ===============================気づく価値のあることが3つあります。
まず、素のpytestだけですべてが完了しました — フラグなしで。測定、ブランチチェック、欠落リスト、閾値がすべてpyproject.tomlから来ました。
2つ目に、shipping.pyが64%から100%になりました。赤い行を追うのではなく、計画を書き留めたからです。カバレッジはその後、計画がすべての行とすべてのブランチに到達したことを確認しました。(5, "BD", 60.0)行と、無効な重さに-1ではなく0を選んだことは、パーセンテージには何も加えません — 契約にエッジがあるからあり、エッジは<=を<とタイプするようなタイポが潜む場所だからです。
3つ目に、2->4がまだリストされていても96%でビルドがパスします。閾値は下限であって、ゴールではありません。レポートは次にどのテストを書くべきかを正確に教えてくれています:クーポンなしのfinal_price。
困ったとき
error: unrecognized arguments: --cov=shop pytest-covがpytestを実行している環境にインストールされていません。インストールしてください(uv add --dev pytest-cov)そしてその環境を通して実行してください、たとえばuv run pytestで。-nで同じメッセージならpytest-xdistが足りません。
CoverageWarning: Module shopp was never imported. (module-not-imported)に続いてNo data was collected. --cov=の後の名前が、テストがインポートしたどのパッケージとも一致しません — ここではタイポ。綴りを確認し、srcレイアウトではフォルダ(src)ではなくインポート名(shop)を指定してください。
WARNING: Failed to generate report: No data to report. 上と同じ原因:何も測定されなかったので報告するものがありません。テストはパスしましたが、それがまさにこの問題を見逃しやすい理由です。
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% 壊れたテストではありません — 閾値です。Missing列を見て、それが指しているテストを書いてください。ビルドをグリーンにするためにfail_underを下げるのは、それを置いた意味を台無しにします。
単独ではパスするテストが-n autoで失敗する(またはその逆) テストが状態を共有しています:モジュールレベルのリストや辞書、固定パスのファイル、環境変数、データベース行。失敗するテストが前提としている既存の状態を特定し、フィクスチャ、tmp_path、monkeypatchでテストに渡すようにしてください。
カバレッジ100%でもユーザーはバグに遭う これは想定内です。カバレッジは何が実行されたかを示すだけで、何がチェックされたかを示しません。境界、空の入力、1900年などのケースを探してください — 行ではありません。
ステップ 4 / 6 — 予想する
理解度をチェック
テストは1つだけで、ブランチカバレッジがオンです。coupons.py行のMissing列には何が表示されますか?
# shop/coupons.py
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
# tests/test_coupons.py
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0
# command: pytest -q --cov=shop --cov-branch --cov-report=term-missing- Aなし — 100%カバー済み
- B4
- C2->4
- D3
pytestでは両テストがパスしますが、pytest -n 2ではtest_cart_has_one_itemが失敗します。本当の問題は何ですか?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdistにバグがある。テストは問題ない
- B2つ目のテストが1つ目の実行済みを前提としている — 2つのテストが状態を共有している
- C`-n 2`はすべてのテストを2回実行する
- D`CART`がモジュールレベルなので、ワーカー間で共有されない
関数が行とブランチで100%カバレッジ、すべてのテストがパスします。何を結論できますか?
- Aすべての行とすべての決定の両側が少なくとも1回実行されたということだけ — 結果が正しいとは限らない
- B`fail_under = 100`なら、バグが関数に入ることは二度とない
- C関数に境界バグはあり得ない
- D関数は正しく、さらなるテストは不要
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
このモジュールsrc/shop/loyalty.pyで小さなプロジェクトを始めます:
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return points契約:100使うごとに1ポイント、会員なら2倍;負の合計はエラー;ゼロは許可。単一のテストtests/test_loyalty.pyとともに届きます:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4そしてブランチカバレッジを毎回測定するpyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = truepytest -qを実行し、Missing列を読みます。各エントリが何を意味するか言葉で説明してください。- テストを書く前に、契約のテスト計画表を書きます — ちょうど100の境界とその直下のエッジを含めて。
- 計画を1つのパラメトライズドテストと1つの
pytest.raisesテストに変えます。 [tool.coverage.report]にfail_under = 100を追加し、実行がパスすることを確認します。- 非会員の行を削除し、実行が失敗することを確認してください — 残ったテストはすべてパスしているのに。
pytest -n autoでスイートを実行し、結果が同一であることを確認します。
解答例
ステップ1 — レポートを読む。
$ pytest -q
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 1 4 2 73% 3, 5->7
------------------------------------------------------------------
TOTAL 7 1 4 2 73%
1 passed in 0.09s3はraise行:負の合計を渡したテストはありません。5->7はif is_member:からreturn pointsへのジャンプ — 非会員パス、一度も通られていません。ブランチカバレッジなしでは、5->7はまったく現れません。なぜなら行6は実行されたからです(会員の場合)。
ステップ2 — 計画。
| ケース | 入力 | 期待 | |---|---|---| | 会員、2倍 | 250, True | 4 | | 非会員 | 250, False | 2 | | 最初の100の直前 | 99.99, False | 0 | | 境界:ちょうど100 | 100, False | 1 | | ゼロは許可 | 0, True | 0 | | 無効:負 | -1, True | ValueError、"cannot be negative" |
ステップ3 — テスト。 tests/test_loyalty.py:
import pytest
from shop.loyalty import loyalty_points
@pytest.mark.parametrize(
("total", "is_member", "expected"),
[
(250, True, 4), # member: points doubled
(250, False, 2), # non-member: the `if` is skipped
(99.99, False, 0), # just under the first 100
(100, False, 1), # exactly on the boundary
(0, True, 0), # zero is allowed, not an error
],
)
def test_loyalty_points(total, is_member, expected):
assert loyalty_points(total, is_member) == expected
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="cannot be negative"):
loyalty_points(-1, True)ステップ4 — ゲート。 pyproject.tomlに追加:
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sステップ5 — ゲートが働く証明。 3つのFalse行を削除して:
$ pytest -q
...
ERROR: Coverage failure: total of 91 is less than fail-under=100
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 1 91% 5->7
------------------------------------------------------------------
TOTAL 7 0 4 1 91%
FAIL Required test coverage of 100.0% not reached. Total coverage: 90.91%
3 passed in 0.12s3 passed、終了ステータス1。すべての行はまだ実行されています — ブランチだけが消えました — そしてゲートがそれを捕捉します。行を元に戻してください。
ステップ6 — 並列で。
$ pytest -n auto
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
created: 4/4 workers
4 workers [6 items]
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
============================== 6 passed in 0.63s ===============================同じ6テスト、同じ100%:4ワーカーのカバレッジが1つのレポートに結合されました。
各判断の理由。
- 計画がテストより先に来た。 6行のうち、レポートが要求していたのは2つだけ(
3と5->7)。99.99と100は契約に100のエッジがあるからあり、0, Trueは契約がゼロを許可すると言っているから — エラーにならないことのテストです。カバレッジならそれらなしでも満足していました。 - なぜここでは`-1`でよいのか。 ガードは
total < 0なので、エッジ自体は0で、0, True行がすでにそれを有効としてピン止めしています。誰かがガードをtotal <= 0に変えたら、その行が失敗します。無効側ならどの負の数でも役立ちます。 - なぜここでは`fail_under = 100`だが実プロジェクトではではない。 このサイズのモジュールなら完全カバレッジは現実的です。大規模コードベースでは、今日の数値の少し下から始めて上げていきます。
- なぜ`-n auto`実行が何かを証明するのか。 すべてのケースが自分の引数を持ち、テスト間で何も共有されないので、順序もワーカーも関係ありません。結果が
-n autoで変わっていたら、それがまず直すべきバグでした。