অধ্যায় 06

parametrize — এক টেস্ট, অনেক কেস

কপি করা টেস্ট আর টেস্টের ভেতরের লুপের বদলে @pytest.mark.parametrize। টেস্ট ID, ids= আর pytest.param, decorator স্ট্যাক করা, একই টেবিলে মান আর ত্রুটি, আর সাধারণ ভুলগুলো।

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

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

এখানে একটি ছোট ফাংশন, যেটি পরীক্ষার নম্বরকে গ্রেডে বদলায় — grading.py:

python
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"
text
$ 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। প্রথমবার সবাই যেভাবে লেখে তা হলো একটি টেস্ট কপি করে দুটি মান বদলে দেওয়া:

python
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"
text
$ pytest -q test_copy.py
...                                                                      [100%]
3 passed in 0.01s

কাজ করে, কিন্তু বাড়ানো যায় না। তিনটি ফাংশন একই কথা বলছে — "এই নম্বর এই গ্রেড দেয়" — আর এদের মধ্যে পার্থক্য কেবল দুটি মানে। আরও দশটি কেস মানে আরও দশটি কপি, যেগুলো একসাথে মিলিয়ে রাখতে হবে।

তাই পরের ভাবনা: কেসগুলোর একটি তালিকা, আর একটি টেস্টের ভেতরে লুপ। নিচের দুটি প্রত্যাশিত গ্রেড ইচ্ছে করে ভুল — 79 হওয়া উচিত B, আর 40 হওয়া উচিত C:

python
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
text
$ 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 প্রতিটি মানের জন্য একটি টেস্ট বানায় আর মানটি সেই নামে পাঠিয়ে দেয়:

python
import pytest

from grading import grade


@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
    assert grade(score) == "A"
text
$ 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 হিসেবে দিন। আগের লুপটিই আবার, সেই দুটি ভুল প্রত্যাশাসহ:

python
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
text
$ 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, কোনো বস্তু — সে চেষ্টাই করে না। ব্যবহার করে আর্গুমেন্টের নাম আর কেসের অবস্থান:

python
import pytest


@pytest.mark.parametrize(
    ["value", "expected"],
    [
        (1.5, True),
        ("hello world", None),
        ([1, 2], {"a": 1}),
    ],
)
def test_show_ids(value, expected):
    pass
text
$ 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, প্রতি কেসে একটি, একই ক্রমে:

python
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
text
$ 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 সেটিকে ডাকে প্রতিটি মানের জন্য একবার, প্রতিটি কেসের জন্য নয়, আর ফলগুলো - দিয়ে জোড়ে:

python
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
text
$ 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= যা কেবল সেই কেসেই খাটে।

python
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
text
$ 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 তাদের মানের প্রতিটি জোড়ার জন্য টেস্ট চালায় — কার্টেসিয়ান গুণফল। দুটি মুদ্রা গুণ তিনটি অঙ্ক = ছয়টি টেস্ট:

python
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)
text
$ 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 দিয়ে তাতে ঢুকতে পারেন:

python
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
text
$ 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-টি একটি টেস্ট ক্লাসের উপর বসালে তার প্রতিটি টেস্ট মেথড প্যারামিটারটি পায়:

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

python
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
text
$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160

test_shipping.py:

python
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
text
$ 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)। এটি কালেকশনের সময়ই ব্যর্থ হয়, কোনো টেস্ট চলার আগে:

python
import pytest

from grading import grade


@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
    assert grade(score) == expected
text
$ 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.01s

fixture 'expected' not found উল্টো ভুল: ফাংশনের এমন একটি প্যারামিটার আছে যার নাম decorator কখনো বলেনি, তাই pytest সেই নামের fixture খুঁজতে যায় আর পায় না। নামটি decorator-এর স্ট্রিংয়ে যোগ করুন।

the number of names (2) ... must be equal to the number of values (1) কোনো একটি কেসে মানের সংখ্যা ভুল — যেমন "score,expected"-এর তালিকায় (40,):

text
$ 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 পায়:

python
import pytest

from grading import grade


@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
    assert grade(score) == "A"
text
$ 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 — বদলায়, পরের টেস্ট সেই বদলটা দেখে:

python
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 == []
text
$ 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 থেকে শুরু করে, আর দুটিই পাস করে।