Coverage আর CI — কোন লাইন টেস্ট কখনো চালায়নি
pytest-cov দিয়ে coverage মাপা, Missing কলাম পড়া, branch coverage, ন্যূনতম সীমা বেঁধে দেওয়া, pytest-xdist দিয়ে সমান্তরালে টেস্ট চালানো, আর GitHub Actions-এ প্রতিটি push-এ পুরো স্যুট চালানো। সঙ্গে কেন ১০০% coverage মানেই সঠিক কোড নয়।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এখানে একটি ছোট ফাংশন আছে যা পার্সেলের খরচ হিসাব করে, আর তার জন্য দুটি টেস্ট।
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সবুজ। কিন্তু ফাংশনের যে অংশগুলো টেস্ট কখনো ছোঁয়নি, সেগুলো সম্পর্কে এই সবুজ কিছুই বলে না। ভারতে পার্সেল পাঠাতে কি ৯০ লাগে? শূন্য ওজন দিলে কি সত্যিই ত্রুটি হয়? কেউ জিজ্ঞেসই করেনি। "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-এর কমান্ড লাইন থেকে এটি চালু করে। এটিকে ডেভেলপমেন্ট ডিপেন্ডেন্সি হিসেবে ইনস্টল করুন:
$ 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 হলো যতটুকু চলেছে তার অংশ। এগারোটি স্টেটমেন্ট, চারটি কখনো চলেনি: ৬৪%।
--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-এর লাইন নম্বর। ফাইলে নিচের দিকে গুনুন:
- লাইন ৩ হলো
raise ValueError(...)— কোনো টেস্ট শূন্য বা তার কম ওজন দেয়নি। - লাইন ৬ থেকে ৯ হলো
elif country == "IN":,base = 90.0,else:আরbase = 500.0— কোনো টেস্ট বাংলাদেশ ছাড়া অন্য কোথাও পার্সেল পাঠায়নি।
লাইন ৮, else:, নিজে কোনো স্টেটমেন্ট নয়, তাই এগারোর গণনায় নেই; তালিকা ছোট রাখতে coverage শুধু এটাকে 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-এ রাখুন।
Branch coverage — কেন লাইন coverage মিথ্যা বলে
এমন একটি ফাংশন যেখানে লাইন coverage বলে সব ঠিক আছে:
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.09s১০০%। প্রতিটি লাইন চলেছে। কিন্তু কেউ কখনো কুপন ছাড়া final_price ডাকেনি। else ছাড়া একটি if-এর বের হওয়ার দুটি পথ — ভেতরের অংশে ঢোকা, অথবা সোজা পাশ কাটিয়ে যাওয়া — আর চেষ্টা হয়েছে কেবল একটির। পরে কেউ কুপন-ছাড়া পথটি ভেঙে ফেললে এই রিপোর্ট টেরই পাবে না।
লাইন coverage জিজ্ঞেস করে "এই লাইনটি চলেছে কি?"। Branch coverage জিজ্ঞেস করে "প্রতিটি সিদ্ধান্ত কি দুই দিকেই গেছে?"। --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.10sদুটি নতুন কলাম। Branch হলো সম্ভাব্য লাফের সংখ্যা (লাইন ২-এর if যেতে পারে লাইন ৩-এ অথবা লাইন ৪-এ: দুটি)। BrPart গোনে সেই সিদ্ধান্তগুলো যেগুলো কেবল এক দিকে গেছে। আর Missing এখন দেখাচ্ছে 2->4: লাইন ২ থেকে সোজা লাইন ৪-এ লাফ — "কুপন নেই" পথটি — কখনো ঘটেনি।
Branch coverage বেশি কড়া আর সত্যের বেশি কাছে। এটা সবসময় চালু না রাখার তেমন কোনো কারণ নেই।
সংখ্যাটিকে দরজা বানানো: --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। প্রতিটি টেস্ট পাস করেছে, তবুও কমান্ডটি status 1 দিয়ে শেষ হয়। CI-তে সেটা লাল বিল্ড। কথাটা এই নয় যে ৯০ কোনো জাদুকরী সংখ্যা; কথাটা হলো, কেউ টেস্ট ছাড়া কোড যোগ করলে coverage আর চুপচাপ নেমে যেতে পারবে না।
আজ আপনি যেখানে আছেন তার একটু নিচে একটি সীমা বেছে নিন, আর সময়ের সাথে বাড়ান। প্রথম দিনেই ১০০ বসালে সাধারণত দেখা যায়, মানুষ শুধু সংখ্যা মেটাতে অর্থহীন টেস্ট লিখছে।
জেনেশুনে কোড বাদ দেওয়া: # pragma: no cover
কিছু লাইন টেস্ট করার মতো নয় — ধরুন এমন একটি ব্লক যা কেবল হাতে ফাইলটি চালালে চলে। দ্বিতীয় একটি টেস্ট যোগ করুন, 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লাইন ৮, print, pytest-এর অধীনে কখনো চলে না — আর কখনো চলবেও না। if-টিকে এমন একটি মন্তব্য দিয়ে চিহ্নিত করুন যা coverage বোঝে:
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.08spragma যখন একটি ব্লক খোলে এমন লাইনে বসে, তখন পুরো ব্লকটাই বাদ যায়। এটা কম আর সৎভাবে ব্যবহার করুন। প্রতিটি pragma এমন একটি লাইন যা আপনি যাচাই না করার সিদ্ধান্ত নিয়েছেন; যদি দেখেন কোনো কঠিন শাখাকে "সরিয়ে দিতে" একটি যোগ করছেন, তাহলে সম্ভবত সেই শাখাটিরই টেস্ট সবচেয়ে বেশি দরকার।
সব pyproject.toml-এ রাখা
প্রতিবার চারটি --cov ফ্ল্যাগ টাইপ করা মানেই একদিন ভুলে যাওয়া। Coverage নিজের সেটিং 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-ই মাপে, রিপোর্ট দেয় আর নিয়ম খাটায়। নিচের পূর্ণাঙ্গ উদাহরণে এটা চলতে দেখবেন।
১০০% coverage মানে সঠিক নয়
Coverage উত্তর দেয় "এই লাইনটি চলেছে কি?"। উত্তর দেয় না "কেউ কি যাচাই করেছে ফলাফলটি সঠিক?"। একটি ছোট উদাহরণ:
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 দিয়েও বিভাজ্য হয়। আরেকটি টেস্ট, যা বেছে নেওয়া হয়েছে লাইনের কথা নয়, নিয়মের কথা ভেবে:
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এখনো ১০০% — আর এখন একটি ফেল। Coverage এই বাগের দিকে আঙুল তুলতে পারত না, কারণ কোনো লাইন বাদ পড়েনি; বাদ পড়েছিল একটি কেস। Coverage-কে দেখুন সেই মানচিত্র হিসেবে যেখানে আপনি নিশ্চিতভাবে তাকাননি, কখনোই এই প্রমাণ হিসেবে নয় যে যেখানে তাকিয়েছেন তা ঠিক।
pytest-xdist দিয়ে টেস্ট সমান্তরালে চালানো
স্যুট বড় হলে ধীর হয়, আর ধীর স্যুট কম চালানো হয়। pytest-xdist টেস্টগুলোকে কয়েকটি প্রসেসে ভাগ করে দেয়। আটটি টেস্ট, প্রতিটি আধা সেকেন্ড নেয়:
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 core-এর জন্য একটি worker চালু করে (এই মেশিনে চারটি) আর প্রত্যেককে চালানোর জন্য টেস্ট দেয়। এটা --cov-এর সাথে একসঙ্গে কাজ করে; সব worker-এর coverage ডেটা মিলে একটি রিপোর্ট হয়।
এর একটা দাম আছে। প্রতিটি worker আলাদা প্রসেস, আর টেস্ট চলার ক্রম আর ফাইলের ক্রম নয়। যে টেস্ট চুপচাপ অন্য একটি টেস্ট আগে চলার ওপর ভরসা করত, সেটি ভাঙবে:
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 ==========================দ্বিতীয় টেস্টটি পাস করেছিল কেবল এ কারণে যে প্রথমটি আগেই ভাগ-করা তালিকাটি ভরে রেখেছিল। অন্য worker-এ তালিকা ছিল খালি। pytest test_cart.py::test_cart_has_one_item একা চালালেও একইভাবে ফেল করে — xdist বাগটি বানায়নি, ফাঁস করেছে।
সমাধান fixture-এর অধ্যায়গুলোর সেই একই: প্রতিটি টেস্টকে তার নিজস্ব অবস্থা দিন।
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 ===============================একটি টেস্টের পাস করা উচিত একা, যেকোনো ক্রমে, যেকোনো worker-এ। pytest-randomly নামে একটি plugin আছে যা পুরোপুরি এই ধারণার ওপর দাঁড়ানো: এটি প্রতিটি রানে টেস্টের ক্রম এলোমেলো করে দেয়, যাতে লুকানো নির্ভরতা মাস কয়েক পরে CI-তে নয়, আগেভাগে আপনার মেশিনেই ধরা পড়ে। এই কোর্স অনুসরণ করতে এটা লাগবে না; এটুকু জানাই যথেষ্ট যে "স্যুট কেবল ফাইলের ক্রমে পাস করে" — এটা একটা বাগ।
প্রতিটি push-এ চালানো: 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-এ প্রতিটি push-এ আর প্রতিটি pull request-এ চালাও।matrix:— job-টি তিনবার চলে, প্রতিটি পাইথন সংস্করণের জন্য একবার, পাশাপাশি।fail-fast: falseতিনটিকেই শেষ হতে দেয়, যাতে দেখতে পান কোনো ফেল কেবল একটি সংস্করণের কিনা।setup-uvuv ইনস্টল করে আর 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-এর পরে, একই কমান্ড একই ফল দেয় (আউটপুটের শেষ লাইনগুলো):
$ 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-এর প্রজেক্ট, এই অধ্যায়ের দুটি মডিউলই, আর সব সেটিং এক জায়গায়।
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml হলো ওপরের কনফিগারেশন অংশেরটি। coupons.py তার __main__ ব্লক রেখে দিয়েছে, কোনো pragma ছাড়াই — 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 ===============================তিনটি জিনিস লক্ষ করার মতো।
প্রথমত, সাধারণ 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 সাল।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
শুধু একটি টেস্ট আছে, আর branch coverage চালু। 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- A3
- Bকিছুই না — ১০০% কভার
- C2->4
- D4
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-এ একটি বাগ আছে; টেস্টগুলো ঠিক আছে
- B`-n 2` টেস্টগুলোকে দুবার করে চালায়
- C`CART` মডিউল স্তরের তালিকা হওয়ায় তালিকা প্রতিটি worker-এ নতুন করে তৈরি হয় না
- Dদ্বিতীয় টেস্টটি প্রথমটির চালানোর ওপর নির্ভর করে — দুটো টেস্ট একই অবস্থা ভাগ করে নেয়
একটি ফাংশনের line ও branch coverage দুটোই ১০০%, আর সব টেস্ট পাস করছে। এ থেকে কী বলা যায়?
- Aফাংশনটি সঠিক, আর কোনো টেস্ট লাগবে না
- Bশুধু এটুকু যে প্রতিটি লাইন ও সিদ্ধান্তের প্রতিটি দিক অন্তত একবার চলেছে — ফলাফল সঠিক কিনা তা নয়
- Cফাংশনে কোনো boundary বাগ থাকতে পারে না
- D`fail_under = 100` রাখলে ফাংশনে আর বাগ ঢুকতে পারবে না
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
এই মডিউল দিয়ে একটি ছোট প্রজেক্ট শুরু করুন, 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চুক্তি: প্রতি পুরো ১০০ খরচে এক পয়েন্ট, সদস্যদের জন্য দ্বিগুণ; ঋণাত্মক মোট একটি ত্রুটি; শূন্য অনুমোদিত। এর সাথে আসে মাত্র একটি টেস্ট, tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4আর একটি pyproject.toml যা প্রতিটি রানে branch coverage মাপে:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = truepytest -qচালান আরMissingকলাম পড়ুন। প্রতিটি এন্ট্রির অর্থ কথায় বলুন।- কোনো টেস্ট লেখার আগে, চুক্তির জন্য একটি টেস্ট পরিকল্পনার টেবিল লিখুন — ঠিক ১০০-এর সীমানা আর তার ঠিক নিচের কিনারাসহ।
- পরিকল্পনাটিকে একটি parametrize-করা টেস্ট আর একটি
pytest.raisesটেস্টে রূপ দিন। [tool.coverage.report]-এর নিচেfail_under = 100যোগ করুন আর নিশ্চিত হোন রানটি পাস করে।- অ-সদস্যের সারিগুলো মুছে ফেলুন আর নিশ্চিত হোন রানটি ফেল করে — যদিও বাকি প্রতিটি টেস্ট পাস করে।
pytest -n autoদিয়ে স্যুট চালান আর নিশ্চিত হোন ফল হুবহু একই।
সমাধান
ধাপ ১ — রিপোর্ট পড়া।
$ 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-এ লাফ — অ-সদস্যের পথ, কখনো নেওয়া হয়নি। branch coverage ছাড়া 5->7 দেখাই যেত না, কারণ লাইন ৬ চলেছিল (সদস্যের জন্য)।
ধাপ ২ — পরিকল্পনা।
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সদস্য, দ্বিগুণ | 250, True | 4 | | অ-সদস্য | 250, False | 2 | | প্রথম ১০০-এর ঠিক নিচে | 99.99, False | 0 | | সীমানা: ঠিক ১০০ | 100, False | 1 | | শূন্য অনুমোদিত | 0, True | 0 | | অবৈধ: ঋণাত্মক | -1, True | ValueError, "cannot be negative" |
ধাপ ৩ — টেস্টগুলো। 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)ধাপ ৪ — দরজা। 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ধাপ ৫ — প্রমাণ যে দরজা কাজ করে। তিনটি 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, exit status 1। প্রতিটি লাইন এখনো চলে — শুধু শাখাটি নেই — আর দরজা সেটা ধরে ফেলে। সারিগুলো ফিরিয়ে দিন।
ধাপ ৬ — সমান্তরালে।
$ 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 ===============================একই ছয়টি টেস্ট, একই ১০০%: চারটি worker-এর coverage মিলে একটি রিপোর্ট হয়েছে।
প্রতিটি সিদ্ধান্তের কারণ।
- পরিকল্পনা এসেছে টেস্টের আগে। ছয়টি সারির মধ্যে রিপোর্ট চেয়েছিল মাত্র দুটি (
3আর5->7)।99.99আর100আছে কারণ চুক্তির ১০০-তে একটি কিনারা আছে, আর0, Trueআছে কারণ চুক্তি বলে শূন্য অনুমোদিত — এটা একটা টেস্ট যে এখানে ত্রুটি হয় না। এগুলোর একটিও ছাড়া coverage সন্তুষ্ট থাকত। - এখানে কেন `-1` চলে। শর্তটি হলো
total < 0, তাই কিনারাটি নিজে0, আর0, Trueসারি সেটাকে বৈধ হিসেবে আগেই বেঁধে দিয়েছে। কেউ শর্তটিtotal <= 0করে দিলে সেই সারি ফেল করবে। তাহলে অবৈধ দিকের জন্য যেকোনো ঋণাত্মক সংখ্যাই চলে। - কেন এখানে `fail_under = 100`, কিন্তু আসল প্রজেক্টে নয়। এত ছোট একটি মডিউল যুক্তিসঙ্গতভাবে পুরোপুরি কভার করা যায়। বড় কোডবেসে আজকের সংখ্যার ঠিক নিচ থেকে শুরু করুন আর বাড়ান।
- কেন `-n auto` রানটি কিছু প্রমাণ করে। প্রতিটি কেস নিজের আর্গুমেন্ট পায় আর টেস্টগুলোর মধ্যে কিছুই ভাগ হয় না, তাই ক্রম বা worker কোনো ব্যাপার নয়।
-n auto-তে ফল বদলালে সেটাই হতো প্রথমে ঠিক করার বাগ।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন