অধ্যায় 14

Coverage আর CI — কোন লাইন টেস্ট কখনো চালায়নি

pytest-cov দিয়ে coverage মাপা, Missing কলাম পড়া, branch coverage, ন্যূনতম সীমা বেঁধে দেওয়া, pytest-xdist দিয়ে সমান্তরালে টেস্ট চালানো, আর GitHub Actions-এ প্রতিটি push-এ পুরো স্যুট চালানো। সঙ্গে কেন ১০০% coverage মানেই সঠিক কোড নয়।

50 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

এখানে একটি ছোট ফাংশন আছে যা পার্সেলের খরচ হিসাব করে, আর তার জন্য দুটি টেস্ট।

shop/shipping.py:

python
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 base

tests/test_shipping.py:

python
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
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.07s

সবুজ। কিন্তু ফাংশনের যে অংশগুলো টেস্ট কখনো ছোঁয়নি, সেগুলো সম্পর্কে এই সবুজ কিছুই বলে না। ভারতে পার্সেল পাঠাতে কি ৯০ লাগে? শূন্য ওজন দিলে কি সত্যিই ত্রুটি হয়? কেউ জিজ্ঞেসই করেনি। "IN" শাখায় একটি টাইপো থাকলে সেটা পাস করতে করতে সেখানেই বসে থাকত, যতদিন না কোনো গ্রাহক ধরিয়ে দেন।

বারো লাইন চোখে দেখে যাচাই করা যায়। বারো হাজার লাইন যায় না। আপনার দরকার কম্পিউটার যেন একটি প্রশ্নের উত্তর দেয়: আমার টেস্ট কোন লাইনগুলো কখনো চালায়নি? এই মাপটির নাম coverage। এই অধ্যায় সেটা পাওয়া, পড়া আর তাতে বোকা না বনা নিয়ে — তারপর পুরো স্যুটটি দ্রুত, আর প্রতিটি push-এ স্বয়ংক্রিয়ভাবে চালানো নিয়ে।

এই অধ্যায় শেষে আপনি পারবেন

  • pytest-cov দিয়ে coverage মাপতে আর Missing কলাম পড়তে
  • HTML রিপোর্ট খুলে কভার-না-হওয়া লাইনগুলো কোডের ভেতরেই দেখতে
  • ব্যাখ্যা করতে কেন একটি শাখা কখনো না চললেও লাইন coverage ১০০% বলতে পারে, আর branch coverage চালু করতে
  • coverage একটি সীমার নিচে নামলে রান ফেল করাতে, আর জেনেশুনে কিছু কোড বাদ দিতে
  • এর সবকিছু pyproject.toml-এ রাখতে
  • বলতে কেন ১০০% coverage মানে কোড সঠিক নয়
  • pytest-xdist দিয়ে টেস্ট সমান্তরালে চালাতে, আর ব্যাখ্যা করতে কেন টেস্ট একে অপরের ওপর নির্ভর করতে পারে না
  • একটি GitHub Actions workflow লিখতে যা কয়েকটি পাইথন সংস্করণে স্যুট চালায়

আগে যা জানা লাগবে: কনফিগারেশন ও ওয়ার্নিং।


টেস্ট লেখার আগে

Coverage ব্যবহারের ভুল উপায় হলো আগে রিপোর্ট খোলা, তারপর পরের লাল লাইনটিকে সবুজ করে এমন যেকোনো টেস্ট লিখে ফেলা। তাতে যে টেস্ট তৈরি হয় তার আকার কোডের মতো, কোডের দেওয়া প্রতিশ্রুতির মতো নয় — আর, যেমনটা দেখবেন, যে বাগের নিজের কোনো লাইন নেই সেগুলো তাতে ধরা পড়ে না। সঠিক ক্রম হলো: চুক্তি থেকে পরিকল্পনা করুন, টেস্ট লিখুন, আর কেবল তারপর coverage-কে জিজ্ঞেস করুন পরিকল্পনা কী ভুলে গেছে।

চুক্তি। shipping_cost-কে কোড হিসেবে নয়, একটি প্রতিশ্রুতি হিসেবে পড়ুন:

  • ওজন শূন্য বা তার কম → ValueError, যার বার্তায় "positive" কথাটি আছে
  • বাংলাদেশে খরচ ৬০, ভারতে ৯০, অন্য যেকোনো জায়গায় ৫০০
  • ৫ কেজির ওপরে প্রতিটি বাড়তি কেজিতে ২০ যোগ হয়; ঠিক ৫ কেজিতে কিছুই যোগ হয় না
  • কোনো পার্শ্বপ্রতিক্রিয়া নেই: এটি একটি সংখ্যা ফেরত দেয়, আর কিছু ছোঁয় না

কী প্রস্তুত থাকতে হবে। একটি ভার্চুয়াল এনভায়রনমেন্ট, যাতে pytest আর pytest-cov ইনস্টল করা (আর পরে pytest-xdist)। প্যাকেজটি টেস্ট থেকে import করা যেতে হবে — এখানে shop, হয় ইনস্টল করা, নয়তো আগের অধ্যায়ের pythonpath = ["src"] দিয়ে পৌঁছানো। আর যে কোড মাপছেন তার import নাম জানতে হবে, কারণ --cov=-এর পরে সেটাই বসে।

পরিকল্পনা। প্রতিটি আচরণের জন্য একটি সারি, সীমানা আর অবৈধ ইনপুট ইচ্ছে করেই রাখা:

| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | স্বাভাবিক পথ, নিজের দেশ | 1, "BD" | 60.0 | | দ্বিতীয় দেশ | 1, "IN" | 90.0 | | অন্য যেকোনো জায়গা | 1, "US" | 500.0 | | সীমানা: ঠিক ৫ কেজি | 5, "BD" | 60.0 (বাড়তি চার্জ নেই) | | সীমার ওপরে | 7, "BD" | 100.0 (60 + 2 × 20) | | অবৈধ: শূন্য ওজন | 0, "BD" | ValueError, "positive" |

কী টেস্ট করবেন না। যে কোড আপনি লেখেননি (পাইথনের round, coverage লাইব্রেরি নিজে)। এমন একটি __main__ ব্লক যা কেবল মানুষ হাতে ফাইলটি চালালে চলে। আর কখনোই এমন টেস্ট নয় যার একমাত্র কাজ একটি লাইন চালানো — অর্থপূর্ণ assert ছাড়া টেস্ট শতাংশ বাড়ায়, কিন্তু কিছুই যাচাই করে না।

সীমানার দুটি সারি খেয়াল করুন। ৫ কেজির টেস্ট এমন কোনো লাইন চালায় না যা ১ কেজির টেস্ট আগেই চালায়নি, তাই কোনো coverage রিপোর্ট কখনো এটি চাইবে না। আর অবৈধ কেসে, -1 দেওয়া টেস্ট raise লাইনটি ঠিক 0-এর মতোই কভার করত — কিন্তু কেউ যদি <= 0-এর বদলে weight_kg < 0 লিখে ফেলে, শুধু 0-এর টেস্টটিই ফেল করবে। Coverage এই দুটি টেস্টের পার্থক্য বুঝতে পারে না; চুক্তি পারে। পুরো সম্পর্কটা এই: কী টেস্ট হবে তা ঠিক করে পরিকল্পনা; পরিকল্পনা কী বাদ দিয়েছে তা জানায় coverage।

শুরুর দুটি টেস্ট সারি ১ আর ৫ কভার করে। অধ্যায়ের বাকিটা সেই ফাঁক মাপে, বন্ধ করে, তারপর মাপার কাজটি স্বয়ংক্রিয় করে।

pytest-cov দিয়ে মাপা

Coverage মাপে coverage লাইব্রেরি; pytest-cov হলো সেই plugin যা pytest-এর কমান্ড লাইন থেকে এটি চালু করে। এটিকে ডেভেলপমেন্ট ডিপেন্ডেন্সি হিসেবে ইনস্টল করুন:

text
$ uv add --dev pytest-cov

তারপর --cov= দিয়ে বলে দিন কোন প্যাকেজের ওপর নজর রাখতে হবে:

text
$ 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.09s

