الإعدادات والتحذيرات — القواعد في ملف واحد
كفّ عن كتابة الخيارات نفسها في كل تشغيل: ضع قواعد المشروع في pyproject.toml أو pytest.ini — testpaths و addopts و pythonpath و markers و xfail_strict و log_cli — واختبر التحذيرات وتحكّم فيها بـ pytest.warns و deprecated_call و filterwarnings.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
أصبح مشروع المتجر الآن على الهيئة التي تشترك فيها معظم المشاريع الحقيقية: الشيفرة في src/، والاختبارات في tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyلقد تعلّمت كيف تشغّله بالطريقة الصحيحة، وأصبحت "الطريقة الصحيحة" سطراً طويلاً:
PYTHONPATH=src pytest -ra --strict-markers testsينسخ أحد زملائك المستودع، ويكتب ما سيكتبه أي شخص آخر:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================لا يوجد أي خطأ في الشيفرة. الخطأ هو أن طريقة تشغيل الاختبارات تعيش في ذهنك وفي سجل أوامر الطرفية لديك، بدلاً من أن تعيش داخل المشروع نفسه. وسيشغّل نظام التكامل المستمر (CI) صيغة ثالثة، فيرى ثلاثة أشخاص ثلاث نتائج مختلفة من إيداع (commit) واحد.
هذه الخيارات ليست تفضيلاً شخصياً لك؛ بل هي قواعد المشروع. ينقلها هذا الفصل إلى ملف يقرؤه pytest في كل مرة — ثم يستخدم الملف نفسه لحل مشكلة ثانية أهدأ: التحذيرات التي تمرّ أمام عينيك على الشاشة ولا يقرؤها أحد.
بنهاية هذا الفصل ستكون قادراً على
- وضع إعدادات pytest في
pyproject.toml(أوpytest.ini) والتأكد من الترويسة أي ملف استُخدم - اختيار
testpathsوaddoptsوpythonpathوminversionوmarkersوxfail_strictعن قصد، وتعليل كل اختيار - تفعيل السجلات الحية عبر
log_cliعندما تحتاج إلى مراقبة التشغيل - التحقق من أن الشيفرة تطلق تحذيراً باستخدام
pytest.warnsوpytest.deprecated_call، مع استخدامmatchوالقائمة المسجَّلة - التحكم في التحذيرات عبر
filterwarningsفي ملف الإعدادات، وكعلامة (mark)، وعبر-W— وتحويلها إلى أخطاء في CI
المتطلبات المسبقة: العلامات و skip و xfail.
قبل أن تكتب الاختبار
يكتب هذا الفصل اختبارات أقل من المعتاد، وقواعد أكثر حول كيفية تشغيل الاختبارات. لكن التفكير الذي يسبق ذلك هو نفسه: حدّد ما الذي تَعِد به الشيفرة، ثم خطّط للحالات التي تتحقق من هذا الوعد.
ما الذي نَعِد به. هناك نوعان من الوعود هنا.
- كيف تُشغَّل مجموعة الاختبارات. كل من يكتب
pytestمجرداً يحصل على الاختبارات نفسها، ومسار الاستيراد نفسه، ودرجة الصرامة نفسها التي يحصل عليها CI. العلامة المكتوبة بخطأ إملائي توقف التشغيل. واختبارxfailالذي بدأ ينجح يُبلَّغ عنه ولا يُخفى. - ما تقوله الشيفرة إلى جانب القيمة التي تعيدها. تعيد
apply_discount(total, percent)رقماً، وعندما يكونpercent > 50تطلق أيضاً تحذيراً من نوعUserWarningيقول إن الخصم "looks unusually large". وما زالتtotal_with_vat()تعمل، لكنها تطلقDeprecationWarningيشير إلىline_total(). التحذير مُخرَج من مخرجات الشيفرة؛ وعندما يكون جزءاً من العقد، فإنه يُختبر كما يُختبر أي مُخرَج.
ما الذي يجب أن يكون جاهزاً. بيئة افتراضية فيها pytest 9 (pytest --version)، وشيفرة يمكن استيرادها من الاختبارات (وهذا ما يفعله pythonpath أدناه)، وملف إعدادات واحد في جذر المشروع. لا حاجة إلى إضافات (plugins) أخرى.
الخطة لسلوك التحذيرات:
| الحالة | المُدخَل | المتوقَّع | | --- | --- | --- | | المسار الطبيعي | apply_discount(200.0, 10) | 180.0، دون تحذير | | الحد الفاصل، ما زال هادئاً | apply_discount(200.0, 50) | 100.0، دون تحذير | | بعد الحد الفاصل مباشرة | apply_discount(200.0, 60) | 80.0 وتحذير UserWarning واحد يذكر "unusually large" | | اسم مُهمَل (deprecated) | total_with_vat(100.0, 1) | 115.0 وتحذير DeprecationWarning يذكر line_total | | إهمال صادر عن مكتبة خارجية | أي شيء يطلقه | لا يُفشل مجموعة اختباراتنا نحن |
أما بالنسبة للإعدادات نفسها، فإن "الاختبار" هو التشغيل: يجب أن يجمع pytest المجرد، سواء من الجذر أو من داخل tests/، المجلد tests/ وحده وأن ينجح، ويجب أن توقف العلامةُ المكتوبة بخطأ إملائي التشغيلَ.
ما الذي لا يجب اختباره. لا تختبر أن وحدة warnings في بايثون تعمل، ولا أن pytest يقرأ ملف إعداداته — فتلك شيفرة شخص آخر، وقد اختُبرت. ولا تختبر النص الكامل للتحذير: طابِق العبارة التي يتصرف القارئ بناءً عليها ("unusually large")، حتى لا تؤدي إعادة صياغة بقية النص إلى كسر أي شيء. ولا تختبر تحذيرات الإهمال الصادرة عن مكتبات خارجية — فهذه تُرشَّح، وهذا قرار يخص الإعدادات، وليس اختباراً.
صف الحد الفاصل هو الصف الذي ينساه الناس، لأن غياب التحذير لا يُحدث أي ضجيج. وبنهاية الفصل، سيحوّله سطر واحد في ملف الإعدادات إلى فحص تحصل عليه مجاناً.
ملف واحد يُقرأ قبل أي شيء آخر
يقرأ pytest إعداداته قبل أن يجمع أي اختبار. والمكان المعتاد لها هو ملف pyproject.toml الموجود أصلاً في المشروع، داخل جدول اسمه [tool.pytest.ini_options]:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]يسرد pythonpath مجلدات، نسبةً إلى جذر المشروع، يضيفها pytest إلى sys.path قبل استيراد الاختبارات. أما الشيفرة قيد الاختبار فهي عادية:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================اقرأ الترويسة. السطر configfile: pyproject.toml هو pytest يخبرك أي ملف استخدم. كلما بدا لك أن الإعدادات مُتجاهَلة، افحص هذا السطر أولاً.
لماذا لا نصلح الاستيراد داخل ملف الاختبار؟ إنها الطريقة الخاطئة الواضحة، وهي تعمل — أحياناً:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sالقيمة "src" هنا نسبية إلى المجلد الحالي، فينجح الاختبار أو يفشل بحسب المكان الذي تقف فيه. أما pythonpath في ملف الإعدادات فهو نسبي إلى جذر المشروع، وهو ثابت لا يتحرك. تهيئة الاستيراد تخص المشروع، لذا مكانها ملف المشروع، مرة واحدة، وليس أعلى كل وحدة اختبار.
أي ملف يفوز، وأين يقع الجذر
يقبل pytest عدة ملفات. في كل مجلد، يفحصها بهذا الترتيب: pytest.toml، .pytest.toml، pytest.ini، .pytest.ini، pyproject.toml، tox.ini، setup.cfg. يبدأ من المسارات المذكورة في سطر الأوامر (أو من المجلد الحالي) ويصعد إلى الأعلى؛ وأول ملف يحتوي فعلاً على قسم خاص بـ pytest هو الفائز. ملف pyproject.toml الذي لا يحتوي على جدول pytest لا يُحتسب.
يصبح ذلك المجلد هو rootdir — أي سطر rootdir: في الترويسة — وتكون المسارات النسبية في ملف الإعدادات نسبيةً إليه. لذلك، حتى لو وقفت داخل tests/، فإن pytest يجد الجذر:
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 itemsتستخدم الإعدادات نفسها في pytest.ini صيغة INI، مع قسم [pytest]، ودون علامات تنصيص أو أقواس معقوفة:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'اختر ملفاً واحداً. إذا وُجد الملفان معاً، يفوز pytest.ini بالكامل — ولا يُدمج أي شيء — والترويسة تقول ذلك صراحة. هنا أُضيف ملف pytest.ini من سطرين (minversion = 9.0) بجانب pyproject.toml:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…ويعود الخطأ ModuleNotFoundError، لأن pythonpath كان موجوداً في الملف الذي أصبح الآن مُتجاهَلاً.
testpaths — أين تبحث عندما لا تحدد شيئاً
ترك أحدهم ملفاً للتجارب، notes/test_scratch.py، يجرّب فيه التقريب. يجمع pytest المجرد كل ما تحت الجذر، فيُشغَّل هذا الملف أيضاً:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01sيحدد testpaths المكان الذي تعيش فيه مجموعة الاختبارات الحقيقية:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sفي الترويسة الكاملة سترى الآن أيضاً testpaths: tests. إنه قيمة افتراضية، وليس سياجاً: يُطبَّق فقط عندما تشغّل من الجذر دون تحديد مسارات، لذا فإن pytest notes ما زال يشغّل ملف التجارب. وفي المستودعات الكبيرة يسرّع أيضاً عملية الجمع، لأن pytest يتوقف عن التجوال في docs/ أو build/ أو مجلد البيئة الافتراضية.
addopts و markers — قواعد يحصل عليها الجميع
addopts نص يضعه pytest قبل كل ما تكتبه. وهنا يكون مكان قاعدتي المشروع اللتين رأيناهما في البداية:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"يطبع -ra سطر ملخص قصيراً لكل اختبار لم ينجح نجاحاً صريحاً — سواء تم تخطيه، أو كان xfailed، أو xpassed، أو فشل — حتى لا يمرّ أي تخطٍّ دون أن يُلاحَظ. ويجعل --strict-markers أي علامة غير مسجلة خطأً. في ملف جديد، tests/test_discounts.py، يوجد اختبار مُعلَّم بـ @pytest.mark.slow (وآخر يُتخطّى خارج Windows)، والعلامة ليست مسجلة بعد:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sهذا هو الغرض من هذا الخيار. بدونه، لا يجلب خطأ إملائي مثل @pytest.mark.slwo سوى تحذير PytestUnknownMarkWarning في الملخص، ويبقى التشغيل أخضر، ويستمر -m "not slow" في تشغيل الاختبار الذي كنت تقصد استبعاده. سجّل العلامة، سطراً لكل علامة، مع وصف سيعرضه pytest --markers:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'سطر SKIPPED هو نتيجة عمل -ra: أصبح كل تخطٍّ يشرح نفسه في كل تشغيل.
ما الذي لا ينتمي إلى addopts. الطريقة الخاطئة هي addopts = "-v -x --pdb". فهذه عاداتك أنت في تصحيح أخطاء اليوم؛ ووضعها في الملف المشترك يفرضها على الجميع، بما في ذلك CI — حيث ينتظر --pdb إلى الأبد لوحة مفاتيح لا يجلس أمامها أحد. ضع في addopts قواعد المشروع فقط. ما تكتبه أنت يُضاف بعده، لذا يظل pytest -x يعمل لك وحدك، ويتجاوز -o أي إعداد لتشغيل واحد:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sلا يظهر سطر التخطي هذه المرة، لأن -ra أُسقط في ذلك التشغيل.
minversion و xfail_strict
يوقف minversion التشغيل فوراً عندما يكون إصدار pytest لدى أحدهم أقدم مما يتطلبه الملف. وطلب إصدار غير موجود يُظهر الرسالة:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'خطأ واضح عند بدء التشغيل أفضل من إصدار أقدم من pytest يفهم الإعدادات نصف فهم.
يعالج xfail_strict المسألة المعلقة من الفصل السابق: اختبار xfail بدأ ينجح. هنا عُلِّم خطأ برمجي على أنه متوقع الفشل، ومنذ ذلك الحين أصلحه أحدهم:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sتشغيل أخضر، وحرف X لا يقرؤه أحد، وعلامة أصبحت الآن تكذب. مع xfail_strict = true في ملف الإعدادات:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sالخبر السار يصل الآن على هيئة فشل يخبرك بإزالة العلامة — ولهذا يستحق هذا الإعداد أن يُضبط للمشروع بأكمله لا لكل اختبار على حدة. يقبل pytest 9.1 أيضاً الاسم الأحدث strict_xfail، وإعداداً واحداً strict = true يفعّل كل خيارات الصرامة دفعة واحدة (بما فيها العلامات و xfail)؛ ويظل xfail_strict يعمل.
السجلات الحية: log_cli
افتراضياً، يلتقط pytest سجلات التسجيل (log records) ولا يعرضها إلا للاختبارات الفاشلة. ولكي تشاهد تشغيلاً ناجحاً يروي قصته، فعّل التسجيل الحي:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"عندئذٍ تُطبع سجلات INFO وما فوقها لكل اختبار تحت عنوان live log call أثناء تشغيله؛ وسيُظهر المثال الكامل أدناه ذلك. السجلات الحية كثيرة الضجيج، لذا تُبقي مشاريع كثيرة على log_cli_level وحده في الملف، وتفعّل الإخراج عند الحاجة باستخدام pytest -o log_cli=true.
الجدول الأصلي في pytest 9: [tool.pytest]
للاسم [tool.pytest.ini_options] غرابة لها سبب: إنه إعدادات INI تقيم داخل ملف TOML، لذا تتحول كل قيمة إلى نص. يضيف pytest 9 جدولاً أصلياً، [tool.pytest]، تحتفظ فيه القيم بأنواعها في TOML — قوائم حقيقية، وقيم منطقية حقيقية:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01sأصبح addopts الآن قائمة. وهناك قاعدتان: استخدم أحد الجدولين أو الآخر، ولا تجمعهما أبداً في ملف واحد (يرفض pytest البدء)، وتذكّر أن إصدارات pytest السابقة للإصدار 9 لا تقرأ [tool.pytest] إطلاقاً. يحتفظ هذا الفصل بـ [tool.pytest.ini_options] لأنه يعمل على كل إصدار من pytest يُرجَّح أن تصادفه؛ أما في مشروع لا يشغّل سوى pytest 9، فالجدول الأصلي هو الصيغة الموصى بها. ويقرأ pytest 9 أيضاً ملفاً مستقلاً باسم pytest.toml فيه جدول [pytest] بالأسلوب الأصلي نفسه.
ذاكرة التخزين المؤقت، باختصار
بعد التشغيل يظهر مجلد .pytest_cache/ في الجذر. يتذكر هذا المجلد الاختبارات التي فشلت في المرة السابقة، وهذا ما يقوم عليه --lf (آخر ما فشل) و --ff (الفاشل أولاً). هناك خياران يستحقان المعرفة. يفرّغ pytest --cache-clear الذاكرة المؤقتة قبل التشغيل — وهو مفيد عندما يستمر --lf في اختيار اختبارات لم تعد موجودة. ويعطّل pytest -p no:cacheprovider إضافة الذاكرة المؤقتة بالكامل، فلا يُكتب أي شيء، وهذا يناسب نسخة مستودع للقراءة فقط. الثمن هو أن خيارات الإضافة تختفي معها:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvالتحذيرات: القسم الموجود أسفل التشغيل
التحذير رسالة تقول إن شيئاً ما لم يصبح بعد خاطئاً. تطبعه بايثون وتمضي في عملها:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0يجعل stacklevel=2 تحذير الإهمال يشير إلى سطر المستدعي، أي السطر الذي يجب على أحدهم تغييره. يلتقط pytest التحذيرات التي تُطلق أثناء الاختبارات ويجمعها في قسم واحد في النهاية:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sنجح الاختبار، ويقول السطر الأخير 1 passed, 1 warning. في مجموعة اختبارات كبيرة، يكبر هذا القسم ليضم عشرات المدخلات، ويتعلم الجميع تجاوزه دون قراءة، وفي يوم ما تُزال الدالة المُهمَلة فتنكسر مجموعة الاختبارات "الناجحة". هناك أمران يمكنك فعلهما بالتحذير: أن تتوقعه، عندما يكون جزءاً من السلوك، أو أن ترفضه، عندما لا يكون كذلك.
pytest.warns — توقُّع تحذير
عندما يكون التحذير جزءاً من العقد — صف "بعد الحد الفاصل مباشرة" في الخطة — اختبره بالطريقة التي تختبر بها استثناءً باستخدام pytest.raises:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sتنجح الكتلة عندما يُطلَق داخلها تحذير UserWarning واحد على الأقل (أو صنف فرعي منه) و تطابق رسالته match، وهو تعبير نمطي (regular expression) يُبحث عنه داخل النص، كما في pytest.raises. وكلا النصفين يفشل بوضوح. هنا تكون الشيفرة هادئة عند 50، ويبحث الاختبار الثاني عن كلمات خاطئة:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sيسرد التقريران التحذيرات التي أُطلقت فعلاً، وهي عادةً كل ما تحتاجه لإصلاح الاختبار. والتحذير الذي لم يطابق لا يُبتلع أيضاً؛ بل ينتقل إلى ملخص التحذيرات.
لماذا تحدد match دائماً. كتابة pytest.warns(UserWarning) وحدها هي الصيغة الضعيفة: أي UserWarning صادر من أي مكان داخل الكتلة يُرضيها — بما في ذلك تحذير من مكتبة تصادف أنك تستدعيها، لسبب لا علاقة له بالأمر. وقد يستمر الاختبار في النجاح بعد أن يختفي التحذير الذي يهمك. أما العبارة المطابَقة فتثبّت الاختبار على هذا التحذير بعينه.
لكي تتحقق من عدد التحذيرات التي أُطلقت، أو مما قالته، احتفظ بالسجل باستخدام as:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01sيسرد record كل تحذير التُقط داخل الكتلة — وهما اثنان، لأن استدعاء الـ 10% هادئ. ولكل مُدخل .message و .category و .filename و .lineno.
pytest.deprecated_call — وعد لمستخدميك
إهمال دالة وعدٌ: "هذه ما زالت تعمل، وقد أُبلغت". يتحقق pytest.deprecated_call() من نصفي هذا الوعد:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sإنه pytest.warns مع الأصناف معبأة مسبقاً — DeprecationWarning و PendingDeprecationWarning و FutureWarning — ويقبل match أيضاً. واختفى المُدخل من ملخص التحذيرات: كان التحذير متوقعاً، فاستُهلك.
filterwarnings — رفض التحذيرات
أصبحت التحذيرات المتوقعة الآن اختبارات. أما البقية فهي ضجيج أو إنذارات مبكرة، والعادة التي تستحق أن تتبناها في CI هي: كل تحذير غير متوقع خطأ. سطر واحد:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]إليك مجموعة اختبارات فيها اختبار نظيف، وآخر ما زال يستدعي الاسم المُهمَل، وثالث يستدعي currency.rate() من مكتبة لا تستطيع تعديلها، تطلق تحذير DeprecationWarning خاصاً بها:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sأصبح كل تحذير يُطلق الآن كاستثناء، مع تتبع (traceback) يصل إلى السطر الذي تسبب فيه. وكل فشل يستدعي قراراً بدلاً من صمت شامل.
الطريقة الخاطئة هي "ignore::DeprecationWarning": سطر واحد، وكل شيء أخضر، وتحذير الإهمال الخاص بك مخفي مع تحذير المكتبة. الطريقة الصحيحة هي تضييق كل استثناء. للمرشِّح (filter) الشكل action:message:category:module — وهو الشكل نفسه لخيار -W في بايثون — حيث يطابق message بداية نص التحذير، و module هي الوحدة التي يُنسب إليها التحذير:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]الترتيب مهم: الأسطر اللاحقة لها الأولوية، لذا يأتي error أولاً ثم الاستثناءات بعده. أصبح تحذير المكتبة الآن مُتجاهَلاً فقط عندما يصدر من currency. ولاختبار واحد، يمكن إرفاق المرشِّح نفسه كعلامة — هنا على test_old_name، وبقية الملف دون تغيير:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sهذه العلامة تسمح بالتحذير فقط. إذا كنت تقصد "يجب أن يُطلق هذا تحذيراً"، فإن pytest.deprecated_call() هو الخيار الأقوى، لأنه يفشل أيضاً عندما يختفي التحذير. استخدم العلامة للتحذيرات التي تتسامح معها، و warns للتحذيرات التي تَعِد بها.
يمكن تمرير المرشِّحات نفسها لتشغيل واحد عبر -W، وهذا مفيد لتجربة سياسة قبل تدوينها. على ملف test_old_api.py السابق، دون أي ملف إعدادات:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sعندما تتعارض المصادر، يفوز الأكثر تحديداً: العلامة على الاختبار تتغلب على -W، و -W يتغلب على ملف الإعدادات. في مجموعة الاختبارات الثلاثة، يتجاوز -W قاعدة ignore الخاصة بـ currency في ملف الإعدادات، بينما يحتفظ الاختبار المُعلَّم باستثنائه:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sوهناك أمر آخر يمنحك إياه error. عُد إلى صف الحد الفاصل في الخطة، "دون تحذير عند 50". مع وجود error في ملف الإعدادات، فإن اختباراً بسيطاً لـ apply_discount(200.0, 50) يتحقق من ذلك بالفعل: لو أطلق ذلك الاستدعاء تحذيراً في أي وقت، لأصبح التحذير استثناءً ولفشل الاختبار. الحالات الهادئة لا تحتاج إلى شيفرة خاصة.
مثال تطبيقي متكامل
مشروع المتجر مع كل القواعد في مكان واحد. pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — الخطة التي وضعناها في بداية الفصل، صفاً صفاً:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================هناك ثلاثة أمور جديرة بالملاحظة.
أولاً، لا يوجد في أيٍّ من الأمرين ما يُعد قاعدة من قواعد المشروع. فالقواعد — مكان الاختبارات، وما يمكن استيراده، والعلامات الصارمة، و xfail الصارم، والتحذيرات كأخطاء — كلها في الملف، لذا يحصل الزميل الذي يكتب pytest مجرداً على التشغيل الأول تماماً، وكذلك CI.
ثانياً، لا يوجد ملخص للتحذيرات. كلا التحذيرين اللذين تطلقهما الشيفرة متوقعان، فاستهلكهما warns و deprecated_call؛ وأي تحذير آخر كان سيُفشل اختباراً. أصبح التشغيل النظيف الآن نظيفاً فعلاً.
ثالثاً، لا يحتوي test_fifty_percent_is_quiet على أي شيفرة خاصة بالتحذيرات، ومع ذلك فهو يحرس الحد الفاصل: في ظل error، كان أي تحذير عند 50% سيُفشله.
الأخطاء الشائعة وحلولها
ModuleNotFoundError: No module named 'shop' أثناء الجمع لا يمكن استيراد الشيفرة من الاختبارات. أضف pythonpath = ["src"] إلى ملف الإعدادات، وافحص سطر configfile: في الترويسة لتتأكد من أن ذلك الملف هو الذي يُقرأ.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) يوجد ملفا إعدادات، والملف الذي عدّلته هو الخاسر. لا يُدمج أي شيء. احذف أحدهما.
'slow' not found in markers configuration option الخيار --strict-markers (أو strict = true) مفعّل، والعلامة غير مسجلة أو مكتوبة بخطأ إملائي. سجّلها تحت markers، أو صحّح كتابتها.
PytestConfigWarning: Unknown config option: testpath إعداد مكتوب بخطأ إملائي — testpath بدلاً من testpaths. يكتفي pytest بالتحذير، فلا يفعل الإعداد شيئاً في صمت. ومع strict_config = true (أو strict = true) يصبح ERROR: Unknown config option: testpath ويتوقف التشغيل، وهذا ما تريده.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. يحتوي pyproject.toml على الجدولين معاً. انقل كل شيء إلى جدول واحد.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' في الجدول الأصلي [tool.pytest] يكون addopts قائمة: addopts = ["-ra", "--strict-markers"]. أما الصيغة النصية فتنتمي إلى [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' إصدار pytest المثبت أقدم مما يحتاجه المشروع. حدّثه داخل البيئة الافتراضية.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. لم يُطلق أي تحذير من ذلك الصنف داخل الكتلة. اقرأ Emitted warnings: أسفل الرسالة — فكثيراً ما تطلق الشيفرة تحذيراً من صنف مختلف، أو لا تطلقه مع هذا المُدخَل.
Failed: Regex pattern did not match any of the 1 warnings emitted. الصنف صحيح، والنص خاطئ. match تعبير نمطي، لذا تحتاج ( و . و ? إلى تهريب (escaping) — أو غلّف النص بـ re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? خطأ إملائي في صنف التحذير داخل أحد أسطر filterwarnings. يرفض pytest البدء، ويطبع فوق الرسالة مباشرة السطر الذي لم يستطع تحليله.
Step 4 of 6 — Predict
Check your understanding
شُغّل هذا بالأمر pytest -q -s. ما الرقم الذي يطبعه الاختبار؟
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B1
- C2
- D0
في جذر المشروع ملف pytest.ini وملف pyproject.toml فيه [tool.pytest.ini_options]. ماذا يحدث عند تشغيل pytest؟
- Aيُدمج الملفان، ويملأ `pyproject.toml` ما ينقص
- Bيُستخدم `pytest.ini` وتُتجاهل إعدادات `pyproject.toml`، ويحذّر السطر العلوي من ذلك
- Cيفوز `pyproject.toml` لأنه المعيار الأحدث
- Dيتوقف pytest بخطأ لوجود ملفَي إعدادات
في الإعدادات addopts = -ra --strict-markers ولا يُسجَّل تحت markers إلا slow. ماذا يحدث عند pytest -m "not slow"؟
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- Aيُستبعد الاختبار كما هو مقصود
- Bيعمل الاختبار مع تحذير في الملخص
- Cيُتخطّى الاختبار
- Dيتوقف الجمع: 'slwo' not found in `markers` configuration option
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.
دورك الآن
يفرض مشروع المكتبة غرامة تأخير. src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- اكتب جدول
[tool.pytest.ini_options]بحيث إنpytestالمجرد، سواء من الجذر أو من داخلtests/، يستوردlibrary، ويجمعtests/وحده، ويطبع ملخصات-ra، ويرفض العلامات غير المعروفة، ويفشل عند نجاح غير متوقع لاختبارxfail، ويحوّل كل تحذير غير متوقع إلى خطأ. سجّل علامةslow. - اكتب خطة الاختبار أولاً — الحالة، والمُدخَل، والمتوقَّع — بحيث تغطي الإرجاع في الموعد، والحد الفاصل عند 60 يوماً، و61 يوماً، ورقماً سالباً، والدالة المُهمَلة
fine(). - اكتب
tests/test_fees.pyانطلاقاً من الخطة، مع اختبارslowواحد يمرّ على كل يوم من أيام السنة. - شغّل
pytest، ثمpytest -m "not slow"، ثمcd tests && pytest.
ثم أجرِ تجربتين، وتوقّع نتيجة كل منهما أولاً: استبدل اختبار deprecated_call باستدعاء بسيط لـ fine(3)، واكتب slow خطأً على هيئة slwo.
الحل
الخطة:
| الحالة | المُدخَل | المتوقَّع | | --- | --- | --- | | أُعيد في الموعد | late_fee(0) | 0، دون تحذير | | الحد الفاصل، ما زال هادئاً | late_fee(60) | 300، دون تحذير | | بعد الحد الفاصل مباشرة | late_fee(61) | 305 وتحذير UserWarning واحد يذكر "lost book" | | مُدخَل غير صالح | late_fee(-1) | ValueError يذكر "negative" | | اسم مُهمَل | fine(3) | 15 وتحذير DeprecationWarning يذكر late_fee | | سنة كاملة (بطيء) | late_fee(0) … late_fee(365) | آخر غرامة 1825، والتحذيرات متوقعة |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sالتجربتان:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sسبب كل قرار:
pythonpathوtestpathsفي الملف، لا في سطر الأوامر. لهذا يعطيcd tests && pytestالنجاحات الستة نفسها: كلاهما نسبي إلى rootdir، الذي يجده pytest بالصعود إلى أن يصل إلىpyproject.toml.- القواعد وحدها في
addopts. الخياران-raو--strict-markersسياسات يتشاركها الجميع؛ أما-vأو-xفعادات شخصية تبقى في سطر أوامرك أنت. filterwarnings = ["error"]دون أي استثناءات. لا توجد في المشروع بعد تحذيرات من مكتبات خارجية، فلا يوجد ما يستدعي الاستثناء. وفي اليوم الذي يظهر فيه أحدها، أضف سطرignore::...:moduleضيقاً، ولا تضف أبداًignoreشاملاً.- لا يحتوي اختبار الستين يوماً على شيفرة خاصة بالتحذيرات. في ظل
error، كان أي تحذير عند 60 يوماً سيُفشله، فملف الإعدادات هو الذي يحرس الحد الفاصل. matchفي كل فحص يستهدف رسالة بعينها. "lost book" و "use late_fee" هما الكلمات التي يتصرف القارئ بناءً عليها؛ ويمكن إعادة صياغة البقية بحرية. أما اختبار السنة الكاملة فيُسقطmatchعن قصد: فهو يتعلق بالغرامات، ولا يحتاج إلا إلى القول إن التحذيرات متوقعة هناك.recordفي اختبار الواحد والستين يوماً. يثبت أن الاستدعاء الواحد يُطلق تحذيراً مرة واحدة، لا في كل خطوة داخلية.- التجربتان. بدون
deprecated_callيصبح تحذير الإهمال غير متوقع، فيحوّلهerrorإلى فشل — وهذا بالضبط ما يجب أن يفعله CI عندما يستدعي أحدهم واجهة برمجية قديمة. وتوقف العلامةُ المكتوبة بخطأ إملائي عمليةَ الجمع لأن--strict-markersموجود فيaddopts. (وهنا كان مرشِّحerrorسيلتقطها أيضاً، لأن العلامة غير المعروفة تطلقPytestUnknownMarkWarning— لكن قاعدة العلامات يجب ألا تعتمد على سياسة التحذيرات، لذا يبقى الاثنان.)
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz