كتابة الاختبارات باستخدام pytest
من عبارة `assert` إلى إطار pytest، قراءة تقارير الفشل، واختبار الأخطاء عبر `pytest.raises`، و `parametrize`، واستخدام `tmp_path` والتجهيزات (fixtures) — وبنية الكود القابل للاختبار.
كيف تريد أن تتعلم؟
المشكلة التي نقوم بحلها
حتى الآن كنا “نختبر” الكود بطريقة واحدة: نشغّله، ونقرأ المخرجات، ونقرر هل تبدو صحيحة أم لا.
هذا ينجح مرة واحدة. أما المشكلة فتبدأ في المرة الثانية.
في برنامج الفصل 25 كانت قيمة 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")okassert جملة بسيطة: “يُفترض أن يكون هذا صحيحاً”. إن كان صحيحاً فلا يحدث شيء؛ وإن لم يكن، يتوقف البرنامج بخطأ AssertionError.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorهذا هو الأساس. وهذا الفصل عن أداة مبنية عليه — pytest — تشغّل مئات من جمل assert هذه، وإذا فشلت إحداها تقول لك لماذا فشلت.
في نهاية هذا الفصل ستكون قادراً على
- كتابة ملف اختبار وتشغيل
pytest - قراءة تقرير اختبار فاشل وإيجاد المشكلة منه
- اختبار الأخطاء باستخدام
pytest.raises - تشغيل اختبار واحد على حالات كثيرة باستخدام
@pytest.mark.parametrize - اختبار كود يعتمد على الملفات باستخدام
tmp_path - شرح أي كود يسهل اختباره ولماذا
ما تحتاج معرفته أولاً: dataclass وتلميحات الأنواع.
أول اختبار
قاعدتان، وهذا كل شيء: يبدأ اسم الملف بـtest_، وكذلك اسم دالة الاختبار. (يبحث pytest أيضاً عن الملفات التي تنتهي بـ_test.py، لكننا في هذه الدورة نبدأ الأسماء بـ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.00sنقطتان، أي اختباران ناجحان. يجد pytest الملفات والدوال بنفسه ويشغّلها — لا حاجة إلى تسجيل أي شيء في أي مكان. وقد يختلف الوقت في النهاية (0.00s) قليلاً على جهازك.
تقرير الفشل هو الأهم
لنضع رقماً خاطئاً عمداً في الاختبار الثاني من test_pricing.py — 51.0 بدلاً من 51.75 — ونشغّل pytest -q مرة أخرى:
.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:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01s.F في السطر الأول تعني أن الاختبار الأول نجح والثاني فشل. أمر assert العادي لم يعطنا سوى AssertionError — كلمة واحدة بلا أي معلومة. أما هنا فنحصل على:
- أي اختبار (
test_three_items) وأي سطر (test_pricing.py:9) - جملة
assertالتي فشلت بالضبط، مُعلَّمة بـ> assert 51.75 == 51.0— ما الذي نتج فعلاً، وما الذي كان متوقعاًwhere 51.75 = line_total(15.0, 3)— من أين جاء ذلك الرقم- العدد في السطر الأخير:
1 failed, 1 passed
يُسمّى هذا assertion introspection، وهو السبب الرئيسي لاستخدام pytest. كثير من أدوات الاختبار تحتاج لهذا إلى طريقة منفصلة مثل assertEqual(a, b)؛ أما هنا فتكفي == العادية.
الأخطاء أيضاً تحتاج إلى اختبار
في الفصل 24 تعلّمنا أن الدالة يجب أن تُطلق (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 تقول “يُفترض أن يحدث هذا الخطأ هنا”. وإن لم يحدث، يفشل الاختبار. مثلاً، إذا توقّع اختبار اسمه test_one_is_rejected خطأ ValueError من line_total(15.0, 1) — مع أن الكمية 1 صحيحة:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01sالجزء match= يتحقق من أن رسالة الخطأ تحتوي على ذلك النص. كتابته مفيدة، لأن قاعدة الفصل 24 — ضع القيمة التي سببت المشكلة في الرسالة — تصبح عندها وعداً مُختبَراً بحد ذاته.
اختبار واحد، حالات كثيرة
كتابة أربع دوال لأربع حالات عمل مملّ، ويجلب أخطاء النسخ واللصق.
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%]
============================== 4 passed in 0.01s ===============================أربعة اختبارات منفصلة من دالة واحدة. يحمل اسم كل منها قيمها، فإذا فشل أحدها عرفت فوراً أيّها. ولو كُتبت كحلقة، لأوقف الفشل الأول البقية، ولما عُرفت القيم المسؤولة.
ضع 51.0 بدلاً من 51.75 في الحالة الثانية وشغّل pytest -q؛ بداية التقرير تُظهر القيم أيضاً:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0عندما تحتاج إلى ملف — tmp_path
اختبار كود يقرأ الملفات، مثل كود الفصل 23، يحتاج إلى ملف. والاحتفاظ بملف في المستودع (repository) عادة سيئة: تتشابك الاختبارات، وإذا غيّر اختبار الملف انكسر اختبار آخر.
لنفترض أن reader.py فيه هذه الدالة — تعيد الأسطر غير الفارغة من ملف نصي:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]يستطيع pytest أن يعطي كل اختبار مجلداً فارغاً خاصاً به. test_reader.py:
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.01stmp_path كائن pathlib.Path — هو Path نفسه من الفصل 23، وربط المسارات بـ/ كما هناك. اسم المعامل هو ما يخبر pytest بما يجب أن يعطيه. ويحصل كل اختبار على مجلد جديد — لذا يمكن لاختبارين استخدام اسم الملف نفسه دون أي تعارض.
تجهيز متكرر — fixture
إذا احتاجت عدة اختبارات إلى المدخل نفسه، يمكن كتابته مرة واحدة وإعطاؤه اسماً. هنا order_total دالة أخرى في pricing.py تحسب مجموع طلب كامل — الملف كاملاً موجود في المثال المتكامل أدناه:
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 حسب اسم المعامل. وتُشغَّل الدالة من جديد لكل اختبار، لذا حتى لو غيّر اختبار القائمة، يحصل الاختبار التالي على قائمة جديدة — مشكلة البيانات المشتركة من الفصل 21، أُزيلت بالتصميم.
فخ الأعداد العشرية (float)
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
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sالمسألة القديمة من الفصل 5، وكثيراً ما تظهر لأول مرة في الاختبارات.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx تقول “القريب بما يكفي مقبول”. استخدمها في أي اختبار فيه أعداد عشرية — إلا إذا كان الرقم قد ثُبِّت مسبقاً بـround()، كما تفعل line_total.
ماذا تختبر
قاعدة واحدة أنفع من كل القواعد الأخرى: الدالة التي تأخذ قيماً وتعيد قيمة يسهل اختبارها.
هل تتذكر الدوال pure من الفصل 21؟ هذا الفصل يُظهر فائدتها. اختبار line_total لم يحتج إلى ملف، ولا إلى مدخلات، ولا إلى أي تجهيز — فقط استدعاؤها والنظر في النتيجة.
في المقابل، الدالة التي تطبع، أو تطلب مدخلات، أو تكتب ملفاً — تحتاج إلى ترتيبات إضافية قبل اختبارها. لهذا لم تأخذ report في الفصل 23 أي ملف: أخذت قائمة (list) وأعادت قائمة.
صعوبة كتابة الاختبارات ليست في الغالب ذنب الاختبارات — بل ذنب شكل الكود.
ماذا تختبر — ثلاثة أنواع من الحالات: ما يحدث عادة، والحالات الحدّية (صفر، فارغ، واحد)، وما يُفترض أن يفشل.
مثال متكامل
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.py)، أو اسم الدالة لا يبدأ بـtest_. كلاهما مطلوب.
ModuleNotFoundError: No module named 'pricing' شغّل pytest من المجلد الذي توجد فيه الملفات.
اختبار ينجح لكنه لا يتحقق من شيء نسيت كتابة assert. مجرد استدعاء دالة ينجح ما دامت لا تُطلق خطأ.
fixture 'order' not found نسيت @pytest.fixture، أو الاسم لا يطابق اسم المعامل.
مقارنة أعداد عشرية تفشل رغم أن الأرقام تبدو صحيحة استخدم pytest.approx، أو round() داخل الدالة نفسها.
DID NOT RAISE الكود داخل كتلة pytest.raises لم يُطلق خطأ. إما أن الكود خاطئ، أو توقّع الاختبار خاطئ.
اختبار ينجح وحده ويفشل مع البقية الاختبارات تتشارك شيئاً — غالباً قائمة أو قاموساً على مستوى الوحدة (module). البيانات المشتركة القابلة للتعديل من الفصلين 21 و26. استخدم fixture.
الخطوة ٤ من ٦ — التوقع
تحقّق من فهمك
عند تشغيل pytest -q، ما السطر الأخير في التقرير؟
# 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)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) ليست 5. ماذا يقول pytest -q؟
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- Aخطأ `AssertionError`
- B1 failed — كما يجب
- Cno tests ran
- D1 passed — ينجح الاختبار
وضع الاختبار الأول عنصراً في السلة. ماذا يرى الثاني؟
# 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- Afixture 'basket' not found
- B2 failed
- C2 passed — يحصل الاختبار الثاني على قائمة فارغة جديدة
- D1 failed, 1 passed — يرى الثاني عنصراً واحداً
الإجابة تتطلب حساباً
سجّل الدخول للتحقق من إجاباتك
الأسئلة موجودة أعلاه، والتفكير في إجاباتها ذهنياً هو الجزء المهم. سجّل الدخول لرؤية الإجابات والتفسيرات والتلميحات ذات المستويات الثلاثة.
دورك الآن
خذ library.py من الفصل 27 — Book وShelf — واكتب بجانبه test_library.py.
على الأقل هذه:
- عدة حالات لـ
total_pages()باستخدامparametrize، منها رف فارغ - رفض
pages=0باستخدامpytest.raisesمعmatch= - أن
longest()تعيدNoneلرف فارغ - أن
by_authorتعيد كتب ذلك الكاتب بالضبط، وقائمة فارغة إن لم توجد - اختبار يستخدم
tmp_path، يكتب فيه الرف إلى JSON ثم يقرؤه مرة أخرى
ثم خمس تجارب:
- احذف
assertمن اختبار واستدعِ الدالة فقط. هل ينجح الاختبار؟ - غيّر اسم ملف الاختبار إلى
check_library.pyوشغّلpytest. ماذا يحدث؟ (أما باسمlibrary_test.pyفيُعثر عليه — لماذا؟) - احذف التحقق
pages < 1منBook. أي الاختبارات تفشل، وهل يقول التقرير لماذا؟ - ضع خطأ متعمداً في
total_pages()— مثلsum(...) + 1. هل يُظهر تقرير الفشل الرقم الفعلي والرقم المتوقع معاً؟ - اكتب اختباراً يقول
assert 0.1 + 0.2 == 0.3. ثم أصلحه بـpytest.approx.
التجربة الثالثة هي الأكثر تعليماً. مجموعة الاختبارات الجيدة، عندما تنكسر، تخبرك أي وعد كُسر — وهذا ما يجعل البرنامج آمناً للتغيير بعد ستة أشهر.
الخطوة ٦ من ٦
التحدي — اختبار الفصل
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
سجّل الدخول لأداء الاختبار