الفصل 28

كتابة الاختبارات باستخدام pytest

من عبارة `assert` إلى إطار pytest، قراءة تقارير الفشل، واختبار الأخطاء عبر `pytest.raises`، و `parametrize`، واستخدام `tmp_path` والتجهيزات (fixtures) — وبنية الكود القابل للاختبار.

37 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

حتى هذه اللحظة، كنا "نختبر" كودنا بطريقة واحدة فقط: نقوم بتشغيله، ثم نقرأ المخرجات المطبوعة على الشاشة، ونقرر ما إذا كانت تبدو صحيحة أم لا.

هذه الطريقة تنجح مرة واحدة. لكن المتاعب تبدأ في المرة الثانية.

في برنامج الفصل الخامس والعشرين، كانت نسبة الضريبة TAX_RATE محددة بـ 0.15. لنفترض أنها أصبحت الآن 0.18. سيعمل البرنامج، وتُطبع كل الأرقام دون أي خطأ — ولن تكون لديك أي طريقة لمعرفة ما إذا كان أي شيء قد تغير أو فسد في الحسابات، إلا إذا كنت تتذكر بدقة بالغة المخرجات السابقة القديمة.

وما يحتاج إلى الذاكرة البشرية سينتهي به المطاف حتماً إلى النسيان.

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 1) == 17.25
print("ok")
text
ok

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

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 3) == 51.0
print("ok")
text
AssertionError

هذا هو الأساس المتين. ويدور هذا الفصل حول أداة بنيت على هذا الأساس — وهي إطار pytest — القادرة على تشغيل مئات من هذه الادعاءات في ثوانٍ معدودة، وعندما يفشل أحدها، توضح لك بدقة لماذا فشل.

في نهاية هذا الدرس ستكون قادراً على

  • كتابة ملف اختبارات وتشغيله باستخدام pytest
  • قراءة تقرير الفشل التفصيلي وتحديد موضع الخطأ منه مباشرة
  • اختبار الاستثناءات والأخطاء المتوقعة عبر pytest.raises
  • تشغيل اختبار واحد على حالات متعددة باستخدام @pytest.mark.parametrize
  • اختبار الأكواد التي تتعامل مع الملفات بأمان باستخدام tmp_path
  • تمييز الأكواد التي يسهل اختبارها ومعرفة سبب ذلك

المتطلبات السابقة: فئات البيانات وتلميحات الأنواع.


أول اختبار تكتبه

قاعدتان فقط، وهذا كل ما في الأمر: يبدأ اسم الملف بـ test_، وكذلك يبدأ اسم دالة الاختبار بـ test_.

ملف pricing.py:

python
TAX_RATE = 0.15


def line_total(price: float, quantity: int) -> float:
    if quantity < 1:
        raise ValueError(f"quantity must be at least 1: {quantity}")
    return round(price * quantity * (1 + TAX_RATE), 2)

ملف test_pricing.py:

python
from pricing import line_total


def test_one_item():
    assert line_total(15.0, 1) == 17.25


def test_three_items():
    assert line_total(15.0, 3) == 51.75

ثم نفّذ الأمر pytest -q:

text
..                                                                       [100%]
2 passed in 0.01s

نقطتان تدلان على نجاح اختبارين. يكتشف pytest الملفات والدوال تلقائياً ويشغلها — دون الحاجة لتسجيل أي شيء يدوياً.

تقرير الفشل هو جوهر القوة

دعنا نضع رقماً خاطئاً عن عمد في أحد الاختبارات:

text
F                                                                        [100%]
================================== FAILURES ===================================
______________________________ test_three_items _______________________________

    def test_three_items():
>       assert line_total(15.0, 3) == 51.0
E       assert 51.75 == 51.0
E        +  where 51.75 = line_total(15.0, 3)

test_pricing.py:5: AssertionError
=========================== short test summary info ===========================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed in 0.01s

أمر assert العادي لم يقدم لنا سوى كلمة AssertionError الجافة مجردة من أي تفاصيل. أما هنا فقد حصلنا على:

  • اسم الاختبار المعيب (test_three_items) ورقم السطر بدقة (test_pricing.py:5)
  • الادعاء المحدد الذي انكسر، مميزاً بعلامة >
  • assert 51.75 == 51.0 — المقارنة الصريحة بين ما نتج فعلياً وما كان متوقعاً
  • where 51.75 = line_total(15.0, 3) — توضيح مصدر ذلك الرقم المحسوب

