كتابة الاختبارات باستخدام pytest
من عبارة `assert` إلى إطار pytest، قراءة تقارير الفشل، واختبار الأخطاء عبر `pytest.raises`، و `parametrize`، واستخدام `tmp_path` والتجهيزات (fixtures) — وبنية الكود القابل للاختبار.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
حتى هذه اللحظة، كنا "نختبر" كودنا بطريقة واحدة فقط: نقوم بتشغيله، ثم نقرأ المخرجات المطبوعة على الشاشة، ونقرر ما إذا كانت تبدو صحيحة أم لا.
هذه الطريقة تنجح مرة واحدة. لكن المتاعب تبدأ في المرة الثانية.
في برنامج الفصل الخامس والعشرين، كانت نسبة الضريبة TAX_RATE محددة بـ 0.15. لنفترض أنها أصبحت الآن 0.18. سيعمل البرنامج، وتُطبع كل الأرقام دون أي خطأ — ولن تكون لديك أي طريقة لمعرفة ما إذا كان أي شيء قد تغير أو فسد في الحسابات، إلا إذا كنت تتذكر بدقة بالغة المخرجات السابقة القديمة.
وما يحتاج إلى الذاكرة البشرية سينتهي به المطاف حتماً إلى النسيان.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okأمر التوكيد assert هو جملة بسيطة تعلن: "يُفترض أن يكون هذا التعبير صحيحاً". فإن كان صحيحاً، لا يحدث شيء ويمر الكود بسلام؛ وإن كان خاطئاً، يتوقف البرنامج فوراً.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorهذا هو الأساس المتين. ويدور هذا الفصل حول أداة بنيت على هذا الأساس — وهي إطار pytest — القادرة على تشغيل مئات من هذه الادعاءات في ثوانٍ معدودة، وعندما يفشل أحدها، توضح لك بدقة لماذا فشل.
في نهاية هذا الدرس ستكون قادراً على
- كتابة ملف اختبارات وتشغيله باستخدام
pytest - قراءة تقرير الفشل التفصيلي وتحديد موضع الخطأ منه مباشرة
- اختبار الاستثناءات والأخطاء المتوقعة عبر
pytest.raises - تشغيل اختبار واحد على حالات متعددة باستخدام
@pytest.mark.parametrize - اختبار الأكواد التي تتعامل مع الملفات بأمان باستخدام
tmp_path - تمييز الأكواد التي يسهل اختبارها ومعرفة سبب ذلك
المتطلبات السابقة: فئات البيانات وتلميحات الأنواع.
أول اختبار تكتبه
قاعدتان فقط، وهذا كل ما في الأمر: يبدأ اسم الملف بـ test_، وكذلك يبدأ اسم دالة الاختبار بـ test_.
ملف pricing.py:
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:
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:
.. [100%]
2 passed in 0.01sنقطتان تدلان على نجاح اختبارين. يكتشف pytest الملفات والدوال تلقائياً ويشغلها — دون الحاجة لتسجيل أي شيء يدوياً.
تقرير الفشل هو جوهر القوة
دعنا نضع رقماً خاطئاً عن عمد في أحد الاختبارات:
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) عند تلقي مدخلات معيبة. وهذا السلوك هو بمثابة التزام برمجي رسمي، وبالتالي يمكن ويجب اختباره:
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).. [100%]
2 passed in 0.01sكتلة pytest.raises تعلن: "من المفترض أن يحدث هذا الخطأ داخل هذه الكتلة". وإذا لم يحدث الخطأ، يفشل الاختبار:
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)
كتابة أربع دوال اختبار منفصلة لأربع حالات هو عمل رتيب وممل ويدعو للوقوع في أخطاء النسخ واللصق.
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:
============================= 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%]أربعة اختبارات مستقلة نشأت من دالة واحدة. ويحمل كل اختبار قيمه الخاصة في اسمه، فإذا فشلت إحداها عُرفت فوراً — بينما لو كُتبت داخل حلقة تكرار عادية، لأوقف الفشل الأول بقية الحالات ولما بيّن أي القيم تحديداً كانت السبب.
وعند الفشل، يوضح التقرير القيم بدقة:
________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0عندما يتطلب الاختبار ملفاً حقيقياً — tmp_path
اختبار الأكواد التي تتعامل مع الملفات من الفصل الثالث والعشرين يحتاج إلى ملف فعلي. والاحتفاظ بملف تجريبي ثابت في مستودع المشروع عادة سيئة: إذ تتداخل الاختبارات مع بعضها، وتعديل أحد الاختبارات للملف يفسد الاختبار التالي.
يوفر إطار pytest لكل اختبار مجلداً مؤقتاً فارغاً خاصاً به ومستقلاً:
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) == [].. [100%]
2 passed in 0.01sالمعامل tmp_path هو كائن pathlib.Path حقيقي — كائن Path نفسه من الفصل الثالث والعشرين، وتُدمج المسارات معه باستخدام علامة /. واسم المعامل هو ما يخبر pytest بما يجب تزويد الدالة به، ويحصل كل اختبار على مجلد جديد نظيف، فيمكن لاختبارين استخدام نفس اسم الملف دون أي تعارض.
التجهيزات المتكررة المشتركة — fixture
عندما تحتاج عدة اختبارات إلى نفس البيانات الابتدائية، يمكن كتابتها مرة واحدة وتسميتها كـ fixture:
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.. [100%]
2 passed in 0.01sتماماً كما في tmp_path، يطابق pytest التجهيزة عبر اسم المعامل. وتُنفذ دالة التجهيز من جديد لكل اختبار على حدة، لذا فإن الاختبار الذي يعدل في القائمة يترك للاختبار الذي يليه نسخة طازجة غير متأثرة — مما يقضي على مشكلة الحالة المشتركة من الفصل الحادي والعشرين تصميماً وجذرياً.
فخ الأرقام العشرية (Floating-Point)
def test_addition():
assert 0.1 + 0.2 == 0.3F [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مشكلة الفصل الخامس القديمة تطل برأسها مجدداً، وعادة ما تكون الاختبارات هي أول مكان تلدغ فيه المبرمج.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.01sالدالة pytest.approx تعلن: "التقارب الكافي مقبول". استخدمها في أي اختبار يقارن أرقاماً عشرية (Floats) — ما لم تكن النتيجة قد قُربت بدقة مسبقاً عبر round() كما فعلنا في line_total.
ماذا يجب أن تختبر؟
قاعدة ذهبية واحدة تفوق كل القواعد الأخرى أهمية: الدالة التي تأخذ مدخلات وترجع قيمة يسهل اختبارها إلى أقصى حد.
تذكر الدوال النقية (Pure functions) من الفصل الحادي والعشرين؟ هذا الفصل هو مكافأتها المستحقة. فاختبار دالة line_total لم يتطلب ملفاً، ولا طلباً لمدخلات، ولا تجهيزات معقدة — بل مجرد استدعائها وتفحص القيمة المعادة.
وعلى العكس، فإن الدالة التي تطبع، أو تطلب مدخلات من المستخدم، أو تكتب في ملف، تتطلب ترتيبات مسبقة قبل أن تتمكن من اختبارها. ولهذا السبب تحديداً استقبلت دالة report في الفصل الثالث والعشرين قائمة وأرجعت قائمة دون التعامل المباشر مع ملفات.
صعوبة كتابة الاختبارات في الغالب ليست عيباً في الاختبارات ذاتها، بل في طريقة تصميم وبنية الكود نفسه.
ما يجب اختباره يتلخص في ثلاثة جوانب: المسار الطبيعي المعتاد، والحالات الحدية (الصفر، القائمة الفارغة، العنصر الواحد)، وما يُفترض به أن يفشل ويطلق أخطاء.
مثال متكامل
ملف pricing.py:
"""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:
"""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)])........... [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 لعزل البيانات.
Step 4 of 6 — Predict
Check your understanding
Running pytest -q, what is the last line of the report?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A1 failed, 1 passed in 0.01s
- B2 failed in 0.01s
- C2 passed in 0.01s
- D1 failed in 0.01s
add(2, 2) is not 5. What does pytest -q say?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- A1 passed — the test passes
- B1 failed — as it should
- Cno tests ran
- DAn `AssertionError`
The first test put an item in the basket. What does the second see?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- A2 passed — the second test gets a fresh empty list
- B1 failed, 1 passed — the second sees one item
- C2 failed
- Dfixture 'basket' not found
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.
دورك الآن
خذ ملف library.py من الفصل السابع والعشرين — الذي يحتوي على Book و Shelf — واكتب بجواره ملف test_library.py.
واحرص على تغطية هذه الاختبارات على الأقل:
- عدة حالات لحساب
total_pages()عبرparametrize، بما في ذلك رف فارغ - اختبار
pytest.raisesمعmatch=للتحقق من رفضpages=0 - التحقق من أن
longest()ترجعNoneعلى الرف الفارغ - التحقق من أن
by_authorتُرجع بدقة كتب ذلك الكاتب فقط، وترجع قائمة فارغة لكاتب غير موجود - اختبار واحد على الأقل يستخدم
tmp_pathلكتابة بيانات الرف في ملف JSON وقراءتها مجدداً
ثم أجرِ هذه التجارب الخمس:
- احذف
assertمن أحد الاختبارات واكتفِ باستدعاء الدالة فقط. هل ينجح الاختبار؟ - أعد تسمية الملف إلى
library_test.pyثم شغّلpytest. ماذا يحدث؟ - احذف شرط التحقق
pages < 1من صنفBook. ما هي الاختبارات التي ستفشل، وهل يوضح التقرير السبب؟ - ضع خطأ متعمداً في حساب
total_pages()— كإضافة+ 1مثلاً. هل يوضح تقرير الفشل كلاً من الناتج الفعلي والمتوقع؟ - اكتب اختباراً يؤكد أن
0.1 + 0.2 == 0.3. ثم صححه باستخدامpytest.approx.
التجربة الثالثة هي الأهم على الإطلاق. مجموعة الاختبارات الجيدة تخبرك فور انكسارها بأي التزام برمجي تم الإخلال به — وهذا هو الضمان الحقيقي الذي يجعل تطوير البرنامج وتعديله بعد ستة أشهر أمراً آمناً وممكناً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz