monkeypatch — environment, समय और नेटवर्क पर नियंत्रण
Environment variables, घड़ी, randomness या नेटवर्क पर निर्भर कोड को टेस्ट करना। setenv, delenv, setattr, setitem, chdir, हर टेस्ट के बाद अपने-आप undo, "जहाँ खोजा जाता है वहाँ patch कीजिए" का नियम, और बिना patch के dependency injection।
- 1समस्या
- 2समझें
- 3उदाहरण
- 4अनुमान
- 5स्वयं करें
- 6चुनौती
वह समस्या जिसे हम हल कर रहे हैं
यह फ़ंक्शन तय करता है कि कोई एप्लिकेशन debug मोड में चलेगा या नहीं। यह एक environment variable पढ़ता है:
settings.py:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"test_settings.py:
from settings import debug_enabled
def test_debug_is_off_by_default():
assert debug_enabled() is Falseअपने टर्मिनल में pytest -q चलाइए — टेस्ट पास होगा। अब इसे ऐसी मशीन पर चलाइए जहाँ किसी ने APP_DEBUG=1 export कर रखा है — किसी साथी का लैपटॉप, या debugging के लिए सेट किया गया कोई CI सर्वर — और उसी कोड पर वही टेस्ट फ़ेल हो जाता है:
$ pytest -q
. [100%]
1 passed in 0.01s
$ APP_DEBUG=1 pytest -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_debug_is_off_by_default _________________________
def test_debug_is_off_by_default():
> assert debug_enabled() is False
E assert True is False
E + where True = debug_enabled()
test_settings.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_settings.py::test_debug_is_off_by_default - assert True is False
1 failed in 0.01sकोड में कुछ नहीं बदला। बदली है उसके आसपास की दुनिया। Environment variables, मौजूदा समय, random संख्याएँ, मौजूदा डायरेक्टरी, नेटवर्क — जो कोड इनमें से कुछ भी पढ़ता है, वह कहाँ और कब चल रहा है इसके हिसाब से अलग जवाब देता है। और जो टेस्ट इन्हें नियंत्रित नहीं करता, वह सिर्फ़ आपके कोड को टेस्ट नहीं कर रहा।
टेस्ट को कह पाना चाहिए: जब तक यह टेस्ट चल रहा है, दुनिया ऐसी दिखती है — और फिर सब कुछ वापस वैसा ही कर देना चाहिए। बिल्ट-इन monkeypatch fixture ठीक यही करता है।
इस अध्याय के अंत में आप कर पाएंगे
monkeypatch.setenvऔरmonkeypatch.delenvसे एक टेस्ट के लिए environment variables सेट करना और हटानाmonkeypatch.setattrसे किसी फ़ंक्शन को बदलना — object और नाम देकर, या"module.attr"स्ट्रिंग देकरmonkeypatch.setitemसे किसी dictionary की एक key, औरmonkeypatch.chdirसे working directory बदलना- दिखाना कि टेस्ट ख़त्म होने पर हर बदलाव वापस ले लिया जाता है
- किसी नाम को वहाँ patch करना जहाँ उसे खोजा जाता है, और समझाना कि
random.randintको patch करने सेfrom random import randintपर असर क्यों नहीं पड़ता - घड़ी या random फ़ंक्शन को argument के रूप में भेजना, ताकि patch करने की ज़रूरत ही न पड़े
ज़रूरी शर्तें: बिल्ट-इन fixtures।
टेस्ट लिखने से पहले
ऐसे कोड में ज़्यादातर काम पहला def test_ लिखने से पहले होता है। चार सवाल, इसी क्रम में।
1. फ़ंक्शन क्या वादा करता है — और ऐसा क्या पढ़ता है जो आपने उसे दिया ही नहीं? फ़ंक्शन पढ़िए और हर वह लाइन चिह्नित कीजिए जो उसके arguments के बाहर से कुछ लेती है: os.environ, datetime.now(), random, कोई relative फ़ाइल path, module-स्तर की कोई dictionary, कोई नेटवर्क कॉल। इनमें से हर एक एक छिपा हुआ input है। फ़ंक्शन का वादा है "ये छिपे inputs मिलें तो मैं यह लौटाता हूँ", और टेस्ट को हर छिपा input तय करना होगा — वरना वह उसे टेस्ट कर रहा है जो उस वक़्त मशीन पर पड़ा है।
2. क्या तैयार होना चाहिए? सामान्य चीज़ें: pytest इंस्टॉल किया हुआ एक virtual environment, और टेस्ट से module import हो सके (टेस्ट फ़ाइल उसके बगल में हो, या प्रोजेक्ट इंस्टॉल हो)। फिर इस विषय के लिए, हर छिपे input पर एक फ़ैसला:
- environment variable — उसे
setenvकीजिए, याraising=Falseके साथdelenvकीजिए; - नेटवर्क, डिस्क या randomness तक पहुँचने वाला फ़ंक्शन — उसे
setattrसे बदलिए, उस module में जो उसे कॉल करता है; - module-स्तर की dictionary —
setitemसे एक key बदलिए; - relative path —
chdirसे किसीtmp_pathमें जाइए; - जो कोड आप अभी लिख रहे हैं उसमें घड़ी या random चुनाव — उसके बजाय उसे argument के रूप में भेजिए।
3. कौन-से cases? वह configuration loader लीजिए जो आप इस अध्याय के अंत में बनाएँगे। वह पहले defaults पढ़ता है, फिर एक वैकल्पिक app.cfg, फिर APP_* environment variables। कोई कोड लिखने से पहले उसकी योजना:
| Case | टेस्ट के दौरान दुनिया | अपेक्षित | |---|---|---| | कुछ भी सेट नहीं | ख़ाली फ़ोल्डर, कोई APP_* variable नहीं | port 8000, debug False, region "bd" | | फ़ाइल defaults पर भारी | app.cfg में port = 9000 | port 9000 | | Environment फ़ाइल पर भारी | app.cfg में 9000, APP_PORT=7000 | port 7000 | | कोई default बदलता है | DEFAULTS["region"] है "ae" | region "ae" | | अमान्य मान | APP_PORT=70000 | "out of range" के ज़िक्र वाला ValueError | | Reader बदल दिया गया | read_file लौटाता है {"port": "1234"} | port 1234 | | आपके shell में शोर | pytest चलाने से पहले APP_PORT=5 export किया हुआ | ऊपर की हर पंक्ति तब भी सही रहे |
आख़िरी पंक्ति वही है जिसे लोग भूल जाते हैं। यह अपने आप में कोई टेस्ट नहीं — यह पूरे टेस्ट-सूट का गुण है, और यही तय करती है कि हर टेस्ट को पहले variables साफ़ करने होंगे, सिर्फ़ उन्हें नहीं जो उन्हें सेट करते हैं।
4. क्या टेस्ट न करें। यह टेस्ट मत कीजिए कि os.environ स्ट्रिंग रखता है या urllib कोई पेज ला सकता है — यह पायथन का काम है, और वह पहले से टेस्ट किया हुआ है। किसी unit test को असली नेटवर्क छूने मत दीजिए: वह धीमा है, आपके कोड से असंबंधित कारणों से फ़ेल होता है, और मौसम बदलता रहता है। और कभी भी नकली चीज़ पर ही assert मत कीजिए। यह टेस्ट पास होता है और कुछ साबित नहीं करता:
import weather
def test_pointless(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.fetch_temperature("Dhaka") == 35. [100%]
1 passed in 0.01sयह सिर्फ़ जाँचता है कि patch अपनी जगह पर है, यह नहीं कि आपका कोई कोड काम करता है। नकली चीज़ इसलिए होती है ताकि उसके ऊपर दूसरा कोड — नीचे का advice — टेस्ट किया जा सके। अगर assertion में सिर्फ़ वही चीज़ है जिसे आपने बदला, तो टेस्ट मिटा दीजिए।
आगे के sections एक-एक tool लेकर इसी योजना को लागू करते हैं।
setenv और delenv — हर टेस्ट का अपना environment
monkeypatch भी tmp_path जैसा एक fixture है: टेस्ट के parameters में उसका नाम लिखकर माँग लीजिए। थोड़े बड़े settings.py के लिए चार टेस्ट:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"
def api_port():
return int(os.environ.get("APP_PORT", "8000"))from settings import api_port, debug_enabled
def test_debug_on(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert debug_enabled() is True
def test_debug_off_by_default(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert debug_enabled() is False
def test_port_from_env(monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
assert api_port() == 9000
def test_port_default(monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
assert api_port() == 8000$ pytest -q
.... [100%]
4 passed in 0.01s
$ APP_DEBUG=1 APP_PORT=1234 pytest -q
.... [100%]
4 passed in 0.01sदूसरा रन ही असली है। Shell ने ऐसे मान export किए थे जो इनमें से दो टेस्ट तोड़ देते, फिर भी चारों पास होते हैं — क्योंकि हर टेस्ट ख़ुद बताता है कि उसे कौन-सा environment चाहिए।
setenv(name, value) एक variable सेट करता है। delenv(name) एक हटाता है — और डिफ़ॉल्ट रूप से, अगर variable पहले से था ही नहीं, तो KeyError उठाता है। "सेट नहीं" वाले case के टेस्ट को फ़र्क़ नहीं पड़ता कि वह पहले सेट था या नहीं, बस अभी सेट न हो — इसलिए लगभग हमेशा आपको raising=False ही चाहिए।
मान स्ट्रिंग होते हैं। Environment सिर्फ़ टेक्स्ट रखता है, और os.environ भी। "9000" को 9000 बनाना आपके कोड का काम है, और अक्सर ठीक यही रूपांतरण टेस्ट करने लायक होता है।
सब कुछ वापस ले लिया जाता है — साबित कीजिए
दावा यह है कि टेस्ट ख़त्म होने पर monkeypatch अपना बदला हुआ सब कुछ बहाल कर देता है। यह रहा सबूत:
import os
def test_first(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))pytest -q -s (-s से print दिखता है):
inside first : 1
.inside second: None
.
2 passed in 0.01sदूसरे टेस्ट ने monkeypatch नहीं माँगा, फिर भी उसे कुछ नहीं दिखता — variable जा चुका है। अब तुलना कीजिए कि सीधे os.environ में लिखने पर क्या होता है:
import os
def test_first():
os.environ["APP_DEBUG"] = "1"
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))inside first : 1
.inside second: 1
.
2 passed in 0.01sमान अगले टेस्ट में रिस गया है, और उसी रन के बाद वाले हर टेस्ट में रिसेगा। यही वह bug है जिसकी वजह से कोई टेस्ट अकेले चलाने पर पास होता है और बाक़ियों के साथ चलाने पर फ़ेल — या इससे भी बुरा, उल्टा।
वापस लेने में कोई जादू नहीं है। हर बदलाव से पहले monkeypatch पुराना मान दर्ज कर लेता है — या यह कि कोई मान था ही नहीं — और teardown के दौरान दर्ज किया हर मान उल्टे क्रम में वापस रख देता है। टेस्ट पास हो, फ़ेल हो या exception उठाए, teardown चलता ही है, इसलिए बीच में फ़ेल हुआ कोई assert environment को गंदा नहीं छोड़ सकता। हाथ से लिखा os.environ[...] = ... और टेस्ट के अंत में हाथ से लिखी सफ़ाई की लाइन — उस लाइन को ठीक वही फ़ेल हुआ assert छोड़ देता।
setattr — एक टेस्ट के लिए फ़ंक्शन बदलना
Environment variables आसान case हैं। कठिन वाला है वह कोड जो नेटवर्क से बात करता है:
weather.py:
import json
import urllib.request
def fetch_temperature(city):
url = f"https://api.example.com/weather?city={city}"
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)["temp_c"]
def advice(city):
temp = fetch_temperature(city)
if temp >= 30:
return "Hot: carry water"
if temp <= 10:
return "Cold: take a jacket"
return "Mild"दिलचस्प logic advice में है: सीमाएँ, संदेश। इसे जैसा है वैसा टेस्ट करें तो टेस्ट एक सर्वर, एक नेटवर्क कनेक्शन और आज के मौसम पर निर्भर हो जाता है। बिना नेटवर्क वाली मशीन पर:
$ pytest -q --tb=line test_real.py
F [100%]
=================================== FAILURES ===================================
E socket.gaierror: [Errno -5] No address associated with hostname
During handling of the above exception, another exception occurred:
E urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
/usr/lib/python3.12/urllib/request.py:1347: urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
1 failed in 0.01smonkeypatch.setattr एक टेस्ट की अवधि के लिए fetch_temperature को आपके नियंत्रण वाली किसी चीज़ से बदल देता है:
import weather
def test_hot_day(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.advice("Dhaka") == "Hot: carry water"
def test_cold_day(monkeypatch):
monkeypatch.setattr("weather.fetch_temperature", lambda city: 4)
assert weather.advice("Oslo") == "Cold: take a jacket".. [100%]
2 passed in 0.01sलक्ष्य बताने के दो तरीक़े हैं, और दोनों एक ही काम करते हैं:
setattr(weather, "fetch_temperature", new)— पहले object, फिर attribute का नाम स्ट्रिंग के रूप में।setattr("weather.fetch_temperature", new)— एक ही बिंदुओं वाली स्ट्रिंग; pytestweatherको import करता है और आख़िरी हिस्सा अलग कर लेता है।
जब टेस्ट पहले से module import करता है, object वाला रूप इस्तेमाल कीजिए; जब import की ज़रूरत न हो, स्ट्रिंग वाला। दोनों में, ध्यान दीजिए कि अब क्या टेस्ट हो रहा है: fetch_temperature नहीं, जिसे हमने बदल दिया, बल्कि advice की सीमाएँ और संदेश — वह कोड जो हमने लिखा। न नेटवर्क, न इंतज़ार, हर बार वही जवाब — और माँगते ही 35 या 4 डिग्री, जो असली मौसम कभी हुक्म पर नहीं देगा।
टेस्ट में सीधे weather.fetch_temperature = lambda city: 35 लिखने के बजाय setattr क्यों? उसी कारण से जो os.environ के साथ था: सादा assignment कभी वापस नहीं लिया जाता, और रन के बाद वाले हर टेस्ट को नकली फ़ंक्शन ही मिलेगा।
नियम: वहाँ patch कीजिए जहाँ खोजा जाता है
अब वह ग़लती जो हर कोई एक बार करता है। पासा फेंकने वाला एक छोटा module:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Jackpot टेस्ट करने के लिए randint को 6 लौटाने पर मजबूर कीजिए। वह random में रहता है, तो वहीं patch करते हैं:
import random
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.is_jackpot() is TrueF [100%]
=================================== FAILURES ===================================
_________________________________ test_jackpot _________________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fc1b2fdc9b0>
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
> assert dice.is_jackpot() is True
E assert False is True
E + where False = <function is_jackpot at 0x7fc1b2fcd800>()
E + where <function is_jackpot at 0x7fc1b2fcd800> = dice.is_jackpot
test_dice.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_dice.py::test_jackpot - assert False is True
1 failed in 0.01sPatch लगा था, और dice ने उसे कभी देखा ही नहीं। टेस्ट को कुछ और बार चलाइए — कभी-कभी पास होगा, जब भी असली randint संयोग से 6 फेंक दे। जो टेस्ट ग़लत है और कभी-कभी हरा, वह हमेशा लाल रहने वाले टेस्ट से भी बुरा है।
कारण यह है कि from random import randint असल में क्या करता है। वह random.randint से कोई जुड़ाव नहीं बनाता। Import के समय वह reference को एक नए नाम में कॉपी कर देता है: dice.randint। उसके बाद एक ही फ़ंक्शन की ओर दो नाम इशारा करते हैं, और setattr उनमें से सिर्फ़ एक को हटाता है:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
Falseजब roll() चलता है, पायथन randint को dice module में खोजता है — नाम वहीं खोजा जाता है। इसलिए बदलना भी वहीं होगा:
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(dice, "randint", lambda a, b: 6)
assert dice.is_jackpot() is True
def test_no_jackpot(monkeypatch):
monkeypatch.setattr("dice.randint", lambda a, b: 3)
assert dice.is_jackpot() is False.. [100%]
2 passed in 0.01sलगातार पाँच रन, पाँचों पास। दूसरा समाधान है dice.py के import का तरीक़ा बदलना। import random और random.randint(1, 6) कॉल के साथ, हर कॉल पर खोज random module में होती है, और तब random.randint को patch करना काम करता है:
import random
def roll():
return random.randint(1, 6)
def is_jackpot():
return roll() == 6$ pytest -q # the original test, patching random.randint
. [100%]
1 passed in 0.01sएक वाक्य में नियम: वह लाइन ढूँढिए जो फ़ंक्शन को कॉल करती है, देखिए कि वह नाम किस module से लेती है, और नाम को वहीं patch कीजिए।
setitem — dictionary की एक key
Settings अक्सर module-स्तर की किसी dictionary में रखी जाती हैं। पूरी dictionary बदलना भद्दा होगा; setitem एक key बदलता है और बाद में उसे बहाल कर देता है:
shop.py:
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
def price_with_tax(amount):
total = round(amount * (1 + SETTINGS["tax_rate"]), 2)
return f"{total} {SETTINGS['currency']}"import shop
def test_tax_free_day(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
assert shop.price_with_tax(100) == "100.0 BDT"
def test_other_currency(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "currency", "INR")
assert shop.price_with_tax(100) == "115.0 INR"
def test_settings_are_back():
assert shop.SETTINGS == {"currency": "BDT", "tax_rate": 0.15}pytest -v:
test_shop.py::test_tax_free_day PASSED [ 33%]
test_shop.py::test_other_currency PASSED [ 66%]
test_shop.py::test_settings_are_back PASSED [100%]
============================== 3 passed in 0.01s ===============================तीसरा टेस्ट फिर सबूत है: दो टेस्टों के एक-एक key बदलने के बाद भी dictionary ठीक वैसी है जैसी शुरू में थी। इसका साथी, monkeypatch.delitem(d, key), एक टेस्ट के लिए एक key हटा देता है।
chdir और syspath_prepend
जो कोड "notes.txt" जैसा relative path खोलता है, वह इस पर निर्भर है कि वह किस डायरेक्टरी से चल रहा है। पिछले अध्याय के tmp_path के साथ chdir मिलाइए:
notes.py:
from pathlib import Path
def read_notes():
return Path("notes.txt").read_text().splitlines()from pathlib import Path
from notes import read_notes
def test_reads_from_current_directory(tmp_path, monkeypatch):
(tmp_path / "notes.txt").write_text("buy milk\ncall Rina\n")
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
assert read_notes() == ["buy milk", "call Rina"]
def test_import_from_a_new_folder(tmp_path, monkeypatch):
(tmp_path / "helper.py").write_text("ANSWER = 42\n")
monkeypatch.syspath_prepend(tmp_path)
import helper
assert helper.ANSWER == 42.. [100%]
2 passed in 0.01schdir एक टेस्ट के लिए working directory बदलता है और बाद में वापस ले आता है। दूसरा टेस्ट syspath_prepend का एक लाइन वाला इस्तेमाल दिखाता है: वह एक फ़ोल्डर को sys.path के सबसे आगे रखता है ताकि वहाँ लिखा module import हो सके, और अंत में फिर हटा देता है — उस कोड को टेस्ट करते समय काम का जो किसी फ़ोल्डर से plugins लोड करता है।
इससे भी बेहतर: dependency बाहर से भेजिए
Patch काम करता है, पर हर patch एक छोटी-सी स्वीकारोक्ति है कि कोड ने हाथ बढ़ाकर दुनिया से कुछ उठा लिया। कुछ चीज़ें तो आराम से patch हो ही नहीं सकतीं। datetime.now उनमें से एक है:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'विकल्प एक design का फ़ैसला है: फ़ंक्शन जिस चीज़ पर निर्भर है, उसे वह चीज़ प्राप्त करने दीजिए, असली को default रखकर:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Production कोड is_open() कॉल करता है और असली घड़ी पाता है। टेस्ट अपनी घड़ी भेजता है:
from datetime import datetime
from shop_hours import is_open
def test_closed_just_before_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 8, 59)) is False
def test_open_at_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 9, 0)) is True
def test_closed_at_nine_pm():
assert is_open(now=lambda: datetime(2026, 1, 5, 21, 0)) is False... [100%]
3 passed in 0.01sन कोई fixture, न कुछ वापस लेना, न यह सवाल कि नाम कहाँ खोजा जाता है। टेस्ट एक सादे वाक्य की तरह पढ़ा जाता है — "8:59 पर दुकान बंद है" — और जिन सीमा वाले मिनटों का महत्व है, उन्हें ठीक-ठीक पकड़ना आसान है।
Randomness के लिए भी यही ढाँचा चलता है: def pick_winner(customers, choose=random.choice), और टेस्ट भेजता है choose=lambda items: items[0]। इसे dependency injection कहते हैं, और इसके लिए किसी framework की ज़रूरत नहीं: एक default argument काफ़ी है। कोड लिखते समय इसका इस्तेमाल कीजिए। monkeypatch तब उठाइए जब आप ऐसा कोड टेस्ट कर रहे हों जिसे आप बदल नहीं सकते या बदलना नहीं चाहिए, या जब dependency कई कॉल गहरे दबी हो।
पूर्ण उदाहरण
यह वही configuration loader है जिसकी योजना "टेस्ट लिखने से पहले" में बनी थी: तीन परतें — बिल्ट-इन defaults, मौजूदा डायरेक्टरी में एक वैकल्पिक app.cfg फ़ाइल, और दोनों पर भारी पड़ने वाले environment variables। योजना की हर पंक्ति एक टेस्ट बनती है।
app_config.py:
import os
from pathlib import Path
DEFAULTS = {"port": "8000", "debug": "0", "region": "bd"}
def read_file(name="app.cfg"):
path = Path(name)
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_config():
# Lowest priority first: defaults, then the file, then the environment.
raw = dict(DEFAULTS)
raw.update(read_file())
for key in raw:
env_name = "APP_" + key.upper()
if env_name in os.environ:
raw[key] = os.environ[env_name]
port = int(raw["port"])
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return {"port": port, "debug": raw["debug"] == "1", "region": raw["region"]}test_app_config.py:
import pytest
import app_config
@pytest.fixture
def clean_env(tmp_path, monkeypatch):
# An empty folder and no APP_ variables: the test starts from nothing.
monkeypatch.chdir(tmp_path)
for name in ("APP_PORT", "APP_DEBUG", "APP_REGION"):
monkeypatch.delenv(name, raising=False)
return tmp_path
def test_defaults(clean_env):
assert app_config.load_config() == {"port": 8000, "debug": False, "region": "bd"}
def test_file_beats_defaults(clean_env):
(clean_env / "app.cfg").write_text("port = 9000\nregion = in\n")
config = app_config.load_config()
assert config["port"] == 9000
assert config["region"] == "in"
def test_env_beats_file(clean_env, monkeypatch):
(clean_env / "app.cfg").write_text("port = 9000\n")
monkeypatch.setenv("APP_PORT", "7000")
assert app_config.load_config()["port"] == 7000
def test_changed_default(clean_env, monkeypatch):
monkeypatch.setitem(app_config.DEFAULTS, "region", "ae")
assert app_config.load_config()["region"] == "ae"
def test_bad_port(clean_env, monkeypatch):
monkeypatch.setenv("APP_PORT", "70000")
with pytest.raises(ValueError, match="out of range"):
app_config.load_config()
def test_reader_can_be_replaced(clean_env, monkeypatch):
monkeypatch.setattr(app_config, "read_file", lambda: {"port": "1234"})
assert app_config.load_config()["port"] == 1234pytest -v:
collected 6 items
test_app_config.py::test_defaults PASSED [ 16%]
test_app_config.py::test_file_beats_defaults PASSED [ 33%]
test_app_config.py::test_env_beats_file PASSED [ 50%]
test_app_config.py::test_changed_default PASSED [ 66%]
test_app_config.py::test_bad_port PASSED [ 83%]
test_app_config.py::test_reader_can_be_replaced PASSED [100%]
============================== 6 passed in 0.01s ===============================तीन बातें ध्यान देने लायक हैं।
पहली, clean_env fixture ख़ुद monkeypatch इस्तेमाल करता है। एक fixture और उसे माँगने वाला टेस्ट एक ही monkeypatch object साझा करते हैं, इसलिए टेस्ट उसके ऊपर अपना setenv जोड़ सकता है, और अंत में दोनों के सारे बदलाव एक साथ वापस ले लिए जाते हैं।
दूसरी, fixture यह भरोसा नहीं करता कि variables मौजूद नहीं हैं — वह उन्हें हटा देता है। इसी से APP_PORT=5 APP_REGION=xx pytest -q के साथ भी सूट वही जवाब देता है — चलाकर देखिए; अब भी 6 passed कहता है। यह योजना की आख़िरी पंक्ति है, किसी अलग टेस्ट से नहीं, design से पूरी की गई।
तीसरी, आख़िरी टेस्ट app_config.read_file को patch करता है — वह module जहाँ load_config नाम खोजता है। दोनों फ़ंक्शन एक ही module में हैं, इसलिए मायने रखने वाली जगह बस वही एक है।
जब यह काम न करे
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Attribute के नाम में typo। setattr ऐसा attribute बनाने से इनकार करता है जो पहले से मौजूद न हो, और यह आपको ऐसे नाम को "patch" करने से बचाता है जिसे कोई कभी पढ़ता ही नहीं। स्ट्रिंग वाला रूप यही बात थोड़ा अलग कहता है — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — पर मतलब वही है। raising=False तभी दीजिए जब आप सच में नया attribute जोड़ना चाहते हों।
Patch लगा है, फिर भी असली फ़ंक्शन चल रहा है कोई error ही नहीं: टेस्ट असली मान के साथ फ़ेल होता है, या क़िस्मत से पास। आपने नाम को वहाँ patch किया जहाँ वह परिभाषित है (random.randint), वहाँ नहीं जहाँ वह खोजा जाता है (dice.randint)। टेस्ट हो रहे कोड की import लाइन देखिए।
monkeypatch.delenv से KeyError: 'APP_DEBUG' Variable सेट नहीं था, और delenv डिफ़ॉल्ट रूप से exception उठाता है। raising=False जोड़िए। delitem और delattr पर भी यही लागू होता है।
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly आपने लिखा setenv("APP_PORT", 9000)। Environment variables टेक्स्ट होते हैं; "9000" लिखिए।
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' datetime.datetime जैसे बिल्ट-इन types के methods बदले नहीं जा सकते। इसके बजाय is_open उदाहरण की तरह घड़ी को argument के रूप में भेजिए।
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. scope="module" (या "session") वाले fixture ने monkeypatch माँगा, जो सिर्फ़ एक टेस्ट तक जीता है। Fixture को function scope पर रखिए, या बड़े fixture के अंदर pytest.MonkeyPatch.context() इस्तेमाल करके वापस लेने का ज़िम्मा ख़ुद लीजिए।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
pytest -q -s से चलाया गया। दोनों print क्या दिखाएँगे (CITY पहले से सेट नहीं है)?
import os
def test_one(monkeypatch):
monkeypatch.setenv("CITY", "Dhaka")
print(os.environ.get("CITY", "none"))
def test_two():
print(os.environ.get("CITY", "none"))- ADhaka, then Dhaka
- Bnone, then none
- CDhaka, then none
- DDhaka, then an error: KeyError: 'CITY'
यह टेस्ट ज़्यादातर फ़ेल होता है और कभी-कभी पास। ग़लती कहाँ है?
# dice.py
from random import randint
def roll():
return randint(1, 6)
# test_dice.py
import random
import dice
def test_roll_six(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.roll() == 6- A`lambda` में दो parameters नहीं होने चाहिए
- BPatch `random` पर है, पर `roll()` नाम को `dice` में खोजता है — `dice.randint` patch कीजिए
- C`monkeypatch` standard library के modules patch नहीं कर सकता
- D`setattr` की जगह `setitem` चाहिए
... की जगह कौन-सी लाइन सबसे अच्छी है, ताकि बाद के टेस्ट फिर से 0.15 देखें?
# shop.py
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
# test_shop.py
import shop
def test_tax_free(monkeypatch):
... # set tax_rate to 0.0 for this test only- Ashop.SETTINGS["tax_rate"] = 0.0
- Bmonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
greeter.py लिखिए, जिसमें एक फ़ंक्शन greeting(name) हो जो लौटाए:
- 12:00 से पहले
"Good morning, <name>", 12:00 से 18:00 तक"Good afternoon, <name>", और उसके बाद"Good evening, <name>" - अगर environment variable
GREETER_LOUDका मान"1"हो, तो बड़े अक्षरों में
और module-स्तर की एक dictionary TEMPLATES = {"morning": "Good morning", ...}, जिससे फ़ंक्शन अपने शब्द पढ़ता है।
फिर test_greeter.py लिखिए, जो:
- तेज़ और धीमे — दोनों cases टेस्ट करने के लिए
setenvऔरdelenv(..., raising=False)इस्तेमाल करे, और चाहे आपके shell मेंGREETER_LOUDसेट हो या न हो, पास हो - एक टेस्ट के लिए
setitemसे सुबह वाला शब्द"Shubho shokal"कर दे, और एक दूसरा टेस्ट साबित करे किTEMPLATESफिर सामान्य है - समय की सीमाएँ टेस्ट करे — 11:59, 12:00, 17:59, 18:00
चरण 3 को दो बार लिखिए। पहली बार greeter.py में from datetime import datetime रखकर monkeypatch.setattr(datetime, "now", ...) आज़माइए — ठीक कौन-सा error आता है, नोट कीजिए। (पूरे नाम greeter.datetime को अपनी बनाई एक छोटी नकली class से बदलना काम करता है; देखिए उसमें कितना कोड लगता है।) फिर greeting को now=datetime.now स्वीकार करने दीजिए, और एक घड़ी भेजकर टेस्ट कीजिए।
दोनों में से कौन-सा संस्करण रखना चाहेंगे? ज़्यादातर अनुभवी testers दूसरा बताते हैं, और दोनों आज़माने के बाद आप वजह अपने हाथों से जानेंगे, इस पेज से नहीं।
समाधान
"टेस्ट लिखने से पहले" की तरह योजना से शुरू कीजिए। greeting के छिपे inputs हैं घड़ी, GREETER_LOUD variable और TEMPLATES dictionary। Cases: चार सीमा वाले मिनट, धीमा और तेज़, एक बदला हुआ template, और यह जाँच कि template वापस आ गया।
greeter.py:
import os
from datetime import datetime
TEMPLATES = {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
}
def greeting(name, now=datetime.now):
hour = now().hour
if hour < 12:
part = "morning"
elif hour < 18:
part = "afternoon"
else:
part = "evening"
text = f"{TEMPLATES[part]}, {name}"
if os.environ.get("GREETER_LOUD") == "1":
text = text.upper()
return texttest_greeter.py:
from datetime import datetime
import pytest
import greeter
def at(hour, minute=0):
# A fake clock: a function that always returns the same moment.
return lambda: datetime(2026, 1, 5, hour, minute)
@pytest.fixture
def quiet(monkeypatch):
monkeypatch.delenv("GREETER_LOUD", raising=False)
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(11, 59, "Good morning, Rina"),
(12, 0, "Good afternoon, Rina"),
(17, 59, "Good afternoon, Rina"),
(18, 0, "Good evening, Rina"),
],
)
def test_time_boundaries(quiet, hour, minute, expected):
assert greeter.greeting("Rina", now=at(hour, minute)) == expected
def test_quiet_by_default(quiet):
assert greeter.greeting("Arif", now=at(9)) == "Good morning, Arif"
def test_loud(monkeypatch):
monkeypatch.setenv("GREETER_LOUD", "1")
assert greeter.greeting("Arif", now=at(9)) == "GOOD MORNING, ARIF"
def test_custom_morning_word(quiet, monkeypatch):
monkeypatch.setitem(greeter.TEMPLATES, "morning", "Shubho shokal")
assert greeter.greeting("Maya", now=at(8)) == "Shubho shokal, Maya"
def test_templates_are_back():
assert greeter.TEMPLATES["morning"] == "Good morning"pytest -v:
collected 8 items
test_greeter.py::test_time_boundaries[11-59-Good morning, Rina] PASSED [ 12%]
test_greeter.py::test_time_boundaries[12-0-Good afternoon, Rina] PASSED [ 25%]
test_greeter.py::test_time_boundaries[17-59-Good afternoon, Rina] PASSED [ 37%]
test_greeter.py::test_time_boundaries[18-0-Good evening, Rina] PASSED [ 50%]
test_greeter.py::test_quiet_by_default PASSED [ 62%]
test_greeter.py::test_loud PASSED [ 75%]
test_greeter.py::test_custom_morning_word PASSED [ 87%]
test_greeter.py::test_templates_are_back PASSED [100%]
============================== 8 passed in 0.01s ===============================और shell में variable export करके, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sइसके पीछे के फ़ैसले:
- घड़ी एक argument है, patch नहीं। पहली कोशिश,
monkeypatch.setattr(datetime, "now", ...),TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'पर रुक जाती है। नकलीdatetimeclass बनाने के बजायgreetingस्वीकार करता हैnow=datetime.now। असली caller लिखते हैंgreeting("Rina")और कुछ महसूस नहीं करते; टेस्ट एक सटीक पल भेजते हैं। at(hour, minute)घड़ी बनाता है। एक छोटा helper हर टेस्ट को एक पढ़ने लायक लाइन में रखता है। वह एक फ़ंक्शन लौटाता है, क्योंकिgreetingकॉल करता हैnow()— सीधेdatetimeobject भेजने परTypeError: 'datetime.datetime' object is not callableआता।- सीमाएँ जोड़ियों में। 11:59 और 12:00, 17:59 और 18:00: हर किनारे के दोनों ओर एक मिनट। अगर कोई ग़लती से
hour <= 12लिख दे, तो 12:00 वाला case उसे पकड़ लेता है। quietएक fixture है, धारणा नहीं। हर वह टेस्ट जिसका अपेक्षित टेक्स्ट छोटे-बड़े अक्षरों में मिला-जुला है, इसे माँगता है, क्योंकि वहGREETER_LOUDके न होने पर निर्भर है। इसीलिए variable export किए दूसरे रन में भी आठों पास होते हैं।test_loudको इसकी ज़रूरत नहीं — वह variable ख़ुद सेट करता है।- पहले
setitem, फिर सबूत।test_custom_morning_wordएक key बदलता है;test_templates_are_backकोई fixture नहीं माँगता और पुष्टि करता है कि key वापस आ गई — वापस लेना आँखों से दिखता है। - टेस्ट नहीं किया गया: कि
str.upper()काम करता है, याdatetime.now()मौजूदा समय देता है। दोनों पायथन की ज़िम्मेदारी हैं।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz