অধ্যায় 12

Marker, skip আর xfail — কোন টেস্ট কোথায় চলবে

skip, skipif, importorskip আর xfail দিয়ে প্রতিটি টেস্টের প্রয়োজন ও প্রত্যাশা জানানো; strict=True, raises= আর XPASS-এর অর্থ; নিজের marker রেজিস্টার করে -m দিয়ে বাছাই; সাথে parametrize করা fixture আর factory fixture।

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

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

ছোট একটি প্রজেক্ট, shop, যাতে একটি মডিউল আর দুটি টেস্ট ফাইল। money.py:

python
def parse_price(text: str) -> float:
    return float(text.replace(",", ""))


def split_bill(total: float, people: int) -> float:
    return round(total / people, 2)

test_money.py সাধারণ মান দিয়ে এটি পরীক্ষা করে। test_export.py দাম পড়ে YAML থেকে, আর তার জন্য লাগে বাইরের প্যাকেজ yaml:

python
import yaml

from money import parse_price


def test_prices_from_yaml():
    data = yaml.safe_load("pen: '15.00'")
    assert parse_price(data["pen"]) == 15.0

যে মেশিনে yaml ইনস্টল করা নেই, সেখানে pytest -q বলে:

text
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
    import yaml
E   ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s

শেষ দুটি লাইন পড়ুন। Interrupted। test_money.py-এর ভালো টেস্ট দুটি চালানোই হয়নি। এই মেশিনে চলতে না পারা একটিমাত্র ফাইল গোটা রানটিকে সাথে নিয়ে ডুবেছে।

যথেষ্ট দিন টিকে থাকা যেকোনো প্রজেক্টের এটাই বাস্তব অবস্থা। কিছু টেস্টের লাগে এমন একটি লাইব্রেরি যা ঐচ্ছিক। কিছু কেবল Linux-এ অর্থবহ। কিছুর লাগে এমন একটি API key যা কেবল CI-এর কাছে আছে। কিছু টেস্ট এমন একটি বাগের কথা বলে যা সবাই জানে, কিন্তু কেউ এখনো ঠিক করেনি। আর কিছু টেস্ট এক মিনিট সময় নেয়, আর প্রতিবার ফাইল সেভ করার পর এক মিনিট অপেক্ষা করতে আপনি চান না।

এই টেস্টগুলো মুছে ফেলা ভুল, কমেন্ট করে রাখাও ভুল। আপনি যা চান তা হলো pytest-কে জানানো — প্রতিটি টেস্টের কী লাগবে আর তার কাছে আপনি কী আশা করেন — তারপর প্রতিটি রানে কী করতে হবে তা pytest-ই ঠিক করুক। এর হাতিয়ারটির নাম marker।

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

  • একটি টেস্ট সবসময়, কোনো শর্তে, বা টেস্টের ভেতর থেকে skip করতে, আর কারণটি দৃশ্যমান রাখতে
  • ঐচ্ছিক প্যাকেজ না থাকলে pytest.importorskip দিয়ে গোটা ফাইল skip করতে
  • জানা বাগকে xfail দিয়ে চিহ্নিত করতে, আর strict=True ও raises= দিয়ে তাকে নিখুঁত করতে
  • রিপোর্টের অক্ষর s, x আর X পড়তে, আর -rs, -rx, -rA দিয়ে বিস্তারিত চাইতে
  • নিজের marker বানাতে, রেজিস্টার করতে, আর --strict-markers দিয়ে বানান-ভুল ধরতে
  • -m "not slow" আর -m "slow and db" দিয়ে টেস্ট বেছে নিতে
  • এমন fixture লিখতে যা প্রতিটি টেস্ট কয়েকবার চালায়, আর এমন fixture যা একটি ফাংশন ফেরত দেয়

আগে যা জানা লাগবে: mocking।


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

Marker টেস্ট কী পরীক্ষা করে তা নিয়ে নয় — সেটা আগের অধ্যায়গুলোতে হয়ে গেছে। Marker হলো দুটি প্রশ্নের উত্তর, যা টেস্ট লেখার আগেই দিতে হয়, অথচ বেশিরভাগ মানুষ উত্তর দেয় ভুল কারণে একটি রান লাল হয়ে যাওয়ার পর।

