monkeypatch — التحكم في البيئة والوقت والشبكة
اختبار الكود الذي يعتمد على متغيرات البيئة أو الساعة أو العشوائية أو الشبكة. setenv و delenv و setattr و setitem و chdir، والإلغاء التلقائي بعد كل اختبار، وقاعدة "رقّع حيث يُبحث عن الاسم"، وحقن الاعتمادية الذي لا يحتاج إلى ترقيع.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
هذه دالة تقرر ما إذا كان التطبيق يعمل في وضع التصحيح (debug). إنها تقرأ متغير بيئة:
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 — حاسوب زميل، أو خادم 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لم يتغير شيء في الكود. ما تغيّر هو العالم من حوله. متغيرات البيئة، والوقت الحالي، والأرقام العشوائية، والمجلد الحالي، والشبكة — الكود الذي يقرأ أياً منها يعطي جواباً مختلفاً بحسب مكان تشغيله وزمانه، والاختبار الذي لا يتحكم فيها لا يختبر كودك وحده.
يحتاج الاختبار أن يقول: طوال مدة هذا الاختبار، يبدو العالم هكذا — ثم يعيد كل شيء إلى مكانه. وهذا بالضبط ما يفعله الـ 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 يستطيع جلب صفحة — هذه مهمة بايثون، وهي مختبرة سلفاً. لا تدع اختبار وحدة يلمس الشبكة الحقيقية: فهو بطيء، ويفشل لأسباب لا علاقة لها بكودك، والطقس يتغير. ولا تتحقق أبداً من البديل المزيف نفسه. هذا الاختبار ينجح ولا يثبت شيئاً:
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إنه يتحقق من أن الترقيع في مكانه، لا من أن أي جزء من كودك يعمل. البديل المزيف موجود ليُختبر فوقه كود آخر — advice أدناه. إذا كان التحقق لا يذكر إلا الشيء الذي استبدلته، فاحذف الاختبار.
الأقسام التالية تنفذ هذه الخطة أداةً أداة.
setenv و delenv — بيئة خاصة بكل اختبار
monkeypatch هو fixture مثل tmp_path: اطلبه باسمه في معاملات الاختبار. إليك أربعة اختبارات لملف 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التشغيل الثاني هو المهم. صدّرت الطرفية قيماً كانت ستكسر اثنين من هذه الاختبارات، ومع ذلك تنجح الأربعة كلها، لأن كل اختبار يحدد البيئة التي يحتاجها.
setenv(name, value) يضبط متغيراً. delenv(name) يحذف متغيراً — ويرفع افتراضياً KeyError إذا لم يكن المتغير موجوداً أصلاً. اختبار حالة "غير مضبوط" لا يهمه إن كان مضبوطاً من قبل، بل أن يكون غير مضبوط الآن، لذا فإن raising=False هو ما تريده في أغلب الأحيان.
القيم نصوص. البيئة لا تحمل إلا نصاً، وكذلك 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 ومع ذلك لا يرى شيئاً — المتغير اختفى. قارن ذلك بما يحدث عندما تكتب في 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تسربت القيمة إلى الاختبار التالي، وستتسرب إلى كل اختبار بعده في التشغيل نفسه. هذا هو نوع الخلل الذي يجعل الاختبار ينجح وحده ويفشل مع غيره — أو ما هو أسوأ، العكس.
لا سحر في الإلغاء. قبل كل تغيير يسجّل monkeypatch القيمة القديمة — أو حقيقة أنه لم تكن هناك قيمة — وأثناء مرحلة التفكيك (teardown) يعيد كل قيمة مسجّلة بترتيب معكوس. التفكيك يجري سواء نجح الاختبار أو فشل أو رفع استثناء، فلا يستطيع تحقق فاشل في منتصف الطريق أن يترك البيئة ملوثة. أما os.environ[...] = ... مكتوباً باليد ومتبوعاً بسطر تنظيف مكتوب باليد في نهاية الاختبار، فإن ذلك التحقق الفاشل بالذات سيتخطى سطر التنظيف.
setattr — استبدال دالة لاختبار واحد
متغيرات البيئة هي الحالة اللطيفة. الأصعب هو الكود الذي يتحدث إلى الشبكة:
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"المنطق المهم موجود في 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.01sيستبدل monkeypatch.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)— الكائن، ثم اسم الخاصية كنص.setattr("weather.fetch_temperature", new)— نص واحد بنقاط؛ يستورد pytest الوحدةweatherويفصل الجزء الأخير.
استخدم صيغة الكائن عندما يستورد الاختبار الوحدة أصلاً، وصيغة النص عندما لا يحتاج إلى ذلك. وفي الحالتين، لاحظ ما الذي يُختبر الآن: ليس fetch_temperature التي استبدلناها، بل عتبات advice ورسائلها، أي الكود الذي كتبناه. لا شبكة، لا انتظار، الجواب نفسه في كل مرة — ودرجة حرارة 35 أو 4 عند الطلب، وهو ما لن يمنحك إياه الطقس الحقيقي متى شئت.
لماذا setattr وليس مجرد weather.fetch_temperature = lambda city: 35 في الاختبار؟ للسبب نفسه الذي رأيناه مع os.environ: الإسناد العادي لا يُلغى أبداً، وكل اختبار لاحق في التشغيل سيحصل على البديل المزيف.
القاعدة: رقّع حيث يُبحث عن الاسم
والآن الخطأ الذي يقع فيه الجميع مرة. وحدة صغيرة ترمي حجر نرد:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6لاختبار الجائزة الكبرى، أجبر randint على إعادة 6. إنها تعيش في random، فلنرقّعها هناك:
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.01sطُبّق الترقيع، ولم تره dice قط. شغّل الاختبار بضع مرات أخرى وسينجح أحياناً — كلما صادف أن رمت randint الحقيقية الرقم 6. الاختبار الخاطئ الذي يخضرّ أحياناً أسوأ من اختبار أحمر دائماً.
السبب هو ما تفعله from random import randint فعلاً. إنها لا تنشئ رابطاً إلى random.randint، بل تنسخ المرجع إلى اسم جديد، 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 — هناك يُبحث عن الاسم. فهناك يجب استبداله:
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 random واستدعاء random.randint(1, 6)، يجري البحث في الوحدة random عند كل استدعاء، وعندها ينجح ترقيع random.randint:
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القاعدة في جملة واحدة: ابحث عن السطر الذي يستدعي الدالة، وانظر من أي وحدة يأخذ الاسم، ورقّع الاسم هناك.
setitem — مفتاح واحد في قاموس
كثيراً ما تُحفظ الإعدادات في قاموس على مستوى الوحدة. استبدال القاموس كله سيكون أخرق؛ setitem يغيّر مفتاحاً واحداً ويعيده بعد ذلك:
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 ===============================الاختبار الثالث هو الدليل مرة أخرى: بعد أن غيّر اختباران مفتاحاً لكل منهما، عاد القاموس تماماً كما بدأ. ورفيقه monkeypatch.delitem(d, key) يحذف مفتاحاً لاختبار واحد.
chdir و syspath_prepend
الكود الذي يفتح مساراً نسبياً مثل "notes.txt" يعتمد على المجلد الذي يعمل منه. اجمع chdir مع tmp_path من الفصل السابق:
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.01sينقل chdir مجلد العمل لاختبار واحد ثم يعيده بعد ذلك. ويُظهر الاختبار الثاني syspath_prepend في استخدامه ذي السطر الواحد: يضع مجلداً في مقدمة sys.path لكي يمكن استيراد وحدة مكتوبة فيه، ثم يزيله في النهاية — وهذا مفيد عند اختبار كود يحمّل الإضافات (plugins) من مجلد.
الأفضل من ذلك: مرّر الاعتمادية من الخارج
الترقيع يعمل، لكن كل ترقيع اعتراف صغير بأن الكود مدّ يده وانتزع شيئاً من العالم. وبعض الأشياء لا يمكن ترقيعها بسهولة أصلاً. datetime.now واحدة منها:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'البديل قرار تصميمي: دع الدالة تستقبل الشيء الذي تعتمد عليه، مع جعل الحقيقي هو القيمة الافتراضية:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21كود الإنتاج يستدعي 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 المتجر مغلق" — ومن السهل إصابة الدقائق الحدّية المهمة بدقة.
الشكل نفسه يصلح للعشوائية: def pick_winner(customers, choose=random.choice)، ويمرر الاختبار choose=lambda items: items[0]. هذا يسمى حقن الاعتمادية (dependency injection)، ولا يحتاج إلى أي إطار عمل: وسيط افتراضي يكفي. استخدمه حين تكتب الكود. ولجأ إلى monkeypatch حين تختبر كوداً لا تستطيع أو لا ينبغي لك تغييره، أو حين تكون الاعتمادية مدفونة على عمق عدة استدعاءات.
مثال تطبيقي متكامل
هذا هو مُحمّل الإعدادات الذي خططنا له في "قبل أن تكتب الاختبار": ثلاث طبقات — قيم افتراضية مدمجة، وملف app.cfg اختياري في المجلد الحالي، ومتغيرات بيئة تتغلب على كليهما. كل صف في الخطة يصبح اختباراً واحداً.
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 ===============================ثلاثة أمور تستحق الانتباه.
أولاً، الـ 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 الأوسع وتولَّ الإلغاء بنفسك.
Step 4 of 6 — Predict
Check your understanding
شُغّل بالأمر 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` معاملين
- Bالترقيع على `random`، لكن `roll()` تبحث عن الاسم في `dice` — رقّع `dice.randint`
- Cلا يستطيع `monkeypatch` ترقيع وحدات المكتبة القياسية
- Dيلزم `setitem` بدلاً من `setattr`
أي سطر هو الأفضل مكان ...، بحيث ترى الاختبارات اللاحقة 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)
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
اكتب greeter.py يحتوي دالة greeting(name) تعيد:
"Good morning, <name>"قبل 12:00، و"Good afternoon, <name>"من 12:00 حتى 18:00، و"Good evening, <name>"بعد ذلك- بأحرف كبيرة عندما يكون متغير البيئة
GREETER_LOUDمساوياً"1"
وقاموساً على مستوى الوحدة TEMPLATES = {"morning": "Good morning", ...} تقرأ منه الدالة كلماتها.
ثم اكتب test_greeter.py بحيث:
- يستخدم
setenvوdelenv(..., raising=False)لاختبار الحالتين العالية والهادئة، وينجح سواء كانGREETER_LOUDمضبوطاً في طرفيتك أم لا - يستخدم
setitemلتغيير كلمة الصباح إلى"Shubho shokal"لاختبار واحد، ومعه اختبار ثانٍ يثبت أنTEMPLATESعاد إلى طبيعته - يختبر حدود الوقت — 11:59، 12:00، 17:59، 18:00
اكتب الخطوة 3 مرتين. في المرة الأولى أبقِ from datetime import datetime داخل greeter.py وجرّب monkeypatch.setattr(datetime, "now", ...) — سجّل بالضبط الخطأ الذي يظهر. (استبدال الاسم كله greeter.datetime بصنف مزيف صغير من صنعك يعمل فعلاً؛ لاحظ كم من الكود يتطلب ذلك.) ثم غيّر greeting لتقبل now=datetime.now واختبرها بتمرير ساعة.
أي النسختين تفضّل أن تحتفظ بها؟ الثانية هي جواب معظم المختبِرين ذوي الخبرة، وبعد أن تجرب الاثنتين ستعرف السبب بيديك، لا من هذه الصفحة.
الحل
ابدأ بالخطة، كما في "قبل أن تكتب الاختبار". المدخلات الخفية لـ greeting هي الساعة، والمتغير GREETER_LOUD، والقاموس TEMPLATES. الحالات: الدقائق الحدّية الأربع، والهادئة والعالية، وقالب مُغيَّر، والتحقق من أن القالب عاد.
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 ===============================ومع تصدير المتغير في الطرفية، GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sالقرارات وراء ذلك:
- الساعة وسيط، لا ترقيع. المحاولة الأولى،
monkeypatch.setattr(datetime, "now", ...)، تتوقف عندTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. فبدلاً من بناء صنفdatetimeمزيف، تقبلgreetingالوسيطnow=datetime.now. المستدعون الحقيقيون يكتبونgreeting("Rina")ولا يلاحظون شيئاً؛ والاختبارات تمرر لحظة محددة. at(hour, minute)تبني الساعة. دالة مساعدة صغيرة تُبقي كل اختبار في سطر واحد مقروء. إنها تعيد دالة، لأنgreetingتستدعيnow()— وتمرير كائنdatetimeمباشرة كان سيفشل بـTypeError: 'datetime.datetime' object is not callable.- الحدود أزواجاً. 11:59 و 12:00، و 17:59 و 18:00: دقيقة واحدة على جانبي كل حافة. إذا كتب أحدهم
hour <= 12خطأً، فإن حالة 12:00 تكشفه. quietfixture، لا افتراض. كل اختبار نصه المتوقع بأحرف مختلطة يطلبه، لأنه يعتمد على غيابGREETER_LOUD. لهذا يعطي التشغيل الثاني، مع تصدير المتغير، ثمانية نجاحات أيضاً. أماtest_loudفلا يحتاجه — فهو يضبط المتغير بنفسه.setitemثم الدليل. يغيّرtest_custom_morning_wordمفتاحاً واحداً؛ ولا يطلبtest_templates_are_backأي fixture ويؤكد أن المفتاح عاد، فيصبح الإلغاء مرئياً.- ما لم يُختبر: أن
str.upper()يعمل، أو أنdatetime.now()تعيد الوقت الحالي. كلاهما من مسؤولية بايثون.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz