شرح pytest: من أول اختبار إلى fixture وparametrize

بقلم فريق تقني · (آخر تحديث: )· أدوات المطورين
شرح pytest: من أول اختبار إلى fixture وparametrize

اختبار واحد من 3 أسطر يكفي لتبدأ مع pytest: دالة تبدأ بـ test وجملة assert عادية، ثم أمر pytest في الطرفية. لا أصناف تُورَّث ولا أسماء دوال تحفظها. وفق استبيان مطوري بايثون الرسمي من PSF وJetBrains لعام 2024، يستخدم 53% من المطورين pytest مقابل 23% لـ unittest، بينما 36% لا يستخدمون أي إطار اختبار أصلاً. هذا الدليل يبدأ من الاختبار الأول ثم يمضي إلى ما تقفز فوقه معظم الشروحات: نطاقات fixture الخمسة، وترتيب التنظيف، وفخاخ parametrize والعلامات، وحدود أداة التغطية نفسها.

ثبّت pytest ودَعْه يكتشف اختباراتك

الإصدار التاسع من pytest يتطلب بايثون 3.10 فأحدث، والتثبيت بالأمر pip install -U pytest. وإن كنت تدير مشروعك بأداة uv كما شرحنا في دليل الانتقال من pip وvenv إلى uv، فالأمر uv add --dev pytest يضيفه إلى التبعيات التطويرية وuv run pytest يشغّله.

يكتشف pytest اختباراتك وحده: ملفات باسم test_*.py أو *_test.py، دوال تبدأ بـ test، وأصناف تبدأ بـ Test. ضعها في مجلد tests/ منفصل. أول اختبار من الوثائق الرسمية:

def func(x):
    return x + 1

def test_answer():
    assert func(3) == 5

عند الفشل لا تصلك رسالة مبهمة، بل القيم الوسيطة نفسها: assert 4 == 5 مع بيان أن 4 هي ناتج func(3). وهنا الفرق الجوهري عن unittest: جملة assert واحدة تغنيك عن حفظ assertEqual وأخواتها.

fixture: النطاقات الخمسة وفخ الحالة المشتركة

الـfixture دالة مزخرفة بـ @pytest.fixture تجهّز ما يحتاجه الاختبار، والاختبار يطلبها بكتابة اسمها كمعامل:

import pytest

@pytest.fixture
def numbers():
    return [1, 2, 3]

def test_sum(numbers):
    assert sum(numbers) == 6

افتراضياً يُعاد بناء الـfixture لكل دالة اختبار، لكن معامل scope يقبل 5 قيم: function وclass وmodule وpackage وsession. النطاقات الأعلى توفر وقتاً حقيقياً حين يكون التجهيز مكلفاً؛ مثال الوثائق الرسمية اتصال SMTP بنطاق module يُنشأ مرة واحدة لكل ملف بدل إعادة فتحه مع كل اختبار.

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

yield بدل return: تنظيف مضمون حتى عند الفشل

ضع yield مكان return فيصبح ما قبله تجهيزاً وما بعده تنظيفاً:

@pytest.fixture(scope="module")
def smtp_connection():
    conn = smtplib.SMTP("smtp.example.com")
    yield conn
    conn.close()

يعمل التنظيف حتى لو فشل الاختبار، ولكل fixture اكتمل تجهيزه؛ الاستثناء الوحيد أن يرمي الـfixture نفسه استثناءً قبل yield. وتُنفَّذ عمليات التنظيف بترتيب معكوس — آخر ما جُهّز أول ما يُنظَّف — تماماً كسلوك try/finally لكن دون كتابته يدوياً.

conftest.py وتجهيزات pytest المدمجة

أي fixture تعرّفه في ملف conftest.py يصير متاحاً لكل اختبارات الحزمة دون أي استيراد؛ pytest يكتشفه تلقائياً. ولكل مجلد أن يملك conftest.py خاصاً يضيف إلى ملفات الآباء، ويجوز لملف ابن أن يعيد تعريف fixture ورثه ليخصصه لاختباراته.

وقبل أن تكتب تجهيزاتك الخاصة، تفقّد المدمج منها:

  • tmp_path يمنح كل دالة اختبار مجلداً مؤقتاً فريداً من نوع pathlib.Path، وتبقى مجلدات آخر 3 تشغيلات محفوظة افتراضياً لتفحصها بعد فشل ما:
def test_read_arabic(tmp_path):
    f = tmp_path / "رسالة.txt"
    f.write_text("مرحباً بالعالم", encoding="utf-8")
    assert read_message(f) == "مرحباً بالعالم"
  • monkeypatch يضبط متغيرات البيئة بـ setenv ويستبدل الدوال بـ setattr، وكل تعديلاته تُلغى تلقائياً بعد الاختبار. مثلاً monkeypatch.setattr(Path, "home", lambda: Path("/abc")) يجعل أي كود يسأل عن مجلد المستخدم يتلقى مساراً ثابتاً.
  • capsys يلتقط ما طبعه الكود على المخرجات؛ استدعِ readouterr() وافحص .out و.err.

parametrize بعمق: الضرب الديكارتي وحالات xfail

يشغّل parametrize الاختبار نفسه على عدة مدخلات، والتقرير يحدد بدقة أي توليفة فشلت:

@pytest.mark.parametrize("test_input,expected", [
    ("3+5", 8),
    ("2+4", 6),
    pytest.param("6*9", 42, marks=pytest.mark.xfail),
])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

تعمل الدالة هنا 3 مرات، والحالة الثالثة موسومة فشلاً متوقعاً فلا تكسر الحزمة. وقدرتان تُغفَلان غالباً: تكديس مزخرفَي parametrize فوق الدالة نفسها ينتج الضرب الديكارتي — قائمتان بقيمتين تعنيان 4 اختبارات — ومعامل ids= يمنح كل حالة اسماً مقروءاً في التقرير بدل قيم متلاصقة مبهمة.