১. এই টেস্টটি আদৌ চলতে হলে কী লাগবে? এমন একটি প্যাকেজ যা বাধ্যতামূলক dependency নয়, একটি নির্দিষ্ট অপারেটিং সিস্টেম, পাইথনের একটি ভার্সন, গোপন কিছু রাখা একটি environment variable, একটি ডেটাবেস, নেটওয়ার্ক, অনেকটা সময়। এর প্রতিটিই একটি পূর্বশর্ত। সেটি না থাকলে টেস্টটির বলার কিছু নেই: সে পাসও করেনি, ফেলও করেনি।

২. আজ এর কাছে আমি কী আশা করি? সাধারণত, পাস করবে। কিন্তু কোডে যদি একটি জানা বাগ থাকে, আর আপনি সঠিক আচরণটি বর্ণনা করা টেস্টটি লিখছেন, তাহলে আশা করেন এটি ফেল করবে — আর ফেল করবে একটি নির্দিষ্ট উপায়ে।

উত্তরগুলোই marker ঠিক করে দেয়, তাই আগে সেগুলো লিখে ফেলুন। shop প্রজেক্টের পরিকল্পনা দেখতে এরকম:

| কেস | ইনপুট / পরিস্থিতি | যা লাগবে | প্রত্যাশিত ফল | Marker | |---|---|---|---|---| | সাধারণ দাম | "1,250.50" | কিছু না | 1250.5 | নেই | | YAML export | "pen: '15.00'" | yaml প্যাকেজ | যেখানে ইনস্টল আছে সেখানে 15.0 | pytest.importorskip("yaml") | | POSIX পাথ | "data", "prices.csv" | Windows নয় | "data/prices.csv" | skipif(sys.platform == "win32") | | লাইভ রেট | আসল API | RATES_API_KEY সেট করা | একটি রেট, কেবল যেখানে key আছে | টেস্টের ভেতরে pytest.skip() | | মুদ্রার চিহ্ন | "$1,250" | কিছু না | 1250.0 — আজ ValueError (বাগ #42) | xfail(raises=ValueError, strict=True) | | অনেক দাম | 1000টি স্ট্রিং | এক সেকেন্ড বা বেশি | সঠিক যোগফল | slow (নিজের) |

টেবিলটি কলাম ধরে পড়ুন। যা লাগবে কলাম থেকে আসে skip। প্রত্যাশিত ফল কলাম থেকে আসে xfail। আর সময় কতটা লাগে, তা থেকে আসে একটি নিজের marker, যা দিয়ে আপনি বেছে নেন কী চালাবেন।

এর কোনোটি কাজ করার আগে যা প্রস্তুত থাকা চাই:

  • pytest ইনস্টল করা একটি virtual environment, আর টেস্ট থেকে money.py import করা যায় এমন অবস্থা (আগের অধ্যায়গুলোর মতো প্রজেক্টের রুট থেকে চালান);
  • প্রজেক্টের রুটে একটি pyproject.toml, কারণ নিজের marker সেখানেই রেজিস্টার করা হয়;
  • বাগটি কোথাও একটি নম্বরসহ লিখে রাখা, যাতে reason= সেটির দিকে ইঙ্গিত করতে পারে।

আর যা marker দিয়ে চিহ্নিত করবেন না:

  • কোনো টেস্ট ফেল করছে আর কেন তা জানেন না বলে সেটিকে skip করবেন না। Skip পরের প্রতিটি রান থেকে ব্যর্থতাটি লুকিয়ে ফেলে। হয় ঠিক করুন, নয়তো এতটা বুঝুন যাতে কারণসহ xfail(raises=...) লিখতে পারেন।
  • অস্থির (flaky) টেস্টকে xfail করবেন না। যে টেস্ট মাঝে মাঝে পাস করে, সে কোনোদিন X আর কোনোদিন x দেখাবে, আর strict=True থাকলে এলোমেলোভাবে ফেল করবে। অস্থিরতা টেস্টের নিজের বাগ; সেটাই ঠিক করুন।
  • সবকিছুকে slow লেবেল দেবেন না। প্রতিটি রানে সুইটের বেশিরভাগই বাদ পড়লে রোজকার রানটি কিছুই পরীক্ষা করে না। কেবল সেই কয়েকটি টেস্টকে লেবেল দিন যেগুলো সত্যিই কয়েক সেকেন্ড খায়।
  • pytest ঠিকমতো skip করে কি না, তার টেস্ট লিখবেন না। আপনি money.py পরীক্ষা করছেন, pytest নয়। Marker হলো কনফিগারেশন; রানের রিপোর্টই তার কাজ করার যথেষ্ট প্রমাণ।

অধ্যায়ের বাকিটা এই টেবিলটিকেই, সারি ধরে ধরে, বাস্তবায়ন করে।


skip — এই টেস্টটি নয়, আর কারণ এই

প্রথমে সেই পদ্ধতি, যা marker সম্পর্কে জানার আগে সাধারণত করা হয় — আগেভাগে return:

python
import os


def test_live_rates():
    if "RATES_API_KEY" not in os.environ:
        return
    assert False, "would call the real API here"
text
.                                                                        [100%]
1 passed in 0.07s

Passed। টেস্টটি কিছুই পরীক্ষা করেনি, অথচ রিপোর্ট বলছে সফল। key ছাড়া একটি ল্যাপটপে এটি প্রতিটি রানে বলা একটি মিথ্যা, আর এই সবুজ বিন্দুটিকে আসল একটি থেকে আলাদা করার উপায় নেই। সমাধান শর্তটি এড়িয়ে যাওয়া নয়, বরং সেটি সৎভাবে জানানো — skip হিসেবে।

সবচেয়ে সহজ marker হলো একটি decorator যা বলে "এটি চালিও না", সাথে একটি কারণ:

python
import os
import sys

import pytest

from money import parse_price, split_bill


@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
    assert split_bill(100.0, 3) == 33.34


@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
    assert os.path.join("data", "prices.csv") == "data/prices.csv"


@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
    assert parse_price("10") == 10.0


def test_live_rates():
    if "RATES_API_KEY" not in os.environ:
        pytest.skip("RATES_API_KEY is not set")
    assert False, "would call the real API here"
text
s.ss                                                                     [100%]
1 passed, 3 skipped in 0.09s

চারটি টেস্ট, সিদ্ধান্ত নেওয়ার চারটি আলাদা উপায়:

  • @pytest.mark.skip(reason=...) — সবসময় skip হয়। ভেতরের কোড কখনো চলে না।
  • @pytest.mark.skipif(condition, reason=...) — শর্তটি সত্য হলে skip হয়। শর্তটি যাচাই হয় ফাইলটি collect করার সময়। এই রানটি ছিল Linux-এ, তাই test_posix_path চলেছে আর পাস করেছে; পাইথন ছিল 3.12, তাই test_new_feature skip হয়েছে।
  • টেস্টের ভেতরে ডাকা pytest.skip("...") — সিদ্ধান্তটি হয় টেস্ট চলার সময়। এটি ব্যবহার করুন যখন মাঝপথে গিয়ে জানতে পারেন, যেমন environment দেখার পর বা কোনো fixture কী ফেরত দিল তা দেখার পর।

প্রগ্রেস লাইনের প্রতিটি s একটি skip হওয়া টেস্ট। Skip ব্যর্থতা নয়: রানটি সবুজই থাকে।

কিন্তু কারণগুলো কোথাও দেখা যাচ্ছে না। ডিফল্টভাবে pytest সারাংশে কেবল ব্যর্থতা আর error দেখায়। Skip দেখতে চাইলে -rs দিন:

text
s.ss                                                                     [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10s

এজন্যই reason গুরুত্বপূর্ণ। তিন মাস পর কেবল "skipped" শব্দটি কাউকে বলবে না টেস্টটি আবার চালু করা যায় কি না।

importorskip — যখন গোটা ফাইলের একটি প্যাকেজ লাগে

ফিরে যাই test_export.py-তে। import yaml-এর জায়গায় লিখুন:

python
import pytest

from money import parse_price

yaml = pytest.importorskip("yaml")


def test_prices_from_yaml():
    data = yaml.safe_load("pen: '15.00'")
    assert parse_price(data["pen"]) == 15.0
text
..                                                                       [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08s

pytest.importorskip("yaml") import করার চেষ্টা করে। সফল হলে মডিউলটি ফেরত দেয়, তাই সেটিকে yaml-এ রাখা হয়েছে। ব্যর্থ হলে গোটা ফাইলটি skip হয়, import-এর ত্রুটিটিই কারণ হিসেবে। বাকি দুটি টেস্ট এখন চলে। yaml ইনস্টল করুন, আর কোনো পরিবর্তন ছাড়াই একই ফাইল পুরোটা চলবে।

xfail — যে বাগের কথা আপনি জানেন

Skip বলে "এই টেস্ট এখানে চলতে পারে না"। দ্বিতীয় আরেকটি, ভিন্ন কথাও আছে: "এই টেস্ট চলে, আর এটি ফেল করবে বলেই প্রত্যাশিত, কারণ কোডে একটি জানা বাগ আছে"।

parse_price-এ একটি আছে। এটি মুদ্রার চিহ্ন সামলাতে পারে না:

python
import pytest

from money import parse_price


@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
    assert parse_price("$1,250") == 1250.0


@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
    assert parse_price("1 250") == 1250.0

pytest -q -rx:

text
xx                                                                       [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08s

Skip হওয়া টেস্টের মতো নয়, xfail টেস্ট সত্যিই চলে। পূর্বাভাস অনুযায়ী এটি ফেল করেছে, তাই ছোট হাতের x দেখায় আর রানটি সবুজ থাকে। টেস্টটি এখন বাগটির একটি লিখিত দলিল, যা সুইটের ভেতরেই আছে — কারো মাথার ভেতরের একটি নোট নয়।

তাহলে শুধু skip করলেই হতো না কেন? কারণ skip হওয়া টেস্ট কখনো চলে না, তাই আপনাকে কিছুই জানাতে পারে না — বাগ ঠিক হলেও না, কোড বদলে গিয়ে নতুন উপায়ে ফেল করলেও না। xfail টেস্ট প্রতিবার চলে, আর তার ফল আপনার পূর্বাভাসের সাথে মেলানো হয়। Skip হলো "এখানে চলতে পারে না"-র জন্য; xfail হলো "চলে, আর আমি জানি সে কী বলবে"-র জন্য।

এবার কেউ বাগ #42 ঠিক করল, money.py বদলে $ ছেঁটে ফেলার ব্যবস্থা করল:

text
Xx                                                                       [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12s

বড় হাতের X, XPASS: "ফেল করার কথা ছিল, কিন্তু পাস করেছে"। আর রানটি এখনো সবুজ — exit code 0। সবুজ রানের দিকে কেউ তাকায় না, তাই marker থেকে যায়, আর টেস্টটি এমন একটি বাগের দাবি করে যেতে থাকে যার আর অস্তিত্ব নেই।

strict=True — সুখবরকে জোরালো করুন

python
import pytest

from money import parse_price


@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
    assert parse_price("$1,250") == 1250.0
text
F                                                                        [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10s

strict=True থাকলে অপ্রত্যাশিত পাস একটি ব্যর্থতা। শুনতে উল্টো লাগে, আর ঠিক এটাই আপনি চান: যে বাগটি ঠিক করেছে তাকে marker সরাতে বলা হয়, আর তখন থেকে টেস্টটি একটি সাধারণ টেস্ট হয়ে সংশোধনটিকে পাহারা দেয়। প্রজেক্টের সব xfail-এর জন্য এটি চাইলে pytest কনফিগারেশনে xfail_strict = true দিন।

raises= — সঠিক কারণে ফেল করুন

আর্গুমেন্ট ছাড়া xfail যেকোনো ব্যর্থতা মেনে নেয়। তার মধ্যে সেগুলোও আছে যা আপনি বোঝাতে চাননি:

python
import pytest

from money import parse_price


@pytest.mark.xfail(reason="bug #42")
def test_loose():
    assert parse_prise("$1,250") == 1250.0


@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
    assert parse_prise("$1,250") == 1250.0
text
xF                                                                       [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________

    @pytest.mark.xfail(raises=ValueError, reason="bug #42")
    def test_precise():
>       assert parse_prise("$1,250") == 1250.0
               ^^^^^^^^^^^
E       NameError: name 'parse_prise' is not defined

test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09s

দুটি টেস্টেই একই বানান-ভুল, parse_prise। test_loose NameError-টি গিলে ফেলে শান্তভাবে একটি x দেখিয়েছে — এটি চিরকাল "ফেল করার কথা" হয়েই থাকত, কখনো parse_price পরীক্ষা না করেই। test_precise জানিয়ে দিয়েছে কোন ব্যর্থতা সে আশা করে — float("$1250") যে ValueError দেয় — তাই অন্য যেকোনো exception আসল ব্যর্থতা হিসেবে দেখানো হয়। বানানটি ঠিক করুন, দুটিই x হয়ে যাবে।

রিপোর্টের অক্ষরগুলো

প্রগ্রেস লাইন প্রতিটি টেস্টের জন্য একটি করে অক্ষর ব্যবহার করে, আর সারাংশে কী যাবে তা বাছতে -r সেই একই অক্ষরগুলো নেয়:

| অক্ষর | অর্থ | সারাংশে দেখাতে | |---|---|---| | . | পাস করেছে | -rp | | F | ফেল করেছে | -rf (ডিফল্টভাবে চালু) | | E | setup বা teardown-এ error | -rE (ডিফল্টভাবে চালু) | | s | skip হয়েছে | -rs | | x | xfailed — প্রত্যাশামতো ফেল করেছে | -rx | | X | xpassed — অপ্রত্যাশিতভাবে পাস করেছে | -rX |

অক্ষরগুলো একসাথে দেওয়া যায়: -rsx skip আর xfail একসাথে দেখায়। -rA দেখায় সবকিছু, পাসসহ:

text
..xs                                                                     [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08s

নিজের marker

skip আর xfail বদলে দেয় pytest একটি টেস্টের সাথে কী করবে। কিন্তু একটি marker নিছক একটি লেবেল-ও হতে পারে, আপনার বানানো একটি নাম, যা টেস্ট বাছাইয়ে ব্যবহার না করা পর্যন্ত কিছুই বদলায় না। ধরা যাক কিছু টেস্ট ধীর, আর কিছুর ডেটাবেস লাগে:

python
import time

import pytest

from money import parse_price, split_bill


def test_parse_plain():
    assert parse_price("1,250.50") == 1250.5


@pytest.mark.slow
def test_parse_many():
    time.sleep(1)
    assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0


@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
    time.sleep(1)
    assert split_bill(300.0, 3) == 100.0


@pytest.mark.db
def test_bill_lookup():
    assert split_bill(90.0, 3) == 30.0
text
....                                                                     [100%]
=============================== warnings summary ===============================
test_marks.py:12
  /home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.slow

test_marks.py:18
  /home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.slow

test_marks.py:19
  /home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.db

test_marks.py:25
  /home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.db

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09s

চারটিই পাস করেছে, আর প্রতিটি ব্যবহারে pytest অভিযোগ করছে। pytest.mark.<যেকোনো কিছু> গ্রহণযোগ্য — attribute-টি তখনই তৈরি হয়ে যায় — তাই pytest বুঝতে পারে না slow আসল নাম, নাকি অন্য কিছুর ভুল বানান। সে চায় আপনি যে নামগুলো বোঝাতে চান সেগুলো ঘোষণা করুন।

Marker রেজিস্টার করা

তালিকাটি যায় pyproject.toml-এ, প্রতিটি marker-এর জন্য একটি স্ট্রিং, কোলনের আগে নাম আর পরে বর্ণনা:

toml
[tool.pytest.ini_options]
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
    "db: needs the database",
]
text
....                                                                     [100%]
4 passed in 2.11s

সতর্কবার্তাগুলো চলে গেছে, আর pytest --markers এখন প্রজেক্টে যোগ দেওয়া পরবর্তী মানুষটির জন্য সেগুলো তালিকা করে দেখায়:

text
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'

@pytest.mark.db: needs the database

-m দিয়ে বাছাই

এবার লেবেলগুলো কাজে আসে। -m marker-এর নাম দিয়ে একটি expression নেয়, and, or, not আর বন্ধনীসহ:

text
$ pytest -q -m "not slow"
..                                                                       [100%]
2 passed, 2 deselected in 0.07s

$ pytest -q -m "slow and db"
.                                                                        [100%]
1 passed, 3 deselected in 1.08s

$ pytest -q -m "slow or db"
...                                                                      [100%]
3 passed, 1 deselected in 2.07s

Deselected মানে skipped নয়। Skip হওয়া টেস্টকে বাছা হয়েছিল, তারপর সে চলতে অস্বীকার করেছে; deselected টেস্টকে কখনো বাছাই করা হয়নি। -m "not slow" হলো প্রতিবার সেভ করার পরের রান; পুরো রান CI-এর জন্য আর push করার আগে।

--strict-markers — বানান-ভুল হয়ে যায় error

সতর্কবার্তা চোখ এড়িয়ে যাওয়া সহজ। এখানে একটি টেস্ট যা কেউ পরে যোগ করেছে, marker-এর বানান ভুল:

python
@pytest.mark.slwo
def test_split_large_group():
    time.sleep(1)
    assert split_bill(1000.0, 8) == 125.0
text
...                                                                      [100%]
=============================== warnings summary ===============================
test_marks.py:30
  /home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.slwo

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07s

চাওয়া হয়েছিল -m "not slow", তবু রানে এক সেকেন্ড লেগেছে: ভুল বানানের টেস্টটি slow নয়, তাই সেটি চলেছে। --strict-markers থাকলে রেজিস্টার না করা marker collection-ই থামিয়ে দেয়:

text
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s

প্রতিবার এই ফ্ল্যাগ টাইপ করতে চাইবেন না, তাই এটি addopts-এ রাখুন:

toml
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
    "db: needs the database",
]

pytestmark — গোটা ফাইল চিহ্নিত করা

যখন একটি ফাইলের প্রতিটি টেস্টের ডেটাবেস লাগে, প্রতিটিতে আলাদা decorator বসানো পুনরাবৃত্তি। মডিউল স্তরে pytestmark নামের একটি ভেরিয়েবল ফাইলের প্রতিটি টেস্টে প্রযোজ্য হয়:

python
import pytest

from money import split_bill

pytestmark = pytest.mark.db


def test_bill_for_two():
    assert split_bill(50.0, 2) == 25.0


def test_bill_for_four():
    assert split_bill(80.0, 4) == 20.0
text
$ pytest -q -m db
..                                                                       [100%]
2 passed, 4 deselected in 0.07s

নামটি হুবহু pytestmark হতে হবে। এটি একটি তালিকাও হতে পারে, pytestmark = [pytest.mark.db, pytest.mark.slow]।

parametrize-এর একটিমাত্র কেসে marker

Parametrize করা টেস্টে marker দিলে তা প্রতিটি কেসে প্রযোজ্য হয়। একটি কেস চিহ্নিত করতে সেটিকে pytest.param(..., marks=...)-এ মুড়ে দিন:

python
import pytest

from money import parse_price


@pytest.mark.parametrize(
    "text, expected",
    [
        ("15", 15.0),
        ("1,250.50", 1250.5),
        pytest.param(
            "$1,250", 1250.0,
            marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
        ),
        pytest.param(
            "1 250", 1250.0,
            marks=pytest.mark.skip(reason="space separators not decided"),
        ),
    ],
)
def test_parse_price(text, expected):
    assert parse_price(text) == expected
text
test_params.py::test_parse_price[15-15.0] PASSED                         [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED                 [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42)          [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================

জানা বাগটি কাজ করা কেসগুলোর পাশেই, একই টেবিলে থাকে, আর যেদিন ঠিক হবে সেদিন আপনি একটি pytest.param মোড়ক মুছে দেবেন।


আরও এগিয়ে: যে fixture বদলায়, আর যে fixture বানায়

দুটি fixture প্যাটার্ন fixture-এর অধ্যায়গুলোতে জায়গা পায়নি, আর marker-এর পরই সেগুলোর ভালো সময়, কারণ দুটিই সেই টেস্টগুলোকে বহুগুণ করে বা আকার দেয় যেগুলো আপনি এইমাত্র বাছাই করতে শিখলেন।

Parametrize করা fixture

parametrize একটি টেস্টের ইনপুট বদলায়। কখনো কখনো আপনি চান একটি fixture ব্যবহার করা প্রতিটি টেস্ট সেই fixture-এর প্রতিটি মানের জন্য একবার করে চলুক। @pytest.fixture-কে একটি params তালিকা দিন, আর বর্তমান মানটি পড়ুন request.param থেকে:

python
import pytest

from money import parse_price


@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
    return request.param


def test_parses_to_1250(price_text):
    assert parse_price(price_text) == 1250.0


def test_is_positive(price_text):
    assert parse_price(price_text) > 0
text
test_fixtures.py::test_parses_to_1250[plain] PASSED                      [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED                      [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED                   [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED                         [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED                         [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED                      [100%]
============================== 6 passed in 0.08s ===============================

দুটি টেস্ট, তিনটি মান, ছয়টি রান। request একটি বিল্ট-ইন fixture, যা বর্ণনা করে কোন টেস্টের জন্য প্রস্তুতি চলছে; request.param থাকে কেবল যখন fixture-এর params আছে। ids রিপোর্টে প্রতিটি মানের নাম দেয় — না দিলে pytest মান থেকেই একটি id বানায়, যা লম্বা বা ছাপা যায় না এমন মানের ক্ষেত্রে পড়া কঠিন। তালিকায় marks=-সহ pytest.param(...)-ও রাখা যায়, ঠিক parametrize-এর মতো।

এটি কোথায় কাজে আসে: একই টেস্টগুলো কয়েকটি backend, কয়েকটি ফাইল ফরম্যাট, কয়েকটি মুদ্রার বিরুদ্ধে। params-এ একটি মান যোগ করুন, আর fixture-টি ব্যবহার করা প্রতিটি টেস্ট সেটিও পরীক্ষা করবে।

Factory fixture

একটি fixture টেস্ট থেকে আর্গুমেন্ট নিতে পারে না; টেস্ট কেবল তার নাম উল্লেখ করে। তাহলে টেস্টের যদি আলাদাভাবে বানানো দুটি জিনিস লাগে? একটি ফাংশন ফেরত দিন:

python
import pytest

from money import parse_price


@pytest.fixture
def make_receipt(tmp_path):
    def _make(name, lines):
        path = tmp_path / f"{name}.txt"
        path.write_text("\n".join(lines))
        return path

    return _make


def receipt_total(path):
    return sum(parse_price(line) for line in path.read_text().splitlines())


def test_two_receipts(make_receipt):
    lunch = make_receipt("lunch", ["15", "1,250.50"])
    taxi = make_receipt("taxi", ["300"])
    assert receipt_total(lunch) + receipt_total(taxi) == 1565.5


def test_empty_receipt(make_receipt):
    empty = make_receipt("empty", [])
    assert receipt_total(empty) == 0
text
..                                                                       [100%]
2 passed in 0.08s

Fixture-টি এখনো প্রতি টেস্টে একবার চলে, আর এখনো নিজের fixture পায় (এখানে tmp_path, যা ফাইলগুলো মুছে ফেলার দায়িত্বও নেয়)। সে যা হাতে তুলে দেয় তা হলো _make, আর টেস্ট সেটি যতবার খুশি, যে আর্গুমেন্ট দিয়ে খুশি ডাকে। make_... নামটি প্রচলিত রীতি, আর পাঠককে এক নজরে বলে দেয় এই fixture ডাকার মতো একটি ফাংশন, তৈরি একটি মান নয়।


একটা সম্পূর্ণ উদাহরণ

সবকিছু একসাথে, একটি প্রজেক্টে। pyproject.toml:

toml
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
    "network: talks to an outside service",
]

test_checkout.py, একই money.py দিয়ে — যাতে বাগ #42 এখনো আছে:

python
import os
import time

import pytest

from money import parse_price, split_bill


# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
    return request.param


# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
    def _make(text, people):
        return split_bill(parse_price(text), people)

    return _make


def test_share_is_positive(amount_text, make_share):
    assert make_share(amount_text, 3) > 0


@pytest.mark.parametrize(
    "text, people, share",
    [
        ("300", 3, 100.0),
        ("90", 4, 22.5),
        pytest.param(
            "$300", 3, 100.0,
            marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
        ),
    ],
)
def test_share(make_share, text, people, share):
    assert make_share(text, people) == share


@pytest.mark.slow
def test_many_group_sizes(make_share):
    time.sleep(1)  # stands in for real, slow work
    assert all(make_share("300", n) > 0 for n in range(1, 1000))


@pytest.mark.network
def test_live_exchange_rate():
    if "RATES_API_KEY" not in os.environ:
        pytest.skip("RATES_API_KEY is not set")
    raise AssertionError("would call the real service here")

রোজকার রান, কারণসহ:

text
$ pytest -q -rsx -m "not slow"
.....xs                                                                  [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s

কেবল ধীর টেস্টটি, আর অফলাইন রান যা ধীর ও নেটওয়ার্ক দুই ধরনের টেস্টই বাদ দেয়:

text
$ pytest -q -m slow
.                                                                        [100%]
1 passed, 7 deselected in 1.07s

$ pytest -q -m "not slow and not network"
.....x                                                                   [100%]
5 passed, 2 deselected, 1 xfailed in 0.08s

গুনে দেখুন: তিনটি মানের একটি fixture থেকে test_share_is_positive-এর তিনটি রান, test_share-এর তিনটি কেস, একটি ধীর টেস্ট আর একটি নেটওয়ার্ক টেস্ট — মোট আটটি, আর 1 passed, 7 deselected-এর যোগফলও তাই।

আর প্রতিটি ফল সত্যি কথা বলে। নেটওয়ার্ক টেস্ট key ছাড়া পাস করার ভান করে না; সে বলে যে skip হয়েছে আর কেন। বাগ #42 সুইটের ভেতরে আছে, আর যেহেতু এটি raises=ValueError-সহ strict, যেদিন ঠিক হবে সেদিন রানটি লাল হয়ে marker সরাতে বলবে।


কিছু ভাঙা অবস্থা ও তার সমাধান

PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Marker-টি রেজিস্টার করা নেই। pyproject.toml-এর markers-এ যোগ করুন। নামটি সত্যিই বানান-ভুল হলে ঠিক করুন — আর addopts-এ --strict-markers দিন, যাতে পরেরটি সতর্কবার্তা না হয়ে error হয়।

`'slwo' not found in markers configuration option` এটি --strict-markers-এর নিজের কাজ করা: ফাইলটি এমন একটি marker ব্যবহার করছে যা তালিকায় নেই। বানান ঠিক করুন, অথবা নতুন নামটি রেজিস্টার করুন।

8 deselected আর no tests ran, exit code 5 -m কিছুর সাথেই মেলেনি। -m expression-এ ভুল বানানের নাম error নয়, --strict-markers থাকলেও না — -m sloww কেবল কোনো টেস্টই বাছাই করে না। নামটি pytest --markers-এর সাথে মিলিয়ে দেখুন।

ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input -m expression অসম্পূর্ণ। পুরো expression উদ্ধৃতিচিহ্নে রাখুন, আর নিশ্চিত করুন প্রতিটি and/or-এর দুই পাশে কিছু আছে।

Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. reason= ছাড়া skipif(sys.platform == "linux")। একটি boolean শর্ত কারণ সম্পর্কে কিছু বলে না, তাই pytest একটি কারণ দাবি করে।

`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") ফাইলের একেবারে উপরের স্তরে ডাকা হয়েছে। প্যাকেজ না থাকলে pytest.importorskip ব্যবহার করুন; নয়তো লিখুন pytest.skip("...", allow_module_level=True), অথবা pytestmark-এ একটি skipif দিন।

[XPASS(strict)] bug #42 সুখবর, ব্যর্থতা হিসেবে জানানো: বাগটি ঠিক হয়েছে। xfail marker সরান আর টেস্টটি রেখে দিন।

বাগ ঠিক হওয়ার পরও যে xfail টেস্ট x থেকে যায় এটি অন্য কোনো কারণে ফেল করছে — প্রায়ই টেস্টের নিজের ভেতরে একটি বানান-ভুল। যে exception আপনি সত্যিই আশা করেন সেটি দিয়ে raises= যোগ করুন, তাহলে অন্য যেকোনো কিছু আসল ব্যর্থতা হিসেবে দেখাবে।

AttributeError: 'SubRequest' object has no attribute 'param' Fixture-টি request.param পড়ছে, কিন্তু তার কোনো params= তালিকা নেই, অন্য কোনোভাবেও parametrize করা হয়নি। @pytest.fixture-এ params=[...] যোগ করুন।

fixture 'name' not found, একটি fixture-এর দিকে ইঙ্গিত করে একটি fixture লেখা হয়েছে def make_user(name): হিসেবে, এই আশায় যে টেস্ট name পাঠাবে। Fixture-এর আর্গুমেন্ট হলো অন্য fixture, তাই pytest name নামের একটি fixture খুঁজতে গেছে। এটিকে factory বানান: ভেতরের একটি ফাংশন যা name নেয়, আর fixture সেটি ফেরত দেয়।