🔝 الدليل الرئيسي للسلسلة: بايثون: لغة ومنظومة وأطر للمطوّرين
أصبح Pytest في 2026 المعيار الفعلي لاختبارات Python. الإصدار 9.0 المُثبَّت في نوفمبر 2025 (9.0.2 في أبريل 2026) يجلب الإعداد الأصلي في pyproject.toml، عرض التقدّم في تبويب الطرفية المتوافق مع OSC 9;4، ومنظومة من أكثر من 1000 إضافة (1007 في ربيع 2026). للخروج من مرحلة « أكتب assert بسيطة » وبناء سويت اختبارات جاد يصمد أمام تطوّر المشروع، تفرض نفسها عدة آليات متقدمة: fixtures قابلة للتمعير ومُحدَّدة النطاق، paramétrisation indirecte، marqueurs مخصصة، mocking مُستهدف، قياس التغطية والتنفيذ المتوازي. يُسلسلها هذا الدرس خطوة بخطوة مع أمثلة قابلة للاختبار مباشرة.
المتطلبات
- Python 3.13.13 أو 3.14.5 (راجع تثبيت Python وإعداد البيئة)
- مشروع موجود بـ pyproject.toml أو setup.cfg
- أساسيات الدوال والأصناف والمُزخرفات في Python
- الوقت المُقدَّر: 90 دقيقة
الخطوة 1 — تثبيت pytest وإعداد المشروع
التثبيت القياسي عبر pip أو uv. لمشروع مُهيكَل، نُسجّله كتبعية تطوير. ملف pyproject.toml يُمركز الآن كل إعدادات pytest تحت [tool.pytest.ini_options].
# Avec uv (recommandé 2026)
uv add --dev pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
# Avec pip classique
pip install --upgrade pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
هذا السطر يُثبّت pytest 9.0.2 والإضافات الأربع الأكثر فائدة: cov للتغطية، xdist للتنفيذ المتوازي، mock للتصحيحات المستهدفة، وasyncio لاختبار الكوروتينات. تحقق بـ pytest --version الذي يجب أن يعرض pytest 9.0.2.
# pyproject.toml — configuration pytest moderne
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--strict-config",
"-ra",
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html"
]
markers = [
"slow: marque les tests lents (plus de 1 s)",
"integration: tests d'intégration nécessitant une base externe",
"smoke: tests de fumée minimaux"
]
هذا الإعداد يفرض ثلاثة انضباطات هيكلية. --strict-markers يرفض أي marqueur غير مُعلَن (يتجنّب الأخطاء المطبعية الخفية). --strict-config يرفض الخيارات غير المعروفة. -ra يعرض ملخّص كل النتائج عدا passed. الـ markers تُتيح تصفية جزء من السويت بـ pytest -m slow أو الاستثناء بـ pytest -m "not slow".
الخطوة 2 — Fixtures مع scope وyield
الـ fixture دالة مُزخرفة بـ @pytest.fixture تُحضّر حالة قبل الاختبار وتُنظّف بعده. الـ scope يُحدّد مدة الحياة: function (افتراضي)، class، module، session. اختياره جيدًا يتجنّب ثوانٍ ضائعة في إعادة إنشاء قاعدة أو اتصال HTTP بين كل اختبار.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def db_engine():
"""Une seule base SQLite en mémoire pour toute la session."""
engine = create_engine("sqlite:///:memory:")
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def db_session(db_engine):
"""Session SQLAlchemy isolée par test avec rollback automatique."""
connection = db_engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
yield يفصل مرحلة setup (قبل) عن مرحلة teardown (بعد). عند انتهاء الاختبار المستهلك، يستأنف pytest التنفيذ بعد yield لإغلاق الاتصالات وحذف الملفات المؤقتة. الـ fixtures تتركّب بالتبعية: db_session يطلب db_engine كمعامل، وpytest يحلّ السلسلة تلقائيًا. لمشاركة fixtures بين عدة ملفات اختبار، نُعلنها في tests/conftest.py.
الخطوة 3 — Paramétrisation directe et indirecte
اختبار نفس المنطق بعدة مجموعات بيانات يتجنّب التكرار. @pytest.mark.parametrize يُقدّم قائمة قيم وpytest يُولّد اختبارًا لكل إدخال.
@pytest.mark.parametrize("entree,attendu", [
(0, 0), (1, 1), (2, 1), (3, 2), (10, 55), (15, 610),
], ids=["zero", "un", "deux", "trois", "dix", "quinze"])
def test_fibonacci(entree, attendu):
assert fibonacci(entree) == attendu
@pytest.fixture
def utilisateur(request):
role = request.param
return Utilisateur(nom=f"test_{role}", role=role)
@pytest.mark.parametrize("utilisateur", ["admin", "editeur", "lecteur"], indirect=True)
def test_permissions(utilisateur):
assert utilisateur.role in {"admin", "editeur", "lecteur"}
المعامل ids يُعطي أسماء قابلة للقراءة في إخراج pytest. الـ paramétrisation indirecte قوية: المعامل يذهب للـ fixture، لا للاختبار.
الخطوة 4 — Mocking مُستهدف بـ pytest-mock
عزل المنطق المُختبَر عن تبعياته الخارجية (API، قاعدة بيانات، ساعة النظام) يمرّ عبر mocking. pytest-mock يكشف fixture mocker تتكامل أصلًا مع دورة حياة pytest.
def test_envoie_email_appelle_provider(mocker):
fake_send = mocker.patch("monpkg.notifications.send_via_smtp")
fake_send.return_value = True
resultat = envoyer_bienvenue("user@example.com")
assert resultat is True
fake_send.assert_called_once_with(
destinataire="user@example.com",
sujet="Bienvenue",
corps=mocker.ANY
)
def test_lit_heure_courante(mocker):
mocker.patch("monpkg.time.now", return_value=datetime(2026, 5, 17, 12, 0, 0))
assert horodatage_courant() == "2026-05-17 12:00"
ثلاث نقاط عملية. mocker.patch("chemin.complet.fonction") يستبدل الدالة في المكان الذي تُستورد فيه، لا حيث تُعرَّف. assert_called_once_with يتحقق من عدد الاستدعاءات والوسيطات الدقيقة معًا. mocker.ANY يسمح بوسيطات لا نريد التحقق منها بصرامة.
الخطوة 5 — التغطية بـ pytest-cov
قياس التغطية يُشير إلى الأسطر التي نفذتها الاختبارات. مع عتبات دنيا في CI، يمنع تراجعات التغطية الخفية.
pytest --cov=src --cov-report=term-missing --cov-report=html
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*", "*/__init__.py"]
[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 80
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:"
]
branch = true يقيس تغطية الفروع أيضًا. fail_under = 80 يُفشل CI تحت 80% — رقم هدف معقول. تقرير HTML (htmlcov/index.html) يُظهر سطرًا بسطر ما ليس مُغطَّى.
الخطوة 6 — اختبارات async وتنفيذ متوازٍ
اختبار كوروتينات async def يتطلب pytest-asyncio. منذ 0.21، يكتشف وضع auto الكوروتينات ويُطبّق @pytest.mark.asyncio تلقائيًا.
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
async def test_fetch_user(http_client):
response = await http_client.get("/users/42")
assert response.status_code == 200
assert response.json()["id"] == 42
@pytest_asyncio.fixture
async def http_client():
async with httpx.AsyncClient(base_url="http://test") as client:
yield client
للسويتات الطويلة، pytest-xdist يُوزّع الاختبارات على عدة workers: pytest -n auto يستخدم worker لكل نواة CPU. سويت من 500 اختبار تستغرق 60 ثانية أحادية الخيط تنخفض إلى 12-15 ثانية على Mac M2 بـ 8 أنوية.
الخطوة 7 — pytest في CI GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v8.1.0
- run: uv python install \${{ matrix.python }}
- run: uv sync --all-extras
- run: uv run pytest --junit-xml=junit.xml --cov-report=xml -n auto
- uses: codecov/codecov-action@v6
with:
files: ./coverage.xml
المصفوفة python: ["3.13", "3.14"] تُنفّذ السويت على الإصدارين المستقرين. setup-uv هو الإجراء الرسمي لـ Astral الذي يُثبّت uv في ثوانٍ.
الخطوة 8 — Marqueurs مخصصة وhooks
ما وراء marqueurs التعريفية، يكشف pytest نظام hooks قوي لحقن سلوك قبل/بعد كل اختبار. الـ hooks تعيش في conftest.py.
# tests/conftest.py
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.duration > 1.0:
print(f"\nTEST LENT : {item.nodeid} a pris {rep.duration:.2f}s")
def pytest_collection_modifyitems(config, items):
if not os.getenv("DATABASE_URL"):
skip_marker = pytest.mark.skip(reason="DATABASE_URL non défini")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip_marker)
الـ hook الأول يُتتبّع الاختبارات البطيئة في الزمن الحقيقي. الثاني يتخطّى تلقائيًا اختبارات التكامل إذا كان متغيّر البيئة المطلوب غائبًا.
أخطاء شائعة
| العَرَض | السبب | الحل |
|---|---|---|
ModuleNotFoundError عند الاستيراد |
لا يوجد __init__.py أو الجذر ليس في sys.path |
Layout src/ مع pip install -e . |
| Mock لا يعمل | Patch في التعريف بدل الاستخدام | Patcher monpkg.module_qui_utilise.fonction |
| fixture scope=session تموت | xdist يُنشئ session لكل worker | إخراج الحالة المشتركة أو تعطيل xdist |
| اختبار async لا يُنفَّذ | وضع asyncio غير مُعَدّ | asyncio_mode = "auto" |
| تغطية تنخفض | كود مُتفرّع غير مُختبَر | branch = true في coverage.run |
| marqueur متجاهَل (typo) | strict-markers غير نشط | إضافة --strict-markers |
الأسئلة الشائعة
pytest أم unittest؟
pytest لكل مشروع جديد. unittest يبقى مفيدًا لدمج كود قديم أو لـ stdlib صارمة بدون تبعية خارجية.
أي تغطية نستهدف؟
80% على طبقات الأعمال والأدوات، 60-70% على طبقة التكامل. 100% نادرًا ما يكون مفيدًا.
كيف ننفّذ اختبارًا واحدًا؟pytest tests/test_user.py::test_creation_admin أو pytest -k "admin" للتصفية بكلمة مفتاحية.
هل نحفظ .coverage؟
لا. أضف .coverage وhtmlcov/ إلى .gitignore.
كيف نختبر endpoint FastAPI؟httpx.AsyncClient مع app=fastapi_app أو TestClient من Starlette.
كيف نتجنّب الاختبارات المتذبذبة (flaky)؟
منع time.sleep لصالح freezegun أو mocks ساعة. Cassettes VCR للشبكة.
كم اختبار لكل ملف؟
ملف لكل وحدة مُختبَرة. إذا تجاوز ملف 500 سطر، قسّمه.