अध्याय 10

monkeypatch — environment, समय और नेटवर्क पर नियंत्रण

Environment variables, घड़ी, randomness या नेटवर्क पर निर्भर कोड को टेस्ट करना। setenv, delenv, setattr, setitem, chdir, हर टेस्ट के बाद अपने-आप undo, "जहाँ खोजा जाता है वहाँ patch कीजिए" का नियम, और बिना patch के dependency injection।

50 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

यह फ़ंक्शन तय करता है कि कोई एप्लिकेशन debug मोड में चलेगा या नहीं। यह एक environment variable पढ़ता है:

settings.py:

python
import os


def debug_enabled():
    return os.environ.get("APP_DEBUG", "0") == "1"

test_settings.py:

python
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 सर्वर — और उसी कोड पर वही टेस्ट फ़ेल हो जाता है:

text
$ 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 मत कीजिए। यह टेस्ट पास होता है और कुछ साबित नहीं करता:

python
import weather


def test_pointless(monkeypatch):
    monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
    assert weather.fetch_temperature("Dhaka") == 35
text
.                                                                        [100%]
1 passed in 0.01s

यह सिर्फ़ जाँचता है कि patch अपनी जगह पर है, यह नहीं कि आपका कोई कोड काम करता है। नकली चीज़ इसलिए होती है ताकि उसके ऊपर दूसरा कोड — नीचे का advice — टेस्ट किया जा सके। अगर assertion में सिर्फ़ वही चीज़ है जिसे आपने बदला, तो टेस्ट मिटा दीजिए।

आगे के sections एक-एक tool लेकर इसी योजना को लागू करते हैं।


setenv और delenv — हर टेस्ट का अपना environment

monkeypatch भी tmp_path जैसा एक fixture है: टेस्ट के parameters में उसका नाम लिखकर माँग लीजिए। थोड़े बड़े settings.py के लिए चार टेस्ट:

python
import os


def debug_enabled():
    return os.environ.get("APP_DEBUG", "0") == "1"


def api_port():
    return int(os.environ.get("APP_PORT", "8000"))
python
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
text
$ 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 अपना बदला हुआ सब कुछ बहाल कर देता है। यह रहा सबूत:

python
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 दिखता है):

text
inside first : 1
.inside second: None
.
2 passed in 0.01s

दूसरे टेस्ट ने monkeypatch नहीं माँगा, फिर भी उसे कुछ नहीं दिखता — variable जा चुका है। अब तुलना कीजिए कि सीधे os.environ में लिखने पर क्या होता है:

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

python
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 में है: सीमाएँ, संदेश। इसे जैसा है वैसा टेस्ट करें तो टेस्ट एक सर्वर, एक नेटवर्क कनेक्शन और आज के मौसम पर निर्भर हो जाता है। बिना नेटवर्क वाली मशीन पर:

text
$ 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.01s

monkeypatch.setattr एक टेस्ट की अवधि के लिए fetch_temperature को आपके नियंत्रण वाली किसी चीज़ से बदल देता है:

python
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"
text
..                                                                       [100%]
2 passed in 0.01s

लक्ष्य बताने के दो तरीक़े हैं, और दोनों एक ही काम करते हैं:

  • setattr(weather, "fetch_temperature", new) — पहले object, फिर attribute का नाम स्ट्रिंग के रूप में।
  • setattr("weather.fetch_temperature", new) — एक ही बिंदुओं वाली स्ट्रिंग; pytest weather को import करता है और आख़िरी हिस्सा अलग कर लेता है।

जब टेस्ट पहले से module import करता है, object वाला रूप इस्तेमाल कीजिए; जब import की ज़रूरत न हो, स्ट्रिंग वाला। दोनों में, ध्यान दीजिए कि अब क्या टेस्ट हो रहा है: fetch_temperature नहीं, जिसे हमने बदल दिया, बल्कि advice की सीमाएँ और संदेश — वह कोड जो हमने लिखा। न नेटवर्क, न इंतज़ार, हर बार वही जवाब — और माँगते ही 35 या 4 डिग्री, जो असली मौसम कभी हुक्म पर नहीं देगा।