Stmts হলো ফাইলে চালানো-যোগ্য স্টেটমেন্টের সংখ্যা। Miss হলো তাদের কতগুলো কখনো চলেনি। Cover হলো যতটুকু চলেছে তার অংশ। এগারোটি স্টেটমেন্ট, চারটি কখনো চলেনি: ৬৪%।

--cov=-এর পরের মানটি হলো যে কোড মাপতে চান — টেস্ট নয়। টেস্ট সবসময় নিজেদের প্রতিটি লাইন চালায়, তাই সেগুলো গুনলে মোট সংখ্যা শুধু ফুলে উঠবে।

Missing কলাম পড়া

শতাংশ বলে কতটা। বলে না কোথায়। --cov-report=term-missing যোগ করুন:

text
$ 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.09s

3, 6-9 হলো shipping.py-এর লাইন নম্বর। ফাইলে নিচের দিকে গুনুন:

  • লাইন ৩ হলো raise ValueError(...) — কোনো টেস্ট শূন্য বা তার কম ওজন দেয়নি।
  • লাইন ৬ থেকে ৯ হলো elif country == "IN":, base = 90.0, else: আর base = 500.0 — কোনো টেস্ট বাংলাদেশ ছাড়া অন্য কোথাও পার্সেল পাঠায়নি।

লাইন ৮, else:, নিজে কোনো স্টেটমেন্ট নয়, তাই এগারোর গণনায় নেই; তালিকা ছোট রাখতে coverage শুধু এটাকে 6-9 পরিসরের ভেতর মিলিয়ে দেয়।

এই কলামটাই কাজের তালিকা। প্রতিটি নম্বর এমন একটি লাইন যা পুরোপুরি ভুল হতে পারে, আর তবুও আপনার স্যুট সবুজই থাকবে।

HTML রিপোর্ট

বড় ফাইলে নম্বরের তালিকা অনুসরণ করা কঠিন হয়ে যায়। --cov-report=html তার বদলে একটি ছোট ওয়েবসাইট লেখে:

text
$ 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-এ রাখুন।

Branch coverage — কেন লাইন coverage মিথ্যা বলে

এমন একটি ফাংশন যেখানে লাইন coverage বলে সব ঠিক আছে:

python
def final_price(total: float, coupon: str | None) -> float:
    if coupon == "SAVE10":
        total = total * 0.9
    return round(total, 2)
python
from shop.coupons import final_price


def test_coupon_takes_ten_percent_off():
    assert final_price(200.0, "SAVE10") == 180.0
text
$ 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.09s

১০০%। প্রতিটি লাইন চলেছে। কিন্তু কেউ কখনো কুপন ছাড়া final_price ডাকেনি। else ছাড়া একটি if-এর বের হওয়ার দুটি পথ — ভেতরের অংশে ঢোকা, অথবা সোজা পাশ কাটিয়ে যাওয়া — আর চেষ্টা হয়েছে কেবল একটির। পরে কেউ কুপন-ছাড়া পথটি ভেঙে ফেললে এই রিপোর্ট টেরই পাবে না।

লাইন coverage জিজ্ঞেস করে "এই লাইনটি চলেছে কি?"। Branch coverage জিজ্ঞেস করে "প্রতিটি সিদ্ধান্ত কি দুই দিকেই গেছে?"। --cov-branch দিয়ে এটি চালু করুন:

text
$ 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.10s

দুটি নতুন কলাম। Branch হলো সম্ভাব্য লাফের সংখ্যা (লাইন ২-এর if যেতে পারে লাইন ৩-এ অথবা লাইন ৪-এ: দুটি)। BrPart গোনে সেই সিদ্ধান্তগুলো যেগুলো কেবল এক দিকে গেছে। আর Missing এখন দেখাচ্ছে 2->4: লাইন ২ থেকে সোজা লাইন ৪-এ লাফ — "কুপন নেই" পথটি — কখনো ঘটেনি।

Branch coverage বেশি কড়া আর সত্যের বেশি কাছে। এটা সবসময় চালু না রাখার তেমন কোনো কারণ নেই।

সংখ্যাটিকে দরজা বানানো: --cov-fail-under

