الفصل 10

monkeypatch — التحكم في البيئة والوقت والشبكة

اختبار الكود الذي يعتمد على متغيرات البيئة أو الساعة أو العشوائية أو الشبكة. setenv و delenv و setattr و setitem و chdir، والإلغاء التلقائي بعد كل اختبار، وقاعدة "رقّع حيث يُبحث عن الاسم"، وحقن الاعتمادية الذي لا يحتاج إلى ترقيع.

50 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

هذه دالة تقرر ما إذا كان التطبيق يعمل في وضع التصحيح (debug). إنها تقرأ متغير بيئة:

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 — حاسوب زميل، أو خادم 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

لم يتغير شيء في الكود. ما تغيّر هو العالم من حوله. متغيرات البيئة، والوقت الحالي، والأرقام العشوائية، والمجلد الحالي، والشبكة — الكود الذي يقرأ أياً منها يعطي جواباً مختلفاً بحسب مكان تشغيله وزمانه، والاختبار الذي لا يتحكم فيها لا يختبر كودك وحده.

يحتاج الاختبار أن يقول: طوال مدة هذا الاختبار، يبدو العالم هكذا — ثم يعيد كل شيء إلى مكانه. وهذا بالضبط ما يفعله الـ fixture المدمج monkeypatch.

بنهاية هذا الفصل ستكون قادراً على

  • ضبط متغيرات البيئة وحذفها لاختبار واحد باستخدام monkeypatch.setenv و monkeypatch.delenv
  • استبدال دالة باستخدام monkeypatch.setattr، إما بكائن واسم، أو بنص من الشكل "module.attr"
  • تغيير مفتاح واحد في قاموس باستخدام monkeypatch.setitem، ومجلد العمل باستخدام monkeypatch.chdir
  • إثبات أن كل تغيير يُلغى عند انتهاء الاختبار
  • ترقيع الاسم حيث يُبحث عنه، وشرح لماذا لا يصل ترقيع random.randint إلى from random import randint
  • تمرير ساعة أو دالة عشوائية كوسيط، بحيث لا يلزم أي ترقيع على الإطلاق

المتطلبات المسبقة: الـ fixtures المدمجة.


قبل أن تكتب الاختبار

مع كود كهذا، يجري معظم العمل قبل أول def test_. أربعة أسئلة، بهذا الترتيب.

1. بماذا تَعِد الدالة — وما الذي تقرؤه دون أن تمرّره لها؟ اقرأ الدالة وعلّم كل سطر يأخذ شيئاً من خارج وسائطها: os.environ، datetime.now()، random، مسار ملف نسبي، قاموس على مستوى الوحدة، استدعاء شبكي. كل واحد منها مُدخل خفي. وعد الدالة هو "إذا أُعطيتُ هذه المدخلات الخفية أعيد هذا"، وعلى الاختبار أن يثبّت كل مدخل خفي، وإلا فهو يختبر ما يصادف وجوده على الجهاز.

2. ما الذي يجب أن يكون جاهزاً؟ المعتاد: بيئة افتراضية مثبّت فيها pytest، ووحدة يمكن استيرادها من الاختبار (ملف الاختبار بجانبها، أو المشروع مثبّت). ثم، لهذا الموضوع، قرار واحد لكل مدخل خفي:

  • متغير بيئة — اضبطه بـ setenv، أو احذفه بـ delenv مع raising=False؛
  • دالة تصل إلى الشبكة أو القرص أو مصدر عشوائي — استبدلها بـ setattr، في الوحدة التي تستدعيها؛
  • قاموس على مستوى الوحدة — غيّر مفتاحاً واحداً بـ setitem؛
  • مسار نسبي — انتقل بـ chdir إلى tmp_path؛
  • ساعة أو اختيار عشوائي في كود تكتبه الآن — مرّره كوسيط بدلاً من ذلك.

3. أي الحالات؟ خذ مُحمّل الإعدادات الذي ستبنيه في نهاية هذا الفصل. يقرأ القيم الافتراضية أولاً، ثم ملف app.cfg اختيارياً، ثم متغيرات البيئة APP_*. خطته، قبل أي كود:

| الحالة | العالم أثناء الاختبار | المتوقع | |---|---|---| | لا شيء مضبوط | مجلد فارغ، لا متغيرات APP_* | port 8000، debug False، region "bd" | | الملف يتغلب على الافتراضي | app.cfg يحتوي port = 9000 | port 9000 | | البيئة تتغلب على الملف | app.cfg يقول 9000، و APP_PORT=7000 | port 7000 | | تغيّر قيمة افتراضية | DEFAULTS["region"] تساوي "ae" | region "ae" | | قيمة غير صالحة | APP_PORT=70000 | ValueError يذكر "out of range" | | استبدال القارئ | read_file يعيد {"port": "1234"} | port 1234 | | ضجيج في الطرفية | APP_PORT=5 مُصدَّر قبل تشغيل pytest | كل صف أعلاه يبقى صحيحاً |

الصف الأخير هو الذي ينساه الناس. ليس اختباراً قائماً بذاته — بل خاصية لمجموعة الاختبارات كلها، وهو ما يقرر أن كل اختبار يجب أن ينظف المتغيرات أولاً، لا الاختبارات التي تضبطها فحسب.

4. ما الذي لا تختبره. لا تختبر أن os.environ يخزن نصوصاً أو أن urllib يستطيع جلب صفحة — هذه مهمة بايثون، وهي مختبرة سلفاً. لا تدع اختبار وحدة يلمس الشبكة الحقيقية: فهو بطيء، ويفشل لأسباب لا علاقة لها بكودك، والطقس يتغير. ولا تتحقق أبداً من البديل المزيف نفسه. هذا الاختبار ينجح ولا يثبت شيئاً:

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

إنه يتحقق من أن الترقيع في مكانه، لا من أن أي جزء من كودك يعمل. البديل المزيف موجود ليُختبر فوقه كود آخر — advice أدناه. إذا كان التحقق لا يذكر إلا الشيء الذي استبدلته، فاحذف الاختبار.

الأقسام التالية تنفذ هذه الخطة أداةً أداة.


setenv و delenv — بيئة خاصة بكل اختبار

monkeypatch هو fixture مثل tmp_path: اطلبه باسمه في معاملات الاختبار. إليك أربعة اختبارات لملف 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

التشغيل الثاني هو المهم. صدّرت الطرفية قيماً كانت ستكسر اثنين من هذه الاختبارات، ومع ذلك تنجح الأربعة كلها، لأن كل اختبار يحدد البيئة التي يحتاجها.

setenv(name, value) يضبط متغيراً. delenv(name) يحذف متغيراً — ويرفع افتراضياً KeyError إذا لم يكن المتغير موجوداً أصلاً. اختبار حالة "غير مضبوط" لا يهمه إن كان مضبوطاً من قبل، بل أن يكون غير مضبوط الآن، لذا فإن raising=False هو ما تريده في أغلب الأحيان.

القيم نصوص. البيئة لا تحمل إلا نصاً، وكذلك 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 ومع ذلك لا يرى شيئاً — المتغير اختفى. قارن ذلك بما يحدث عندما تكتب في 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

تسربت القيمة إلى الاختبار التالي، وستتسرب إلى كل اختبار بعده في التشغيل نفسه. هذا هو نوع الخلل الذي يجعل الاختبار ينجح وحده ويفشل مع غيره — أو ما هو أسوأ، العكس.

لا سحر في الإلغاء. قبل كل تغيير يسجّل monkeypatch القيمة القديمة — أو حقيقة أنه لم تكن هناك قيمة — وأثناء مرحلة التفكيك (teardown) يعيد كل قيمة مسجّلة بترتيب معكوس. التفكيك يجري سواء نجح الاختبار أو فشل أو رفع استثناء، فلا يستطيع تحقق فاشل في منتصف الطريق أن يترك البيئة ملوثة. أما os.environ[...] = ... مكتوباً باليد ومتبوعاً بسطر تنظيف مكتوب باليد في نهاية الاختبار، فإن ذلك التحقق الفاشل بالذات سيتخطى سطر التنظيف.

