Kodokon kodokon.com

الاختبارات باستخدام pytest: fixtures، والاختبارات المُعامَلة، وأفضل الممارسات

هيكِل اختبارات متينة باستخدام pytest: حقن fixtures، والتنظيف عبر yield، والاختبارات المُعامَلة على شكل جدول حالات، وفخاخ النطاقات الواسعة.

9 دقيقة · 3 أسئلة

افتح هذا الدرس في Kodokon

تقوم pytest على فكرتين قويّتين: الاكتشاف بالاصطلاح (ملفات test_*.py، ودوال test_*) والتعليمة assert البسيطة، التي يعرض استبطانها القيم الفعلية للطرفين عند الفشل - فلا داعي بعد الآن لحفظ assertEqual وثلاثين نسخة منها. وأداتان يجب معرفتهما فورًا: pytest.raises للتحقّق من أن استثناءً قد أُطلق فعلًا، وpytest.approx لمقارنة الأعداد العشرية دون مصارعة التقريب الثنائي.

PYTHON
import pytest

def divide(a: float, b: float) -> float:
    return a / b

def test_nominal() -> None:
    assert divide(8, 4) == 2.0

def test_zero_division() -> None:
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

def test_floats() -> None:
    assert 0.1 + 0.2 == pytest.approx(0.3)
بدون approx، تكون 0.1 + 0.2 == 0.3 خاطئة في الأعداد العشرية الثنائية.

إن fixtures هي حقن التبعيات في pytest: صرّح بوسيط يحمل اسم fixture، فتبنيها pytest ثم تحقنها. ويفصل yield بين التهيئة والتنظيف - إذ يُنفَّذ الكود الذي يليه حتى لو فشل الاختبار. ويتحكّم الوسيط scope (function افتراضيًا، أو module، أو session) في مدة حياة الكائن. ضع fixtures المشتركة في ملف conftest.py: فتصبح متاحة في المجلد كله، دون استيراد واحد.

PYTHON
import sqlite3

import pytest

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (name TEXT)")
    yield conn
    conn.close()

def test_insert(db) -> None:
    db.execute("INSERT INTO users VALUES ('Ada')")
    query = "SELECT name FROM users"
    rows = db.execute(query).fetchall()
    assert rows == [("Ada",)]
تُنفَّذ conn.close() بعد الاختبار، سواء نجح أم فشل.

يحوّل @pytest.mark.parametrize اختبارًا واحدًا إلى جدول حالات: فيصبح كل صفّ (tuple) اختبارًا مستقلًا، له اسمه ويمكن ترشيحه على حدة. كدّس عدة مزيّنات parametrize للحصول على الجداء الديكارتي للوسائط. وامنح حالاتك معرّفات id مقروءة عبر pytest.param(..., id='...') - فتقرير الفشل يجب أن يكون مفهومًا دون فتح الملف المصدري.

PYTHON
import pytest

def slugify(text: str) -> str:
    return "-".join(text.lower().split())

@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("Hello World", "hello-world"),
        ("  Python  rocks ", "python-rocks"),
        pytest.param("", "", id="empty"),
    ],
)
def test_slugify(raw: str, expected: str) -> None:
    assert slugify(raw) == expected
ثلاث حالات، وثلاثة اختبارات متمايزة في التقرير.
BASH
pytest -x --lf
pytest -k "slugify and not empty"
pytest -q --durations=5
الخيار --lf يعيد تشغيل الإخفاقات وحدها؛ والخيار --durations يكشف عن الاختبارات البطيئة.

اختبار المعرفة

تأكّد من أنك تذكّرت النقاط الأساسية في هذا الدرس.

  1. كيف تعرف pytest أيّ fixture عليها أن تزوّد بها اختبارًا؟
    • بفضل مزيّن @inject يوضع على الاختبار
    • حسب ترتيب التصريح بـ fixtures في الملف
    • حسب التعليق التوضيحي لنوع الوسيط
    • حسب اسم الوسيط: فإذا طابق fixture مرئية (في الوحدة أو في conftest.py)، تبنيها pytest وتحقنها
  2. في fixture، ما الغرض من الكود الموضوع بعد yield؟
    • إنه التنظيف: يُنفَّذ بعد الاختبار، حتى لو فشل الاختبار
    • لا يُنفَّذ أبدًا، فـ yield تُنهي fixture
    • يوفّر قيمة بديلة إذا فشلت التهيئة
    • لا يُنفَّذ إلا إذا فشل الاختبار
  3. ماذا يفعل الأمر pytest --lf؟
    • يسرد fixtures المتاحة في المشروع
    • يعيد تشغيل الاختبارات التي فشلت في التشغيل الأخير وحدها
    • يوقف الجلسة عند أول إخفاق يصادفه