وانتبه لمأزق خفي: قيم المعاملات تُحسب في مرحلة جمع الاختبارات قبل تشغيل أي اختبار، فأي حساب ثقيل داخلها يبطئ حتى أمر --collect-only.

skipif وxfail وتسجيل العلامات المخصصة

علامة skipif تتخطى اختباراً بشرط، كمثال الوثائق sys.platform == "win32" لاختبار لا يعمل على ويندوز، أو شرط على إصدار بايثون. أما xfail فتوثّق علة معروفة أو ميزة لم تكتمل: الاختبار يبقى يعمل، ونجاحه غير المتوقع يظهر XPASS، ومع strict=True يتحول هذا النجاح المفاجئ إلى فشل للحزمة كي لا تنسى إزالة العلامة بعد إصلاح العلة.

واحذر خطأً يمرّ بصمت: علامة مخصصة بخطأ إملائي مثل @pytest.mark.slwo لا تنتج افتراضياً سوى تحذير، ويمضي التشغيل وكأن شيئاً لم يكن. سجّل علاماتك تحت مفتاح markers في الإعدادات وأضف --strict-markers إلى addopts ليصبح كل خطأ إملائي خطأً يوقف التشغيل.

أعلام pytest التي تختصر دورتك اليومية

العلمماذا يفعل
-xيوقف التشغيل بعد أول فشل
-k "تعبير"يشغّل الاختبارات المطابقة بالاسم
--lfيعيد الفاشلة فقط من آخر جلسة
-vتفصيل أكثر في الإخراج
-qإخراج مختصر

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

إعدادات pytest الدائمة في pyproject.toml

بعد أول أسبوع ستجد نفسك تكرر الأعلام نفسها في كل تشغيل. انقلها مرة واحدة إلى pyproject.toml بالمقطع الذي تقترحه الوثائق الرسمية:

[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q"
testpaths = [
    "tests",
    "integration",
]

testpaths يحصر الاكتشاف في مجلدات محددة فيسرّعه، وaddopts أعلام تُطبَّق في كل تشغيل — وهذا الموضع الأنسب لإضافة --strict-markers. الفائدة الأكبر ليست اختصار الكتابة بل التوحيد: كل من يستنسخ المستودع — زميل جديد أو خادم تكامل مستمر عبر GitHub Actions أو وكيل برمجة — يشغّل الاختبارات بالإعداد نفسه، فتختفي فروق النتائج التي مصدرها أعلام تشغيل مختلفة لا كودٌ مختلف. وهذا امتداد طبيعي لفلسفة جمع إعدادات المشروع كلها في pyproject.toml، وهي الفلسفة نفسها التي اعتمدناها للفحص والتنسيق في دليل الانتقال إلى Ruff.

ماذا تقيس تغطية pytest-cov فعلاً؟

الأمر pytest --cov=myproj tests/ عبر إضافة pytest-cov يعرض أي أسطر الكود نُفّذت أثناء الاختبارات. لكن التنفيذ ليس تحققاً: Ned Batchelder، مؤلف أداة coverage.py نفسها، يضرب مثالاً باختبار دالة ترتيب يحقق تغطية 100% دون أن يفحص أبداً أن القائمة صارت مرتبة فعلاً. اعتبر التغطية مقياساً جيداً لما لم يُختبر بعد، لا دليلاً على جودة ما اختُبر.

5 أخطاء تُوقِع المبتدئين مع pytest

  1. وراثة unittest.TestCase ثم محاولة استخدام fixture أو parametrize — لا يعملان داخل TestCase. اكتب دوال مستقلة خارج أي صنف.
  2. كتابة from conftest import لجلب fixture — نمط خاطئ يكسر التسلسل الهرمي للملفات؛ اطلب الـfixture باسمه كمعامل ودع pytest يجده.
  3. ملفا اختبار بالاسم نفسه في مجلدين بلا __init__.py يسبّبان خطأ في الاكتشاف. وحّد الأسماء أو أضف الملف.
  4. خطأ ModuleNotFoundError عند استيراد حزمتك. الحل: تخطيط src مع تثبيت قابل للتحرير، أو التشغيل بـ python -m pytest لأنه يضيف المجلد الحالي إلى المسار.
  5. دالة اختبار واحدة تفحص عدة سلوكيات، فيخفي الفشلُ الأول ما بعده. الوثائق توصي بسلوك واحد لكل اختبار، ببنية ثابتة: تجهيز، فعل، توكيد، تنظيف.

حدود pytest الصريحة: متى يكفيك unittest؟

للإنصاف، حقن fixture بالاسم له ثمنه: فهو أقرب إلى السحر الخفي، وهو نقد وارد في متتبع مشكلات pytest نفسه؛ مستخدم حذف fixture مخصصاً لديه باسم tmpdir فحلّ المدمج مكانه بصمت دون أي خطأ. كذلك إعادة كتابة assert التي تمنحك الرسائل التفصيلية تسري على وحدات الاختبار وconftest فقط؛ دوال التوكيد المساعدة في وحدات أخرى تفقد هذه التفاصيل ما لم تسجلها بـ register_assert_rewrite.

ومع ذلك، ابدأ بـ pytest بلا تردد: فهو يشغّل اختبارات unittest القديمة أصلاً فلن تخسر أي كود موروث، وجملة assert أبسط من حفظ دوال المقارنة. يبقى unittest كافياً في حالة واحدة وجيهة: مشروع يلتزم المكتبة القياسية وحدها بلا أي تبعيات خارجية.

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