যে রিপোর্ট কেউ পড়ে না, তা কিছুই বদলায় না। --cov-fail-under মোট সংখ্যাটিকে পাস/ফেলের শর্তে পরিণত করে:

text
$ 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। প্রতিটি টেস্ট পাস করেছে, তবুও কমান্ডটি status 1 দিয়ে শেষ হয়। CI-তে সেটা লাল বিল্ড। কথাটা এই নয় যে ৯০ কোনো জাদুকরী সংখ্যা; কথাটা হলো, কেউ টেস্ট ছাড়া কোড যোগ করলে coverage আর চুপচাপ নেমে যেতে পারবে না।

আজ আপনি যেখানে আছেন তার একটু নিচে একটি সীমা বেছে নিন, আর সময়ের সাথে বাড়ান। প্রথম দিনেই ১০০ বসালে সাধারণত দেখা যায়, মানুষ শুধু সংখ্যা মেটাতে অর্থহীন টেস্ট লিখছে।

জেনেশুনে কোড বাদ দেওয়া: # pragma: no cover

কিছু লাইন টেস্ট করার মতো নয় — ধরুন এমন একটি ব্লক যা কেবল হাতে ফাইলটি চালালে চলে। দ্বিতীয় একটি টেস্ট যোগ করুন, assert final_price(200.0, None) == 200.0, যাতে final_price পুরোপুরি কভার হয় — তারপর মডিউলে একটি __main__ ব্লক:

python
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"))
text
$ 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

লাইন ৮, print, pytest-এর অধীনে কখনো চলে না — আর কখনো চলবেও না। if-টিকে এমন একটি মন্তব্য দিয়ে চিহ্নিত করুন যা coverage বোঝে:

python
if __name__ == "__main__":  # pragma: no cover
    print(final_price(200.0, "SAVE10"))
text
$ 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

pragma যখন একটি ব্লক খোলে এমন লাইনে বসে, তখন পুরো ব্লকটাই বাদ যায়। এটা কম আর সৎভাবে ব্যবহার করুন। প্রতিটি pragma এমন একটি লাইন যা আপনি যাচাই না করার সিদ্ধান্ত নিয়েছেন; যদি দেখেন কোনো কঠিন শাখাকে "সরিয়ে দিতে" একটি যোগ করছেন, তাহলে সম্ভবত সেই শাখাটিরই টেস্ট সবচেয়ে বেশি দরকার।

সব pyproject.toml-এ রাখা

প্রতিবার চারটি --cov ফ্ল্যাগ টাইপ করা মানেই একদিন ভুলে যাওয়া। Coverage নিজের সেটিং pyproject.toml থেকে পড়ে, আর আগের অধ্যায়ে দেখা addopts pytest-কে দিয়ে এটি চালু করায়:

toml
[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-ই মাপে, রিপোর্ট দেয় আর নিয়ম খাটায়। নিচের পূর্ণাঙ্গ উদাহরণে এটা চলতে দেখবেন।

১০০% coverage মানে সঠিক নয়

Coverage উত্তর দেয় "এই লাইনটি চলেছে কি?"। উত্তর দেয় না "কেউ কি যাচাই করেছে ফলাফলটি সঠিক?"। একটি ছোট উদাহরণ:

python
def is_leap_year(year: int) -> bool:
    return year % 4 == 0
python
from 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)
text
$ 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 দিয়েও বিভাজ্য হয়। আরেকটি টেস্ট, যা বেছে নেওয়া হয়েছে লাইনের কথা নয়, নিয়মের কথা ভেবে:

python
def test_1900_is_not():
    assert not is_leap_year(1900)
text
$ 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

এখনো ১০০% — আর এখন একটি ফেল। Coverage এই বাগের দিকে আঙুল তুলতে পারত না, কারণ কোনো লাইন বাদ পড়েনি; বাদ পড়েছিল একটি কেস। Coverage-কে দেখুন সেই মানচিত্র হিসেবে যেখানে আপনি নিশ্চিতভাবে তাকাননি, কখনোই এই প্রমাণ হিসেবে নয় যে যেখানে তাকিয়েছেন তা ঠিক।

pytest-xdist দিয়ে টেস্ট সমান্তরালে চালানো

স্যুট বড় হলে ধীর হয়, আর ধীর স্যুট কম চালানো হয়। pytest-xdist টেস্টগুলোকে কয়েকটি প্রসেসে ভাগ করে দেয়। আটটি টেস্ট, প্রতিটি আধা সেকেন্ড নেয়:

python
import time

import pytest


@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
    time.sleep(0.5)
    assert n >= 0
text
$ 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 core-এর জন্য একটি worker চালু করে (এই মেশিনে চারটি) আর প্রত্যেককে চালানোর জন্য টেস্ট দেয়। এটা --cov-এর সাথে একসঙ্গে কাজ করে; সব worker-এর coverage ডেটা মিলে একটি রিপোর্ট হয়।

এর একটা দাম আছে। প্রতিটি worker আলাদা প্রসেস, আর টেস্ট চলার ক্রম আর ফাইলের ক্রম নয়। যে টেস্ট চুপচাপ অন্য একটি টেস্ট আগে চলার ওপর ভরসা করত, সেটি ভাঙবে:

python
CART = []


def test_add_item():
    CART.append("pen")
    assert CART == ["pen"]


def test_cart_has_one_item():
    assert len(CART) == 1
text
$ 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 ==========================

দ্বিতীয় টেস্টটি পাস করেছিল কেবল এ কারণে যে প্রথমটি আগেই ভাগ-করা তালিকাটি ভরে রেখেছিল। অন্য worker-এ তালিকা ছিল খালি। pytest test_cart.py::test_cart_has_one_item একা চালালেও একইভাবে ফেল করে — xdist বাগটি বানায়নি, ফাঁস করেছে।

সমাধান fixture-এর অধ্যায়গুলোর সেই একই: প্রতিটি টেস্টকে তার নিজস্ব অবস্থা দিন।

python
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
text
$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]

..                                                                       [100%]
============================== 2 passed in 0.39s ===============================

একটি টেস্টের পাস করা উচিত একা, যেকোনো ক্রমে, যেকোনো worker-এ। pytest-randomly নামে একটি plugin আছে যা পুরোপুরি এই ধারণার ওপর দাঁড়ানো: এটি প্রতিটি রানে টেস্টের ক্রম এলোমেলো করে দেয়, যাতে লুকানো নির্ভরতা মাস কয়েক পরে CI-তে নয়, আগেভাগে আপনার মেশিনেই ধরা পড়ে। এই কোর্স অনুসরণ করতে এটা লাগবে না; এটুকু জানাই যথেষ্ট যে "স্যুট কেবল ফাইলের ক্রমে পাস করে" — এটা একটা বাগ।

প্রতিটি push-এ চালানো: GitHub Actions

শেষ ধাপ হলো স্যুট চালাতে মানুষের মনে রাখার ওপর নির্ভর করা বন্ধ করা। টেস্টের টুলগুলোকে pyproject.toml-এ একটি ডেভেলপমেন্ট গ্রুপ হিসেবে ঘোষণা করুন, যাতে uv sync সেগুলো ইনস্টল করে:

toml
[dependency-groups]
dev = [
    "pytest>=9",
    "pytest-cov>=7",
    "pytest-xdist>=3.8",
]

তারপর .github/workflows/tests.yml যোগ করুন:

yaml
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-এ প্রতিটি push-এ আর প্রতিটি pull request-এ চালাও।
  • matrix: — job-টি তিনবার চলে, প্রতিটি পাইথন সংস্করণের জন্য একবার, পাশাপাশি। fail-fast: false তিনটিকেই শেষ হতে দেয়, যাতে দেখতে পান কোনো ফেল কেবল একটি সংস্করণের কিনা।
  • setup-uv uv ইনস্টল করে আর job-এর সেই কপির জন্য পাইথন সংস্করণ বেঁধে দেয়।
  • uv sync --locked ঠিক তাই ইনস্টল করে যা uv.lock বলে, আর lock ফাইল পুরনো হলে ফেল করে — ফলে CI ঠিক সেটাই টেস্ট করে যা আপনি commit করেছেন।
  • uv run pytest -n auto --cov-report=xml সমান্তরালে চালায়; fail_under সহ coverage-এর সেটিং আসে pyproject.toml থেকে, তাই coverage নামলে বিল্ড ফেল করে। XML রিপোর্টটি টার্মিনালেরটির পাশাপাশি তৈরি হয়, কোনো coverage সার্ভিস বা ডাউনলোডের জন্য প্রস্তুত।