يُطلق على هذا السلوك استبطان التوكيد (Assertion introspection)، وهو السبب الرئيسي وراء شعبية pytest. فالعديد من اللغات الأخرى تتطلب دوال خاصة مثل assertEqual(a, b) للحصول على هذا؛ بينما تكفيك هنا علامة == العادية البسيطة.

الأخطاء سلوك برمجي يستحق الاختبار أيضاً

أوضحنا في الفصل الرابع والعشرين أن الدالة يجب أن تطلق استثناءً (raise) عند تلقي مدخلات معيبة. وهذا السلوك هو بمثابة التزام برمجي رسمي، وبالتالي يمكن ويجب اختباره:

python
import pytest

from pricing import line_total


def test_zero_is_rejected():
    with pytest.raises(ValueError):
        line_total(15.0, 0)


def test_message_names_the_value():
    with pytest.raises(ValueError, match="at least 1: -3"):
        line_total(15.0, -3)
text
..                                                                       [100%]
2 passed in 0.01s

كتلة pytest.raises تعلن: "من المفترض أن يحدث هذا الخطأ داخل هذه الكتلة". وإذا لم يحدث الخطأ، يفشل الاختبار:

text
F                                                                        [100%]
================================== FAILURES ===================================
____________________________ test_one_is_rejected _____________________________

    def test_one_is_rejected():
>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE <class 'ValueError'>

test_pricing.py:7: Failed
=========================== short test summary info ===========================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE <class '...
1 failed in 0.01s

المعامل match= يتحقق من أن نص رسالة الخطأ يحتوي على النص المحدد. ويجدر بك استخدامه دائماً، لأن قاعدة الفصل الرابع والعشرين — تضمين القيمة المسببة للخطأ في الرسالة — تصبح حينها وعداً موثقاً ومختبراً بحد ذاته.

اختبار واحد لحالات متعددة (Parametrize)

كتابة أربع دوال اختبار منفصلة لأربع حالات هو عمل رتيب وممل ويدعو للوقوع في أخطاء النسخ واللصق.

python
import pytest

from pricing import line_total


@pytest.mark.parametrize(
    "price, quantity, expected",
    [
        (15.0, 1, 17.25),
        (15.0, 3, 51.75),
        (0.0, 5, 0.0),
        (100.0, 2, 230.0),
    ],
)
def test_line_total(price, quantity, expected):
    assert line_total(price, quantity) == expected

عند التشغيل عبر pytest -v:

text
============================= test session starts =============================
collecting ... collected 4 items

test_pricing.py::test_line_total[15.0-1-17.25] PASSED                    [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED                    [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED                       [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED                   [100%]

أربعة اختبارات مستقلة نشأت من دالة واحدة. ويحمل كل اختبار قيمه الخاصة في اسمه، فإذا فشلت إحداها عُرفت فوراً — بينما لو كُتبت داخل حلقة تكرار عادية، لأوقف الفشل الأول بقية الحالات ولما بيّن أي القيم تحديداً كانت السبب.

وعند الفشل، يوضح التقرير القيم بدقة:

text
________________________ test_line_total[15.0-3-51.0] _________________________

price = 15.0, quantity = 3, expected = 51.0

عندما يتطلب الاختبار ملفاً حقيقياً — tmp_path

اختبار الأكواد التي تتعامل مع الملفات من الفصل الثالث والعشرين يحتاج إلى ملف فعلي. والاحتفاظ بملف تجريبي ثابت في مستودع المشروع عادة سيئة: إذ تتداخل الاختبارات مع بعضها، وتعديل أحد الاختبارات للملف يفسد الاختبار التالي.

يوفر إطار pytest لكل اختبار مجلداً مؤقتاً فارغاً خاصاً به ومستقلاً:

python
from reader import read_names


def test_blank_lines_are_skipped(tmp_path):
    path = tmp_path / "names.txt"
    path.write_text("one\n\ntwo\n", encoding="utf-8")

    assert read_names(path) == ["one", "two"]


def test_empty_file_gives_empty_list(tmp_path):
    path = tmp_path / "names.txt"
    path.write_text("", encoding="utf-8")

    assert read_names(path) == []
text
..                                                                       [100%]
2 passed in 0.01s

المعامل tmp_path هو كائن pathlib.Path حقيقي — كائن Path نفسه من الفصل الثالث والعشرين، وتُدمج المسارات معه باستخدام علامة /. واسم المعامل هو ما يخبر pytest بما يجب تزويد الدالة به، ويحصل كل اختبار على مجلد جديد نظيف، فيمكن لاختبارين استخدام نفس اسم الملف دون أي تعارض.

التجهيزات المتكررة المشتركة — fixture

عندما تحتاج عدة اختبارات إلى نفس البيانات الابتدائية، يمكن كتابتها مرة واحدة وتسميتها كـ fixture:

python
import pytest

from pricing import order_total


@pytest.fixture
def order():
    return [("pen", 15.0, 3), ("bag", 850.0, 1)]


def test_total(order):
    assert order_total(order) == 1029.25


def test_one_line_removed(order):
    assert order_total(order[:1]) == 51.75
text
..                                                                       [100%]
2 passed in 0.01s

تماماً كما في tmp_path، يطابق pytest التجهيزة عبر اسم المعامل. وتُنفذ دالة التجهيز من جديد لكل اختبار على حدة، لذا فإن الاختبار الذي يعدل في القائمة يترك للاختبار الذي يليه نسخة طازجة غير متأثرة — مما يقضي على مشكلة الحالة المشتركة من الفصل الحادي والعشرين تصميماً وجذرياً.

فخ الأرقام العشرية (Floating-Point)

python
def test_addition():
    assert 0.1 + 0.2 == 0.3
text
F                                                                        [100%]
================================== FAILURES ===================================
________________________________ test_addition ________________________________

    def test_addition():
>       assert 0.1 + 0.2 == 0.3
E       assert (0.1 + 0.2) == 0.3

test_money.py:2: AssertionError

مشكلة الفصل الخامس القديمة تطل برأسها مجدداً، وعادة ما تكون الاختبارات هي أول مكان تلدغ فيه المبرمج.

python
import pytest


def test_addition():
    assert 0.1 + 0.2 == pytest.approx(0.3)
text
.                                                                        [100%]
1 passed in 0.01s

الدالة pytest.approx تعلن: "التقارب الكافي مقبول". استخدمها في أي اختبار يقارن أرقاماً عشرية (Floats) — ما لم تكن النتيجة قد قُربت بدقة مسبقاً عبر round() كما فعلنا في line_total.

ماذا يجب أن تختبر؟

قاعدة ذهبية واحدة تفوق كل القواعد الأخرى أهمية: الدالة التي تأخذ مدخلات وترجع قيمة يسهل اختبارها إلى أقصى حد.

تذكر الدوال النقية (Pure functions) من الفصل الحادي والعشرين؟ هذا الفصل هو مكافأتها المستحقة. فاختبار دالة line_total لم يتطلب ملفاً، ولا طلباً لمدخلات، ولا تجهيزات معقدة — بل مجرد استدعائها وتفحص القيمة المعادة.

وعلى العكس، فإن الدالة التي تطبع، أو تطلب مدخلات من المستخدم، أو تكتب في ملف، تتطلب ترتيبات مسبقة قبل أن تتمكن من اختبارها. ولهذا السبب تحديداً استقبلت دالة report في الفصل الثالث والعشرين قائمة وأرجعت قائمة دون التعامل المباشر مع ملفات.

صعوبة كتابة الاختبارات في الغالب ليست عيباً في الاختبارات ذاتها، بل في طريقة تصميم وبنية الكود نفسه.

ما يجب اختباره يتلخص في ثلاثة جوانب: المسار الطبيعي المعتاد، والحالات الحدية (الصفر، القائمة الفارغة، العنصر الواحد)، وما يُفترض به أن يفشل ويطلق أخطاء.


مثال متكامل

ملف pricing.py:

python
"""Prices and tax. Pure functions, which is what makes them testable."""

TAX_RATE = 0.15


def line_total(price: float, quantity: int) -> float:
    if quantity < 1:
        raise ValueError(f"quantity must be at least 1: {quantity}")
    return round(price * quantity * (1 + TAX_RATE), 2)


def order_total(lines: list[tuple[str, float, int]]) -> float:
    return round(sum(line_total(price, qty) for _, price, qty in lines), 2)

ملف test_pricing.py:

python
"""Tests for pricing. Each one names the behaviour it protects."""

import pytest

from pricing import line_total, order_total


@pytest.mark.parametrize(
    "price, quantity, expected",
    [
        (15.0, 1, 17.25),
        (15.0, 3, 51.75),
        (0.0, 5, 0.0),
        (0.01, 1, 0.01),
    ],
)
def test_line_total_applies_tax(price, quantity, expected):
    assert line_total(price, quantity) == expected


@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
    with pytest.raises(ValueError, match="at least 1"):
        line_total(15.0, quantity)


def test_error_message_names_the_value():
    with pytest.raises(ValueError, match="at least 1: -3"):
        line_total(15.0, -3)


@pytest.fixture
def order():
    return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]


def test_order_total_sums_the_lines(order):
    assert order_total(order) == 1305.25


def test_empty_order_totals_zero():
    assert order_total([]) == 0


def test_one_bad_line_stops_the_order(order):
    with pytest.raises(ValueError):
        order_total(order + [("clip", 5.0, 0)])
text
...........                                                              [100%]
11 passed in 0.01s

أربعة أمور جديرة بالدراسة:

أحد عشر اختباراً نشأت من سبع دوال. صنع parametrize الفارق، وعُدت كل حالة بشكل مستقل.

الأسماء تصف السلوك لا الكود. الاسم test_quantity_below_one_is_rejected يشرح بوضوح ما يضمنه البرنامج؛ بينما test_line_total_2 لا يقدم أي معنى. وهذا الاسم هو أول ما يظهر في تقرير الفشل، لذا يجدر بك صياغته كجملة واضحة ومفهومة.

الحالة 0.01 لم تُختر عشوائياً. إنها تمثل أصغر سعر ممكن — وهي حالة حدية، وتحديداً النقطة التي يثور حولها أكبر شك في سلوك دالة round(). فالأخطاء البرمجية تعيش في الأطراف والحواف، لا في المنتصف.

الاختبار الأخير يحمي التزاماً غير مباشر. الدالة order_total لا تجري أي تحقق من البيانات بنفسها — بل تعتمد كلياً على line_total. فلو قام شخص ما غداً بوضع try/except داخل order_total ليتجاهل الأسطر المعيبة بصمت، فسيفشل هذا الاختبار فوراً متسائلاً: "هل كنت تقصد ذلك حقاً؟".


حالات الخطأ الشائعة

no tests ran اسم الملف لا يبدأ بـ test_، أو أن اسم الدالة لا يبدأ بـ test_. كلاهما مطلوب ومفروض.

ModuleNotFoundError: No module named 'pricing' شغّل أمر pytest من المجلد الذي يحتوي على ملفات المشروع مباشرة.

الاختبار ينجح لكنه لا يتحقق من أي شيء نسيت كتابة assert. مجرد استدعاء دالة دون فحص سيمر بنجاح ما دامت الدالة لم تطلق استثناءً.

fixture 'order' not found نسيت إضافة المزخرف @pytest.fixture، أو أن اسم الدالة يختلف عن اسم المعامل في الاختبار.

فشل مقارنة الأرقام العشرية مع أنها تبدو متطابقة استخدم pytest.approx، أو قيد النتيجة بـ round() داخل الدالة نفسها.

DID NOT RAISE الكود الموجود داخل كتلة pytest.raises لم يتعطل ولم يطلق أي استثناء. إما أن الكود يحتوي على خلل، أو أن توقع الاختبار غير صحيح.

الاختبار ينجح بمفرده ويفشل عند تشغيله مع بقية الاختبارات تشترك الاختبارات في مورد أو حالة عامة — غالباً ما تكون قائمة أو قاموساً على مستوى الوحدة. تذكر مشكلة الحالة المشتركة من الفصلين الثاني والعشرين والسادس والعشرين. استخدم fixture لعزل البيانات.