टेस्ट में सीधे weather.fetch_temperature = lambda city: 35 लिखने के बजाय setattr क्यों? उसी कारण से जो os.environ के साथ था: सादा assignment कभी वापस नहीं लिया जाता, और रन के बाद वाले हर टेस्ट को नकली फ़ंक्शन ही मिलेगा।

नियम: वहाँ patch कीजिए जहाँ खोजा जाता है

अब वह ग़लती जो हर कोई एक बार करता है। पासा फेंकने वाला एक छोटा module:

dice.py:

python
from random import randint


def roll():
    return randint(1, 6)


def is_jackpot():
    return roll() == 6

Jackpot टेस्ट करने के लिए randint को 6 लौटाने पर मजबूर कीजिए। वह random में रहता है, तो वहीं patch करते हैं:

python
import random

import dice


def test_jackpot(monkeypatch):
    monkeypatch.setattr(random, "randint", lambda a, b: 6)
    assert dice.is_jackpot() is True
text
F                                                                        [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.01s

Patch लगा था, और dice ने उसे कभी देखा ही नहीं। टेस्ट को कुछ और बार चलाइए — कभी-कभी पास होगा, जब भी असली randint संयोग से 6 फेंक दे। जो टेस्ट ग़लत है और कभी-कभी हरा, वह हमेशा लाल रहने वाले टेस्ट से भी बुरा है।

कारण यह है कि from random import randint असल में क्या करता है। वह random.randint से कोई जुड़ाव नहीं बनाता। Import के समय वह reference को एक नए नाम में कॉपी कर देता है: dice.randint। उसके बाद एक ही फ़ंक्शन की ओर दो नाम इशारा करते हैं, और setattr उनमें से सिर्फ़ एक को हटाता है:

python
import random

import dice

print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)
text
True
False

जब roll() चलता है, पायथन randint को dice module में खोजता है — नाम वहीं खोजा जाता है। इसलिए बदलना भी वहीं होगा:

python
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
text
..                                                                       [100%]
2 passed in 0.01s

लगातार पाँच रन, पाँचों पास। दूसरा समाधान है dice.py के import का तरीक़ा बदलना। import random और random.randint(1, 6) कॉल के साथ, हर कॉल पर खोज random module में होती है, और तब random.randint को patch करना काम करता है:

python
import random


def roll():
    return random.randint(1, 6)


def is_jackpot():
    return roll() == 6
text
$ 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:

python
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']}"
python
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:

text
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:

python
from pathlib import Path


def read_notes():
    return Path("notes.txt").read_text().splitlines()
python
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
text
..                                                                       [100%]
2 passed in 0.01s

chdir एक टेस्ट के लिए working directory बदलता है और बाद में वापस ले आता है। दूसरा टेस्ट syspath_prepend का एक लाइन वाला इस्तेमाल दिखाता है: वह एक फ़ोल्डर को sys.path के सबसे आगे रखता है ताकि वहाँ लिखा module import हो सके, और अंत में फिर हटा देता है — उस कोड को टेस्ट करते समय काम का जो किसी फ़ोल्डर से plugins लोड करता है।

इससे भी बेहतर: dependency बाहर से भेजिए

Patch काम करता है, पर हर patch एक छोटी-सी स्वीकारोक्ति है कि कोड ने हाथ बढ़ाकर दुनिया से कुछ उठा लिया। कुछ चीज़ें तो आराम से patch हो ही नहीं सकतीं। datetime.now उनमें से एक है:

text
E       TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

विकल्प एक design का फ़ैसला है: फ़ंक्शन जिस चीज़ पर निर्भर है, उसे वह चीज़ प्राप्त करने दीजिए, असली को default रखकर:

shop_hours.py:

python
from datetime import datetime


def is_open(now=datetime.now):
    hour = now().hour
    return 9 <= hour < 21

Production कोड is_open() कॉल करता है और असली घड़ी पाता है। टेस्ट अपनी घड़ी भेजता है:

python
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
text
...                                                                      [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:

python
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:

python
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"] == 1234

pytest -v:

text
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() इस्तेमाल करके वापस लेने का ज़िम्मा ख़ुद लीजिए।