parametrize — এক টেস্ট, অনেক কেস
কপি করা টেস্ট আর টেস্টের ভেতরের লুপের বদলে @pytest.mark.parametrize। টেস্ট ID, ids= আর pytest.param, decorator স্ট্যাক করা, একই টেবিলে মান আর ত্রুটি, আর সাধারণ ভুলগুলো।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এখানে একটি ছোট ফাংশন, যেটি পরীক্ষার নম্বরকে গ্রেডে বদলায় — grading.py:
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C Fঠিকমতো টেস্ট করা মানে অনেকগুলো নম্বর যাচাই করা, বিশেষ করে সীমানার নম্বরগুলো: 80 আর 79, 60 আর 59, 40 আর 39। প্রথমবার সবাই যেভাবে লেখে তা হলো একটি টেস্ট কপি করে দুটি মান বদলে দেওয়া:
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sকাজ করে, কিন্তু বাড়ানো যায় না। তিনটি ফাংশন একই কথা বলছে — "এই নম্বর এই গ্রেড দেয়" — আর এদের মধ্যে পার্থক্য কেবল দুটি মানে। আরও দশটি কেস মানে আরও দশটি কপি, যেগুলো একসাথে মিলিয়ে রাখতে হবে।
তাই পরের ভাবনা: কেসগুলোর একটি তালিকা, আর একটি টেস্টের ভেতরে লুপ। নিচের দুটি প্রত্যাশিত গ্রেড ইচ্ছে করে ভুল — 79 হওয়া উচিত B, আর 40 হওয়া উচিত C:
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sরিপোর্টটি দেখতে যতটা খারাপ, আসলে তার চেয়েও খারাপ। ভুল কেস ছিল দুটি, আর খবর পেলেন একটির: প্রথম ব্যর্থ assert-এই টেস্ট শেষ, তাই 40-এর কেস কখনো চলেইনি। কোন নম্বরটি ব্যর্থ হলো তা-ও রিপোর্ট বলে না — আপনাকেই বের করতে হয় যে 'B' == 'C' এসেছে 79 থেকে। আর "1 failed" পাঁচটি কেসকে একটিমাত্র ঘটনা হিসেবে গোনে। প্রথমটি ঠিক করুন, আবার চালান, তবেই দ্বিতীয়টির দেখা পাবেন।
আপনার দরকার লুপের সংক্ষিপ্ততা আর কপিগুলোর মতো রিপোর্ট: একটি ফাংশন, অনেকগুলো আলাদা টেস্ট।
এই অধ্যায় শেষে আপনি পারবেন
@pytest.mark.parametrizeদিয়ে একটি টেস্ট ফাংশন অনেক কেসে চালাতে- প্রতি কেসে একাধিক মান দিতে, আর pytest-এর বানানো টেস্ট ID পড়তে
ids=আরpytest.param(..., id=...)দিয়ে কেসের নাম দিতে- বাকিগুলো না ছুঁয়ে একটিমাত্র কেসকে
xfailবাskipচিহ্নিত করতে - দুটি parametrize decorator মিলিয়ে তাদের মানগুলোর প্রতিটি জোড়া চালাতে
- "মান ফেরত দেয়" আর "ত্রুটি তোলে" — দুই ধরনের কেস একই টেবিলে রাখতে
- parametrize-এর সাধারণ ভুলগুলো তাদের ত্রুটির বার্তা দেখে চিনতে
আগে যা জানা লাগবে: `pytest.approx` দিয়ে float তুলনা।
টেস্ট লেখার আগে
parametrize আসলে একটি টেবিল লিখে রাখার উপায়। তাই টেস্টের কোড লেখার আগের কাজ হলো সেই টেবিলটি বানানো, আর ঠিক করা যে টেবিলই সঠিক আকার কি না।
১. চুক্তিটি স্পষ্ট করুন। grade(score) কথা দেয়: 0 থেকে 100-এর একটি পূর্ণ নম্বর ঢোকে; বেরোয় "A", "B", "C", "F"-এর একটি; 0–100-এর বাইরের যেকোনো কিছু ValueError তোলে, যার বার্তায় নম্বরটির উল্লেখ থাকে। এর কোনো পার্শ্বপ্রতিক্রিয়া নেই — ফাইল নেই, প্রিন্ট নেই — তাই সেট আপ বা পরিষ্কার করার কিছু নেই। এজন্যই এটি উপযুক্ত: প্রতিটি কেস হলো "এই ইনপুট, ওই ফল"।
২. প্রস্তুতি যাচাই করুন। দরকার চালু থাকা virtual environment, ইনস্টল করা pytest, আর টেস্ট ফাইল থেকে import করা যায় এমন grading.py — এই উদাহরণগুলোতে সেটি একই ফোল্ডারে টেস্টের পাশেই আছে। আর কিছু নয়: parametrize pytest-এর ভেতরেই আছে, আর contextlib স্ট্যান্ডার্ড লাইব্রেরির অংশ।
৩. কেসগুলো পরিকল্পনা করুন। সীমানাই সবচেয়ে গুরুত্বপূর্ণ, কারণ >= আর >-এর ভুল সেখানেই লুকিয়ে থাকে। প্রতিটি নিয়মের রেখার দুই পাশ থেকে একটি করে মান:
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সীমার সর্বোচ্চ | 100 | "A" | | সর্বনিম্ন A | 80 | "A" | | সর্বোচ্চ B | 79 | "B" | | সর্বনিম্ন B | 60 | "B" | | সর্বোচ্চ C | 59 | "C" | | সর্বনিম্ন C | 40 | "C" | | সর্বোচ্চ F | 39 | "F" | | সীমার সর্বনিম্ন | 0 | "F" | | সীমার ঠিক নিচে | -1 | ValueError | | সীমার ঠিক উপরে | 101 | ValueError |
প্রতিটি সারি একটি কেস হবে। "কেস" কলামটি সাজসজ্জা নয় — পরে এটাই টেস্ট ID হবে, ফলে ব্যর্থতা দেখে বোঝা যাবে কোন নিয়মটি ভেঙেছে।
৪. কী টেস্ট করবেন না, ঠিক করুন। 0 থেকে 100-এর প্রতিটি নম্বর নয়: 95 আর 85 সেই একই নিয়ম যাচাই করে যা 80 করে, নতুন তথ্য ছাড়াই কেবল সময় বাড়ায়। প্রতিটি সীমানার প্রতি পাশে একটি মানই যথেষ্ট। পাইথনের নিজের তুলনা অপারেটর টেস্ট করবেন না, আর কেবল টেবিলে আঁটে বলে দুটি আলাদা আচরণ এক টেস্টে রাখবেন না — সারিগুলোর যদি আলাদা assertion লাগে, তবে সেগুলো আলাদা টেস্টের জিনিস।
অধ্যায়ের বাকি অংশ এই টেবিলকে কোডে রূপ দেয়, এক এক করে প্রতিটি হাতিয়ার দিয়ে।
একটি আর্গুমেন্ট, অনেক মান
decorator নেয় একটি আর্গুমেন্টের নাম আর তার জন্য মানের একটি তালিকা। pytest প্রতিটি মানের জন্য একটি টেস্ট বানায় আর মানটি সেই নামে পাঠিয়ে দেয়:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================একটি ফাংশন, তিনটি টেস্ট — প্রত্যেকটির নিজের লাইন আর নিজের নাম। বর্গবন্ধনীর ভেতরের অংশটি টেস্ট ID, মান থেকে বানানো। score কোনো fixture নয়, এর ডিফল্ট মানও নেই — decorator-এর স্ট্রিংয়ের নামটি কেবল ফাংশনের একটি প্যারামিটারের সাথে মিলতে হবে।
প্রতি কেসে একাধিক আর্গুমেন্ট
বেশিরভাগ কেসে লাগে একটি ইনপুট আর একটি প্রত্যাশিত ফল। দুটোর নাম কমা দিয়ে আলাদা করে লিখুন, আর প্রতিটি কেস একই ক্রমে একটি tuple হিসেবে দিন। আগের লুপটিই আবার, সেই দুটি ভুল প্রত্যাশাসহ:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sলুপের সাথে তুলনা করুন। প্রতিটি কেস চলেছে। দুটি ব্যর্থতাই জানানো হয়েছে, প্রতিটি এমন একটি ID-র নিচে — test_grade[79-C] — যা কেসটির নাম বলে দেয়। গণনাও সৎ: 2 failed, 3 passed। (ডিফল্ট লম্বা traceback-এ pytest প্রতিটি ব্যর্থ কেসের আর্গুমেন্টও ছাপে, score = 79, expected = 'C'।)
নামগুলো লেখা যায় একটি স্ট্রিংয়ে — "score,expected" বা "score, expected", কমার পর স্পেস চলে — অথবা স্ট্রিংয়ের একটি list হিসেবে, ["score", "expected"]। দুটোই হুবহু একই টেস্ট বানায়। স্ট্রিংটিই প্রচলিত রূপ; নামগুলো কোড দিয়ে তৈরি হলে list সুবিধাজনক।
ID কীভাবে তৈরি হয়
সংখ্যা, স্ট্রিং, বুলিয়ান আর None-এর ক্ষেত্রে pytest মানটিকেই ID-তে বসায়, আর এক কেসের মানগুলো - দিয়ে জোড়ে। অন্য যেকোনো কিছুর জন্য — list, dict, কোনো বস্তু — সে চেষ্টাই করে না। ব্যবহার করে আর্গুমেন্টের নাম আর কেসের অবস্থান:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2 মানে "শূন্য থেকে গুনলে ২ নম্বর কেসের value আর expected"। ID-টি অনন্য, কিন্তু কিছুই বলে না। এমন কেস ব্যর্থ হলে তালিকা ধরে গুনে গুনে খুঁজতে হয়। নিজে কেসের নাম দেওয়ার কারণ এটাই।
কেসের নাম দেওয়া: ids=
ids=-কে দিন স্ট্রিংয়ের একটি list, প্রতি কেসে একটি, একই ক্রমে:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================এখন আউটপুট বলছে প্রতিটি কেস কেন আছে। দুর্বলতা হলো, নামগুলো থাকে একটি দ্বিতীয় তালিকায়, কেস থেকে দূরে: মাঝখানে একটি কেস ঢুকিয়ে তার নাম দিতে ভুলে গেলে পরের প্রতিটি নাম এক ঘর সরে যায়।
ids= একটি ফাংশনও নেয়। pytest সেটিকে ডাকে প্রতিটি মানের জন্য একবার, প্রতিটি কেসের জন্য নয়, আর ফলগুলো - দিয়ে জোড়ে:
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================যেসব মানের স্বয়ংক্রিয় ID অকেজো, যেমন বস্তু, তাদের জন্য ফাংশনই ভালো। কোনো মানের জন্য ফাংশনটি None ফেরত দিলে pytest সেই মানের স্বয়ংক্রিয় ID-তে ফিরে যায়।
এক এক করে কেস: pytest.param
pytest.param একটিমাত্র কেসকে মুড়ে দেয় আর তার সাথে কিছু জুড়তে দেয়: এমন একটি id= যা মানগুলোর ঠিক পাশে বসে, আর marks= যা কেবল সেই কেসেই খাটে।
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================round-up কেসটি এমন একটি সিদ্ধান্ত লিখে রাখে যা এখনো নেওয়া হয়নি — 59.5 কি B-তে উঠে যাবে? — রান ব্যর্থ না করেই, আর বাকি কেসগুলো অক্ষত থাকে। (xfail আর skip-এর নিজস্ব অধ্যায় পরে আসছে; আপাতত জানুন যে mark একটি কেসেও বসানো যায়।)
ID যেহেতু সত্যিকারের নাম, একটি কেস একাই চালানো যায়: pytest "test_param_obj.py::test_grade_cases[perfect]"। উদ্ধৃতিচিহ্ন দিন — শেল বর্গবন্ধনীকে বিশেষভাবে দেখে।
দুটি decorator: প্রতিটি সমন্বয়
দুটি parametrize decorator একটির উপর আরেকটি বসালে pytest তাদের মানের প্রতিটি জোড়ার জন্য টেস্ট চালায় — কার্টেসিয়ান গুণফল। দুটি মুদ্রা গুণ তিনটি অঙ্ক = ছয়টি টেস্ট:
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================ফাংশনের সবচেয়ে কাছের decorator ID-র প্রথম অংশ দেয়। স্ট্যাক করুন কেবল তখনই যখন ইনপুটগুলো সত্যিই স্বাধীন — প্রতিটি অঙ্ক প্রতিটি মুদ্রার সাথে চলা উচিত। যদি কেবল নির্দিষ্ট কিছু জোড়া অর্থবহ হয়, সেই জোড়াগুলো একটি decorator-এ tuple হিসেবে লিখুন। আর গুণটার দিকে নজর রাখুন: দশটি করে মানের তিনটি decorator মানে এক হাজার টেস্ট।
একই টেবিলে মান আর ত্রুটি
কখনো কেসগুলো দুই ভাগে পড়ে: "এটা ফেরত দেয়" আর "ওটা তোলে"। আপনি প্রত্যাশাটিকেই একটি প্যারামিটার বানাতে পারেন, আর with দিয়ে তাতে ঢুকতে পারেন:
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontext এমন একটি context manager যা কিছুই করে না: with nullcontext("C") as expected কেবল expected = "C" বসায়, তাই সাধারণ কেসগুলো স্বাভাবিকভাবে assert চালায়। ত্রুটির কেসে context manager হলো pytest.raises: grade তার ভেতরে ত্রুটি তোলে, assert কখনো শেষ হয় না, আর ঠিক ত্রুটিটি আসায় টেস্ট পাস করে। কোনো ত্রুটি না এলে পাবেন পরিচিত DID NOT RAISE।
ছোট টেবিলের জন্য এটি গোছানো। টেস্টের ভেতরে যদি দুই ধরন আলাদা করতে if লাগতে শুরু করে, তবে দুটি টেস্টে ভাগ করুন। ID-গুলোও খেয়াল করুন: expectation0 আবার সেই বস্তুর নিয়ম। সম্পূর্ণ উদাহরণে pytest.param দিয়ে সেটা ঠিক করা হয়েছে।
একটি ক্লাস parametrize করা
decorator-টি একটি টেস্ট ক্লাসের উপর বসালে তার প্রতিটি টেস্ট মেথড প্যারামিটারটি পায়:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================দুটি মেথড গুণ দুটি মান: চারটি টেস্ট। ক্লাসের প্রতিটি মেথডকে score নিতে হবে, নইলে কালেকশন ব্যর্থ হবে।
একটা সম্পূর্ণ উদাহরণ
shipping.py:
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================তিনটি ফাংশন, চৌদ্দটি টেস্ট, আর প্রতিটি লাইন বলছে সে কী যাচাই করল। তিনটি বিষয় লক্ষ করার মতো।
প্রথমত, test_cost-এর ID-গুলো প্রতিটি কেসের কারণ বলে — part-kg-rounds-up যে নিয়মটি রক্ষা করা হচ্ছে তার নাম দেয়, যা 1.2-local-70 দিত না।
দ্বিতীয়ত, test_first_kg_is_flat স্ট্যাক করে, কারণ ওজন আর অঞ্চল সত্যিই স্বাধীন। এটি ফাংশনকে কোনো সংখ্যার সাথে নয়, নিজের সাথেই তুলনা করে, তাই রেট বদলালেও টিকে থাকে।
তৃতীয়ত, pytest.param(..., id=...) expectation2-কে বানিয়ে দেয় negative। মান আর ত্রুটির টেবিলটি এখন একটি স্পেসিফিকেশনের মতো পড়া যায়।
কিছু ভাঙা অবস্থা ও তার সমাধান
function uses no argument 'scores' decorator-এর স্ট্রিংয়ের একটি নাম ফাংশনের কোনো প্যারামিটারের সাথে মেলে না — এখানে "scores,expected" বনাম def test_grade(score, expected)। এটি কালেকশনের সময়ই ব্যর্থ হয়, কোনো টেস্ট চলার আগে:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found উল্টো ভুল: ফাংশনের এমন একটি প্যারামিটার আছে যার নাম decorator কখনো বলেনি, তাই pytest সেই নামের fixture খুঁজতে যায় আর পায় না। নামটি decorator-এর স্ট্রিংয়ে যোগ করুন।
the number of names (2) ... must be equal to the number of values (1) কোনো একটি কেসে মানের সংখ্যা ভুল — যেমন "score,expected"-এর তালিকায় (40,):
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)[score0]-এর মতো ID আর tuple-এর উল্লেখসহ TypeError আর্গুমেন্টের নাম একটি হলে তালিকার প্রতিটি মান গোটা অবস্থায় যায়। অভ্যাসবশত সেখানে tuple লিখলে ফাংশন একটি tuple পায়:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================সূত্র হলো ID: সাধারণ সংখ্যা হলে [80] দেখাত, তাই [score0] বলছে মানটি অন্য কিছু ছিল। একটি আর্গুমেন্টের জন্য লিখুন [80, 95]।
একা চালালে কেস পাস করে, অন্যদের সাথে চালালে ব্যর্থ হয় প্যারামিটারের মান তৈরি হয় একবারই, মডিউল কালেক্ট হওয়ার সময়, আর যে টেস্টই সেটি পায় তাকে একই বস্তু দেওয়া হয়। কোনো টেস্ট যদি পরিবর্তনযোগ্য মান — list, dict — বদলায়, পরের টেস্ট সেই বদলটা দেখে:
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sঅপরিবর্তনীয় কিছু দিন, যেমন tuple (), আর প্রতিটি টেস্টের ভেতরে cart = list(items) দিয়ে নতুন বস্তু বানান। তখন প্রতিটি টেস্ট নিজের খালি list থেকে শুরু করে, আর দুটিই পাস করে।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
pytest -v চালালে কোন চারটি টেস্ট ID, কোন ক্রমে দেখা যাবে?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
- Btest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Ctest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Dtest_pair[1-x], test_pair[2-y]
grade(80) আর grade(95) দুটোই "A" দেয়, তবু দুটি টেস্টই ব্যর্থ হয়। কারণ কী?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- Aএকটিমাত্র আর্গুমেন্ট নাম দিয়ে parametrize ব্যবহার করা যায় না
- Bনাম একটি হলে প্রতিটি মান গোটা অবস্থায় যায়, তাই `score` হলো tuple `(80,)`, `80` নয়
- C`score0` আর `score1` ID দুটি ফাংশনের নামের সাথে সংঘর্ষ করে
- Dমানগুলো list নয়, tuple-এর tuple হতে হবে
এখানে দুটি প্রত্যাশা ভুল (79 হওয়া উচিত B, 40 হওয়া উচিত C)। pytest কী রিপোর্ট করবে?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — প্রতিটি কেসের জন্য এক লাইন
- B3 failed, কারণ একটি ব্যর্থতা সব কেসকে ব্যর্থ করে
- C1 failed — 40-এর কেসের জন্য, শেষ ভুলটির জন্য
- D1 failed — কেবল প্রথম ভুল কেসটির জন্য; 40-এর কেস কখনো চলেই না
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
password.py লিখুন, যাতে থাকবে check_password(text) নামের একটি ফাংশন, যা সমস্যার একটি তালিকা ফেরত দেয়: 8 অক্ষরের কম হলে "too short", কোনো অঙ্ক না থাকলে "no digit", কোনো অক্ষর না থাকলে "no letter"। ভালো পাসওয়ার্ড ফেরত দেয় []। str নয় এমন যেকোনো কিছু TypeError তোলে।
তারপর test_password.py লিখুন, যাতে থাকবে:
- অন্তত ছয়টি কেসের একটি parametrize করা টেস্ট,
pytest.paramদিয়ে লেখা, আর প্রতিটির এমনid=যা কেসটি কী নিয়ে তা বলে ("seven-chars","digits-only"ইত্যাদি) - দুটি স্ট্যাক করা decorator — তিনটি ভালো পাসওয়ার্ড গুণ দুটি suffix,
""আর"!"— যাচাই করবে যে ছয়টিই[]ফেরত দেয় - একটি টেবিল যা সাধারণ কেস (
nullcontext) আরTypeErrorকেস (pytest.raises) মেশায় — দ্বিতীয় ধরনের জন্যNoneআর পূর্ণসংখ্যা12345678ভালো ইনপুট - এখনো লেখা হয়নি এমন একটি নিয়মের জন্য
xfailচিহ্নিত একটি কেস, যেমন "কোনো বড় হাতের অক্ষর নেই"
pytest -v চালান আর ID-র তালিকাটি এমনভাবে পড়ুন যেন অন্য কেউ লিখেছে। কেবল ID দেখে কি পাসওয়ার্ডের নিয়মগুলো বলতে পারবেন? না পারলে, যতক্ষণ না পারছেন কেসগুলোর নাম বদলাতে থাকুন।
তারপর ইচ্ছে করে একটি প্রত্যাশা ভেঙে আবার চালান। দেখুন ঠিক একটি লাইন ব্যর্থ হচ্ছে কি না, তার ID বলছে কি না সেটি কোন কেস, আর বাকি প্রতিটি কেস তবুও চলেছে কি না।
সমাধান
শুরু চুক্তি দিয়ে, তারপর টেবিল। check_password একটি str নেয় আর পাওয়া প্রতিটি সমস্যার একটি তালিকা ফেরত দেয়, নির্দিষ্ট ক্রমে; str না হলে কিছু ফেরত না দিয়ে TypeError তোলে। password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================কেন এভাবে লেখা:
- সীমানার জোড়াটি আগে।
eight-chars-okআরseven-charsদৈর্ঘ্যের নিয়মের দুই পাশে বসে —<বনাম<=-এর ভুল ঠিক সেখানেই ধরা পড়ত। বাকি প্রতিটি নিয়মের জন্য এমন একটি কেস যা কেবল সেই নিয়মটিই ভাঙে, তাই ব্যর্থতাcheck_password-এর ঠিক একটি লাইনের দিকে আঙুল তোলে। emptyতালিকার ক্রম যাচাই করে। এটিই একমাত্র কেস যা তিনটি নিয়মই ভাঙে, তাই নিশ্চিত করে যে সমস্যাগুলো নির্দিষ্ট ক্রমে আসে — ব্যবহারকারীকে সেগুলো দেখানো কোড এর উপরই ভরসা করবে।- প্রত্যাশিত মানগুলো প্রতিটি কেসে নতুন list, আর টেস্ট সেগুলো কেবল পড়ে। কিছুই বদলানো হয় না, তাই "ভাঙা অবস্থা"-র ভাগ করা বস্তুর ফাঁদ এখানে ঘটতে পারে না।
test_good_passwords-এ স্ট্যাকিং মানানসই, কারণ suffix আর পাসওয়ার্ড স্বাধীন: যেকোনো ভালো পাসওয়ার্ড!থাকুক বা না থাকুক, ভালোই থাকা উচিত।abcdef12-আরabcdef12-!ID সাহায্য ছাড়াই পড়া যায়, তাইids=লাগেনি।- ত্রুটির টেবিলে
pytest.param-এর id ব্যবহার করা হয়েছে, কারণ না হলেnullcontextআরpytest.raisesবস্তুগুলোexpectation0,expectation1হয়ে দেখা দিত।match="int"যাচাই করে যে বার্তায় ভুল ধরনটির নাম আছে, কেবল কোনো একটাTypeErrorহয়েছে তা নয়। xfailকেসটি ভবিষ্যতের একটি নিয়ম লিখে রাখে, রান না ভেঙেই। বড় হাতের অক্ষরের নিয়ম যোগ হলে কেসটি পাস করতে শুরু করবে, আর pytest সেটিকেXPASSহিসেবে জানাবে — mark সরানোর সংকেত।
উপর থেকে নিচে ID-গুলো পড়লেই নিয়মগুলো পাওয়া যায়: আট অক্ষর, অন্তত একটি অঙ্ক, অন্তত একটি অক্ষর, কেবল স্ট্রিং, বড় হাতের অক্ষর পরিকল্পনায়। নামগুলো যথেষ্ট ভালো কি না, তার পরীক্ষা এটাই।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন