AzLearn

pytest

pytest Fundamentals

مفهوم ~20 دقيقة

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

الجانبunittestpytest
الكلاس مطلوب؟نعم (عادةً)لا — دوال عادية
التحققself.assertEqual(...)assert a == b
عدة حالاتحلقة يدوية@pytest.mark.parametrize
الموارد المشتركةsetUp/tearDown@pytest.fixture
رسائل الفشلأساسيةتفصيلية تلقائياً
المكتبة المعياريةنعملا (pip install)
متوافق مع pytestنعم

نقطة مهمة: pytest يشغّل اختبارات unittest أيضاً. يمكنك البدء بـunittest والانتقال تدريجياً لأسلوب pytest.

الآن بـ unittest — التطبيق التفاعلي

نُطبّق نفس المبادئ باستخدام unittest الذي يعمل في المتصفح مباشرة:

main.go

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. كلاهما يؤدي المهمة؛ الفرق في سرعة الكتابة ووضوح رسائل الفشل.

تحدي — Challenge