अध्याय 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 दो सवालों के जवाब हैं, जो टेस्ट लिखने से पहले देने होते हैं, और जिनका जवाब ज़्यादातर लोग तब देते हैं जब कोई रन ग़लत वजह से लाल हो चुका होता है।

1. इस टेस्ट को चलने के लिए ही क्या चाहिए? कोई पैकेज जो अनिवार्य dependency नहीं है, कोई ऑपरेटिंग सिस्टम, पायथन का कोई वर्ज़न, कोई सीक्रेट रखने वाला environment variable, डेटाबेस, नेटवर्क, बहुत सारा समय। इनमें से हर एक एक पूर्व-शर्त है। अगर वह मौजूद नहीं है, तो टेस्ट के पास कहने को कुछ नहीं: वह न पास हुआ, न फ़ेल।

2. आज मैं इससे क्या उम्मीद करता हूँ? आम तौर पर, पास होना। लेकिन अगर कोड में कोई ज्ञात बग है और आप वह टेस्ट लिख रहे हैं जो सही व्यवहार बताता है, तो आप उम्मीद करते हैं कि यह फ़ेल होगा — और एक ख़ास तरीक़े से फ़ेल होगा।

जवाब ही 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 ने बता दिया कि वह कौन सी विफलता अपेक्षित मानता है — वह ValueError जो float("$1250") देता है — इसलिए कोई भी दूसरा 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 हटाइए और टेस्ट रखिए।

बग ठीक होने के बाद भी x बना रहने वाला xfail टेस्ट वह किसी और वजह से फ़ेल हो रहा है — अक्सर टेस्ट के अंदर ही कोई टाइपो। जिस 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 लौटाता है।