নিজের মেশিনে, uv sync-এর পরে, একই কমান্ড একই ফল দেয় (আউটপুটের শেষ লাইনগুলো):

text
$ 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 layout-এর প্রজেক্ট, এই অধ্যায়ের দুটি মডিউলই, আর সব সেটিং এক জায়গায়।

text
shop-project/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       ├── coupons.py
│       └── shipping.py
└── tests/
    ├── test_coupons.py
    └── test_shipping.py

pyproject.toml হলো ওপরের কনফিগারেশন অংশেরটি। coupons.py তার __main__ ব্লক রেখে দিয়েছে, কোনো pragma ছাড়াই — exclude_also সেটা সামলায়। test_coupons.py-তে এখনো শুধু SAVE10-এর টেস্ট। আর test_shipping.py এখন অধ্যায়ের শুরুর টেস্ট পরিকল্পনা, সারি ধরে ধরে:

python
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")
text
$ 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 ===============================

তিনটি জিনিস লক্ষ করার মতো।

প্রথমত, সাধারণ pytest-ই সব করেছে — কোনো ফ্ল্যাগ ছাড়া। মাপা, branch যাচাই, বাদ-পড়ার তালিকা আর সীমা — সবই এসেছে pyproject.toml থেকে।

দ্বিতীয়ত, shipping.py ৬৪% থেকে ১০০%-এ গেছে কারণ পরিকল্পনাটি লিখে রাখা হয়েছিল, লাল লাইনের পেছনে দৌড়ানোর জন্য নয়। তারপর coverage নিশ্চিত করে যে পরিকল্পনা প্রতিটি লাইন আর প্রতিটি শাখায় পৌঁছেছে। (5, "BD", 60.0) সারি আর অবৈধ ওজনের জন্য -1-এর বদলে 0 বেছে নেওয়া — কোনোটিই শতাংশে কিছু যোগ করে না। এগুলো আছে কারণ চুক্তির কিনারা আছে, আর <=-এর জায়গায় <-এর মতো টাইপো থাকে কিনারাতেই।

তৃতীয়ত, 2->4 তালিকায় থাকতেই বিল্ড ৯৬%-এ পাস করছে। সীমা হলো মেঝে, শেষ রেখা নয়। রিপোর্টটি এখনো ঠিক বলে দিচ্ছে পরের টেস্টটি কী লিখতে হবে: কুপন ছাড়া final_price।


যখন ভাঙে

error: unrecognized arguments: --cov=shop pytest যে এনভায়রনমেন্ট থেকে চলছে তাতে pytest-cov ইনস্টল নেই। ইনস্টল করুন (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=-এর পরের নামটি আপনার টেস্টের import করা কোনো প্যাকেজের সাথে মেলে না — এখানে একটি টাইপো। বানান যাচাই করুন, আর src layout-এ ফোল্ডারের নাম (src) নয়, import নাম (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-তে ফেল করে (অথবা উল্টোটা) টেস্টগুলো অবস্থা ভাগ করে নিচ্ছে: মডিউল স্তরের একটি তালিকা বা ডিকশনারি, নির্দিষ্ট পথের একটি ফাইল, একটি এনভায়রনমেন্ট ভেরিয়েবল, ডেটাবেসের একটি সারি। ফেল-করা টেস্টটি কী আগে থেকেই আছে বলে ধরে নিচ্ছে তা খুঁজুন, আর সেটা fixture, tmp_path বা monkeypatch দিয়ে টেস্টকে দিন।

Coverage ১০০% কিন্তু ব্যবহারকারীরা তবুও বাগে পড়ছেন এটাই প্রত্যাশিত। Coverage দেখায় কী চলেছে, কী যাচাই হয়েছে তা নয়। লাইন নয়, কেস খুঁজুন — সীমানা, খালি ইনপুট, 1900 সাল।