setattr — استبدال دالة لاختبار واحد

متغيرات البيئة هي الحالة اللطيفة. الأصعب هو الكود الذي يتحدث إلى الشبكة:

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"

المنطق المهم موجود في 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) — الكائن، ثم اسم الخاصية كنص.
  • setattr("weather.fetch_temperature", new) — نص واحد بنقاط؛ يستورد pytest الوحدة weather ويفصل الجزء الأخير.

استخدم صيغة الكائن عندما يستورد الاختبار الوحدة أصلاً، وصيغة النص عندما لا يحتاج إلى ذلك. وفي الحالتين، لاحظ ما الذي يُختبر الآن: ليس fetch_temperature التي استبدلناها، بل عتبات advice ورسائلها، أي الكود الذي كتبناه. لا شبكة، لا انتظار، الجواب نفسه في كل مرة — ودرجة حرارة 35 أو 4 عند الطلب، وهو ما لن يمنحك إياه الطقس الحقيقي متى شئت.

لماذا setattr وليس مجرد weather.fetch_temperature = lambda city: 35 في الاختبار؟ للسبب نفسه الذي رأيناه مع os.environ: الإسناد العادي لا يُلغى أبداً، وكل اختبار لاحق في التشغيل سيحصل على البديل المزيف.

القاعدة: رقّع حيث يُبحث عن الاسم

والآن الخطأ الذي يقع فيه الجميع مرة. وحدة صغيرة ترمي حجر نرد:

dice.py:

python
from random import randint


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


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

لاختبار الجائزة الكبرى، أجبر randint على إعادة 6. إنها تعيش في random، فلنرقّعها هناك:

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

طُبّق الترقيع، ولم تره dice قط. شغّل الاختبار بضع مرات أخرى وسينجح أحياناً — كلما صادف أن رمت randint الحقيقية الرقم 6. الاختبار الخاطئ الذي يخضرّ أحياناً أسوأ من اختبار أحمر دائماً.

السبب هو ما تفعله from random import randint فعلاً. إنها لا تنشئ رابطاً إلى random.randint، بل تنسخ المرجع إلى اسم جديد، 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 — هناك يُبحث عن الاسم. فهناك يجب استبداله:

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 random واستدعاء random.randint(1, 6)، يجري البحث في الوحدة random عند كل استدعاء، وعندها ينجح ترقيع random.randint:

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

القاعدة في جملة واحدة: ابحث عن السطر الذي يستدعي الدالة، وانظر من أي وحدة يأخذ الاسم، ورقّع الاسم هناك.

setitem — مفتاح واحد في قاموس

كثيراً ما تُحفظ الإعدادات في قاموس على مستوى الوحدة. استبدال القاموس كله سيكون أخرق؛ setitem يغيّر مفتاحاً واحداً ويعيده بعد ذلك:

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 ===============================

الاختبار الثالث هو الدليل مرة أخرى: بعد أن غيّر اختباران مفتاحاً لكل منهما، عاد القاموس تماماً كما بدأ. ورفيقه monkeypatch.delitem(d, key) يحذف مفتاحاً لاختبار واحد.

chdir و syspath_prepend

الكود الذي يفتح مساراً نسبياً مثل "notes.txt" يعتمد على المجلد الذي يعمل منه. اجمع chdir مع tmp_path من الفصل السابق:

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 مجلد العمل لاختبار واحد ثم يعيده بعد ذلك. ويُظهر الاختبار الثاني syspath_prepend في استخدامه ذي السطر الواحد: يضع مجلداً في مقدمة sys.path لكي يمكن استيراد وحدة مكتوبة فيه، ثم يزيله في النهاية — وهذا مفيد عند اختبار كود يحمّل الإضافات (plugins) من مجلد.

الأفضل من ذلك: مرّر الاعتمادية من الخارج

الترقيع يعمل، لكن كل ترقيع اعتراف صغير بأن الكود مدّ يده وانتزع شيئاً من العالم. وبعض الأشياء لا يمكن ترقيعها بسهولة أصلاً. datetime.now واحدة منها:

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

