pytest
pytest Fundamentals
pytest — أساسيات pytest
إذا كان unittest يُشبه بناء رسمياً منظماً — كلاسات، وراثة، assertions متخصصة — فإن pytest يُشبه الكتابة بالطريقة الأبسط: دوال عادية، assert بسيط، ونتائج تشخيصية أوضح عند الفشل.
pytest ليس في المكتبة المعيارية (يحتاج pip install pytest)، لكنه أصبح المعيار الفعلي في كثير من المشاريع الاحترافية لأنه أقل كوداً بنفس الإمكانيات أو أكثر. في هذا الدرس نفهم المبادئ عبر أمثلة الكود، ثم نُطبّق ما يوازيها في unittest داخل الـ playground، لأن unittest متوفر في المتصفح دون تثبيت.
الفرق الجوهري في الكتابة
مع unittest:
import unittest
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(2, 3), 5)
مع pytest:
# لا وراثة، لا كلاس — just a function
def test_positive():
assert add(2, 3) == 5
Python تُشغّل pytest وتكتشف تلقائياً كل دالة تبدأ بـtest_. الأمر assert العادي يكفي — pytest يُحسّن رسالة الفشل تلقائياً لتُظهر القيمتين الفعلية والمتوقعة.
أسلوب pytest بمثال كامل
# file: test_calculator.py
def add(a, b):
return a + b
def multiply(a, b):
return a * b
def test_add_integers():
assert add(1, 2) == 3
def test_add_negative():
assert add(-1, 1) == 0
def test_multiply_by_zero():
assert multiply(5, 0) == 0
def test_multiply_normal():
result = multiply(3, 4)
assert result == 12, f"توقعنا 12، حصلنا على {result}"
التشغيل:
pytest test_calculator.py -v
الناتج عند فشل اختبار:
FAILED test_calculator.py::test_add_integers
AssertionError: assert 2 == 3
where 2 = add(1, 1)
pytest يوضح القيم بدون أن تكتب رسالة يدوياً.
parametrize — اختبار حالات متعددة بسطر واحد
بدل كتابة دالة اختبار لكل حالة، @pytest.mark.parametrize تتيح تمرير جدول حالات لدالة واحدة:
import pytest
def is_palindrome(s):
return s == s[::-1]
@pytest.mark.parametrize("input_str, expected", [
("level", True),
("hello", False),
("racecar", True),
("", True),
("a", True),
])
def test_is_palindrome(input_str, expected):
assert is_palindrome(input_str) == expected
pytest يُشغّل الدالة مرة لكل صف ويُعطيها اسماً تلقائياً مثل test_is_palindrome[level-True]. إذا فشلت حالة بعينها، تعرف أيها فشلت على الفور.
مقابلها في unittest (ما نُشغّله فعلياً في الـ playground):
cases = [("level", True), ("hello", False), ("racecar", True)]
for s, expected in cases:
self.assertEqual(is_palindrome(s), expected)
Fixtures — الموارد المُعادة
fixture في pytest هي دالة مزيّنة بـ@pytest.fixture تُعيد قيمة (كائن، اتصال، بيانات) يستخدمها الاختبار كمعامل تلقائي:
import pytest
@pytest.fixture
def sample_users():
# يُشغَّل قبل كل اختبار يطلبه — Runs before each test that requests it
return [
{"name": "أحمد", "age": 30},
{"name": "فاطمة", "age": 25},
]
def test_user_count(sample_users):
# pytest يمرّر sample_users تلقائياً
assert len(sample_users) == 2
def test_first_user(sample_users):
assert sample_users[0]["name"] == "أحمد"
pytest يكتشف أن test_user_count تطلب sample_users (بالاسم) ويُشغّل الـ fixture ثم يمرّر نتيجتها تلقائياً. الـ fixture الواحد يُشارك بين عدة اختبارات دون تكرار الكود.
Fixture مع cleanup بـ yield
إذا أنشأت مورداً يحتاج تنظيفاً (ملف، اتصال)، استخدم yield بدل return:
@pytest.fixture
def temp_file(tmp_path):
# tmp_path هي fixture مدمجة في pytest — built-in fixture
f = tmp_path / "test.txt"
f.write_text("بيانات اختبار")
yield f # يُمرَّر للاختبار
# بعد yield: كود التنظيف — cleanup code
f.unlink(missing_ok=True)
def test_file_content(temp_file):
assert "بيانات" in temp_file.read_text()
كل شيء قبل yield هو setup، وكل شيء بعده هو teardown — يُشغَّل حتى لو فشل الاختبار.
نطاق الـ Fixture — scope
بشكل افتراضي الـ fixture يُشغَّل قبل كل اختبار (scope="function"). يمكن تغيير النطاق:
@pytest.fixture(scope="module")
def db_connection():
# ينشئ الاتصال مرة واحدة لكل الاختبارات في الملف
conn = create_connection()
yield conn
conn.close()
| scope | متى يُشغَّل |
|---|---|
function (افتراضي) | قبل كل اختبار |
class | مرة لكل كلاس |
module | مرة لكل ملف |
session | مرة لكل جلسة تشغيل |
مقارنة سريعة: pytest vs unittest
| الجانب | unittest | pytest |
|---|---|---|
| الكلاس مطلوب؟ | نعم (عادةً) | لا — دوال عادية |
| التحقق | self.assertEqual(...) | assert a == b |
| عدة حالات | حلقة يدوية | @pytest.mark.parametrize |
| الموارد المشتركة | setUp/tearDown | @pytest.fixture |
| رسائل الفشل | أساسية | تفصيلية تلقائياً |
| المكتبة المعيارية | نعم | لا (pip install) |
| متوافق مع pytest | نعم | — |
نقطة مهمة: pytest يشغّل اختبارات unittest أيضاً. يمكنك البدء بـunittest والانتقال تدريجياً لأسلوب pytest.
الآن بـ unittest — التطبيق التفاعلي
نُطبّق نفس المبادئ باستخدام unittest الذي يعمل في المتصفح مباشرة:
subTest — التكرار الجدولي في unittest
لاحظت self.subTest(s=s) في المثال أعلاه. هذا مكافئ parametrize في unittest: يُشغّل حالات متعددة داخل اختبار واحد ويُبلّغ عن كل حالة فاشلة بدقة:
def test_scenarios(self):
cases = [
(0, 0, 0), # a, b, expected
(1, 2, 3),
(-1, 1, 0),
]
for a, b, expected in cases:
with self.subTest(a=a, b=b):
self.assertEqual(add(a, b), expected)
إذا فشلت الحالة (-1, 1, 0) فقط، يُظهر FAIL: test_scenarios (a=-1, b=1) — يستمر ولا يتوقف عند أول فشل.
نصيحة عملية
للمشاريع الصغيرة، ابدأ بـunittest — متاح دائماً بلا تثبيت. عندما تكبر قاعدة الاختبارات وتحتاج parametrize والـ fixtures المتقدمة وتقارير أوضح، انتقل لـpytest. كلاهما يؤدي المهمة؛ الفرق في سرعة الكتابة ووضوح رسائل الفشل.