البديل قرار تصميمي: دع الدالة تستقبل الشيء الذي تعتمد عليه، مع جعل الحقيقي هو القيمة الافتراضية:

shop_hours.py:

python
from datetime import datetime


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

كود الإنتاج يستدعي 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 المتجر مغلق" — ومن السهل إصابة الدقائق الحدّية المهمة بدقة.

الشكل نفسه يصلح للعشوائية: def pick_winner(customers, choose=random.choice)، ويمرر الاختبار choose=lambda items: items[0]. هذا يسمى حقن الاعتمادية (dependency injection)، ولا يحتاج إلى أي إطار عمل: وسيط افتراضي يكفي. استخدمه حين تكتب الكود. ولجأ إلى monkeypatch حين تختبر كوداً لا تستطيع أو لا ينبغي لك تغييره، أو حين تكون الاعتمادية مدفونة على عمق عدة استدعاءات.


مثال تطبيقي متكامل

هذا هو مُحمّل الإعدادات الذي خططنا له في "قبل أن تكتب الاختبار": ثلاث طبقات — قيم افتراضية مدمجة، وملف app.cfg اختياري في المجلد الحالي، ومتغيرات بيئة تتغلب على كليهما. كل صف في الخطة يصبح اختباراً واحداً.

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 ===============================

ثلاثة أمور تستحق الانتباه.

أولاً، الـ fixture المسمى clean_env يستخدم monkeypatch بنفسه. الـ fixture والاختبار الذي يطلبه يتشاركان كائن monkeypatch واحداً، لذا يستطيع الاختبار أن يضيف setenv خاصاً به فوقه، وتُلغى كل تغييرات الاثنين معاً في النهاية.

ثانياً، يحذف الـ fixture المتغيرات بدلاً من الثقة بأنها غائبة. وهذا ما يجعل المجموعة تعطي الجواب نفسه مع APP_PORT=5 APP_REGION=xx pytest -q ومن دونه — جرّب ذلك؛ ستظل تقول 6 passed. هذا هو الصف الأخير من الخطة، محققاً بالتصميم لا باختبار مستقل.

ثالثاً، يرقّع الاختبار الأخير app_config.read_file — الوحدة التي تبحث فيها load_config عن الاسم. ولأن الدالتين تعيشان في الوحدة نفسها، فهذا هو المكان الوحيد المهم.


الأخطاء الشائعة وحلولها

AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' خطأ إملائي في اسم الخاصية. يرفض setattr إنشاء خاصية غير موجودة مسبقاً، وهذا يحميك من "ترقيع" اسم لا يقرؤه أحد أبداً. الصيغة النصية تقول ذلك بشكل مختلف — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — لكن المعنى واحد. لا تمرر raising=False إلا إذا كنت تقصد فعلاً إضافة خاصية جديدة.

الترقيع مطبّق لكن الدالة الحقيقية ما زالت تعمل لا خطأ على الإطلاق: يفشل الاختبار بالقيمة الحقيقية، أو ينجح بالصدفة. لقد رقّعت الاسم حيث عُرّف (random.randint) بدلاً من حيث يُبحث عنه (dice.randint). انظر إلى سطر الاستيراد في الكود المختبَر.

KeyError: 'APP_DEBUG' من monkeypatch.delenv المتغير لم يكن مضبوطاً، و delenv يرفع استثناء افتراضياً. أضف 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). متغيرات البيئة نصوص؛ اكتب "9000".

TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' الأنواع المدمجة مثل datetime.datetime لا يمكن استبدال دوالها. مرّر الساعة كوسيط بدلاً من ذلك، كما في مثال is_open.

ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. طلب fixture ذو scope="module" (أو "session") الـ monkeypatch، الذي لا يعيش إلا لاختبار واحد. أبقِ الـ fixture بنطاق الدالة، أو استخدم pytest.MonkeyPatch.context() داخل الـ fixture الأوسع وتولَّ الإلغاء بنفسك.