الاختبار — ضمان الجودة الشامل مع pytest
الاختبار مثل شبكة الأمان—لا يمكنك رؤيتها أثناء المشي على الحبل (كتابة الكود)، لكن إذا انزلقت (واجهت خطأ)، فهي خط حياتك الوحيد. الأداء بدون شبكة أمان محكوم عليه بالحادث عاجلًا أم آجلًا.
1. ما ستتعلمه
- أساسيات
TestClient(httpx): عميل اختبار متزامن/غير متزامن - عزل قاعدة بيانات الاختبار: استخدام قاعدة بيانات منفصلة لكل اختبار، تُدار عبر fixtures
- تجاوز الاعتماد:
app.dependency_overridesيستبدل اعتمادات DB/المصادقة - هرم الاختبار: استراتيجية طبقية لاختبار الوحدة → اختبار التكامل → اختبار E2E
- سيناريو Alice: مجموعة اختبارات PriceTracker الكاملة
2. القصة الحقيقية لـ Alice
(1) نقطة الألم: اكتشاف الأخطاء فقط بعد الإطلاق
بعد نشر PriceTracker، اكتشفت Alice أن وقت انتهاء صلاحية رمز JWT كان معينًا إلى ثانية واحدة، وأن الاستيراد المجمّع كان يتخطى جميع التحققات، وأن تحديد المعدل للمستخدمين Pro كان نفسه للمستخدمين المجانيين. في كل مرة تصلح خطأً، تُدخل خطأً جديدًا، واشتكى Bob: الميزة التي كانت تعمل الأسبوع الماضي لا تعمل هذا الأسبوع مرة أخرى. لم يكن لدى Alice اختبارات آلية؛ اعتمدت بالكامل على النقر يدويًا عبر Swagger UI، وكل اختبار انحدار استغرق ساعتين.
(2) حلول اختبار pytest الآلي
pytest + httpx TestClient يضمن أن كل نقطة نهاية API لديها اختبارات آلية؛ dependency_overrides يستبدل قاعدة بيانات الإنتاج بقاعدة بيانات اختبار؛ وفي كل مرة يتم تشغيل git push، تُنفذ جميع الاختبارات تلقائيًا، مما يكتشف جميع مشاكل الانحدار في دقيقتين.
def test_create_product(client):
response = client.post("/api/v1/products", json={"name": "Widget", "price": 9.99})
assert response.status_code == 201
assert response.json()["name"] == "Widget"
(3) العائد
انتقل اختبار الانحدار من عملية يدوية مدتها ساعتان إلى عملية آلية مدتها دقيقتان؛ تم اكتشاف خطأ انتهاء صلاحية JWT خلال مرحلة الاختبار في التطوير؛ وبعد النشر، انخفض عدد الأخطاء من 5 أسبوعيًا إلى 1 شهريًا.
3. أساسيات TestClient
(1) هرم الاختبار
graph TD
E2E[اختبارات E2E - قليلة] --> INT[اختبارات التكامل - متوسطة]
INT --> UNIT[اختبارات الوحدة - كثيرة]
UNIT --- U1[التحقق من نموذج Pydantic]
UNIT --- U2[دوال Repository]
UNIT --- U3[منطق الخدمة]
INT --- I1[نقطة نهاية API + DB]
INT --- I2[تدفق المصادقة]
INT --- I3[عمليات CRUD]
E2E --- E1[رحلة المستخدم الكاملة]
E2E --- E2[WebSocket + API]
| المستوى | الكمية | السرعة | الاعتمادات |
|---|---|---|---|
| اختبارات الوحدة | كثيرة (100+) | سريعة (< 1 مللي ثانية) | بدون اعتمادات خارجية |
| اختبار التكامل | متوسطة (30-50) | متوسطة (10-100 مللي ثانية) | قاعدة بيانات اختبار |
| اختبار E2E | قليلة (5-10) | بطيئة (1-5 ثوانٍ) | بيئة كاملة |
(1) ▶ مثال: Fixture أساسي لـ TestClient
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.core.deps import get_db
# تجاوز اعتماد قاعدة البيانات
def get_test_db():
# استخدام SQLite في الذاكرة للاختبار
engine = create_async_engine("sqlite+aiosqlite:///test.db")
# ... إعداد الجلسة
yield session
# ... تنظيف
@pytest.fixture
def client():
app.dependency_overrides[get_db] = get_test_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: اختبار نقطة نهاية أساسية
# tests/test_health.py
def test_health_check(client):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "service" in data
def test_openapi_docs_available(client):
response = client.get("/docs")
assert response.status_code == 200
الناتج:
# تم تعريف الدالة بنجاح
4. عزل قاعدة بيانات الاختبار
(1) قاعدة بيانات منفصلة لكل اختبار
(1) ▶ مثال: Fixture قاعدة بيانات الاختبار
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.models import Base
TEST_DATABASE_URL = "sqlite+aiosqlite:///test_pricetracker.db"
@pytest.fixture(scope="function")
async def test_db():
# إنشاء قاعدة بيانات اختبار جديدة لكل اختبار
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = async_sessionmaker(engine, expire_on_commit=False)
async with TestSession() as session:
yield session
# حذف جميع الجداول بعد الاختبار
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: عميل اختبار غير متزامن
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
الناتج:
# تم تعريف الدالة بنجاح
(2) مقارنة استراتيجيات العزل
| الاستراتيجية | السرعة | العزل | قابلية التطبيق |
|---|---|---|---|
| إنشاء جداول جديدة لكل اختبار | بطيئة | معزول بالكامل | اختبار سلامة البيانات |
| إرجاع المعاملة | سريعة | جيد | معظم اختبارات التكامل |
| SQLite في الذاكرة | سريعة | جيد | اختبارات لا تعتمد على ميزات PostgreSQL |
5. تقنيات تجاوز الاعتماد
(1) استبدال المصادقة وقاعدة البيانات
(1) ▶ مثال: تجاوز اعتمادات المصادقة
# tests/conftest.py
from app.core.deps import get_current_user, get_db
def get_test_user():
"""مستخدم مصادق محاكي للاختبار"""
return {"id": 1, "email": "alice@test.com", "role": "admin", "subscription": "pro"}
@pytest.fixture
def auth_client(client):
# تجاوز المصادقة - لا حاجة لـ JWT حقيقي
app.dependency_overrides[get_current_user] = get_test_user
yield client
app.dependency_overrides.clear()
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: اختبار نقاط النهاية المحمية
# tests/test_products.py
def test_list_products_unauthorized(client):
"""بدون رمز مصادقة، يجب أن يعيد 401"""
response = client.get("/api/v1/products")
assert response.status_code == 401
def test_list_products_authorized(auth_client):
"""مع مصادقة محاكية، يجب أن يعيد 200"""
response = auth_client.get("/api/v1/products")
assert response.status_code == 200
def test_create_product(auth_client):
response = auth_client.post(
"/api/v1/products",
json={"name": "Widget", "category": "electronics", "base_price": 29.99},
)
assert response.status_code == 201
assert response.json()["name"] == "Widget"
الناتج:
# تم تعريف الدالة بنجاح
(3) ▶ مثال: تجاوز اختبار صلاحيات مستوى الاشتراك
def get_free_user():
return {"id": 2, "email": "free@test.com", "role": "user", "subscription": "free"}
def get_pro_user():
return {"id": 3, "email": "pro@test.com", "role": "user", "subscription": "pro"}
def test_bulk_import_free_user_limited(client):
"""المستخدمون المجانيون يمكنهم استيراد 1000 سعر كحد أقصى"""
app.dependency_overrides[get_current_user] = get_free_user
prices = [{"product_id": i, "price": 9.99} for i in range(1500)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 403
assert "limit" in response.json()["detail"].lower()
app.dependency_overrides.clear()
def test_bulk_import_pro_user(client):
"""المستخدمون Pro يمكنهم استيراد ما يصل إلى 100000 سعر"""
app.dependency_overrides[get_current_user] = get_pro_user
prices = [{"product_id": i, "price": 9.99} for i in range(5000)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 201
app.dependency_overrides.clear()
الناتج:
# تم تعريف الدالة بنجاح
6. مثال على مجموعة اختبارات كاملة
(1) ▶ مثال: اختبار وحدة لنماذج Pydantic
# tests/test_models.py
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreate, PriceCreate
def test_product_create_valid():
p = ProductCreate(name="Widget", category="electronics", base_price=29.99)
assert p.name == "Widget"
assert p.base_price == 29.99
def test_product_create_negative_price():
with pytest.raises(ValidationError) as exc:
ProductCreate(name="Widget", category="electronics", base_price=-1)
assert "greater than 0" in str(exc.value)
def test_product_create_empty_name():
with pytest.raises(ValidationError):
ProductCreate(name="", category="electronics", base_price=9.99)
def test_price_create_rounds_precision():
p = PriceCreate(product_id=1, price=9.999, currency="USD", source="test")
assert p.price == 10.0 # تقريب إلى منزلتين عشريتين
def test_price_create_invalid_currency():
with pytest.raises(ValidationError):
PriceCreate(product_id=1, price=9.99, currency="XYZ", source="test")
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: اختبار تكامل لعمليات CRUD
# tests/test_crud.py
import pytest
from fastapi.testclient import TestClient
def test_product_crud_lifecycle(auth_client):
# إنشاء
create_resp = auth_client.post(
"/api/v1/products",
json={"name": "Test Widget", "category": "electronics", "base_price": 19.99},
)
assert create_resp.status_code == 201
product_id = create_resp.json()["id"]
# قراءة
get_resp = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "Test Widget"
# تحديث
update_resp = auth_client.put(
f"/api/v1/products/{product_id}",
json={"name": "Updated Widget", "base_price": 24.99},
)
assert update_resp.status_code == 200
assert update_resp.json()["base_price"] == 24.99
# حذف
delete_resp = auth_client.delete(f"/api/v1/products/{product_id}")
assert delete_resp.status_code == 200
# التحقق من الحذف
get_resp2 = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp2.status_code == 404
الناتج:
# تم تعريف الدالة بنجاح
❓ أسئلة شائعة
function (تُعاد إنشاؤها لكل اختبار) هي الأكثر أمانًا؛ session (مشتركة عبر جلسة الاختبار بأكملها) هي الأسرع لكن العزل ضعيف. استخدم function لقاعدة البيانات و function لـ TestClient.AsyncClient.websocket_connect() لإنشاء اتصال، وأرسل واستقبل رسائل للتحقق من سلوكه.pytest-xdist للتنفيذ المتوازي (pytest -n auto)، واستخدم إرجاع المعاملة بدلاً من إنشاء الجداول، واستخدم اختبارات الوحدة بدلاً من اختبارات التكامل.dependency_overrides على اختبارات أخرى؟app.dependency_overrides.clear() بعد عبارة yield في fixture.📖 ملخص
- هرم الاختبار: اختبار الوحدة (متكرر وسريع) → اختبار التكامل (متوسط) → E2E (أقل تكرارًا وأبطأ)
- TestClient يُستخدم لاختبار نقاط النهاية المتزامنة البسيطة، بينما AsyncClient يُستخدم لاختبار المنطق غير المتزامن (مثل WebSocket)
- كل اختبار يستخدم قاعدة بيانات منفصلة؛ تدير fixtures الإنشاء والتنظيف
app.dependency_overridesيستبدل اعتمادات المصادقة وقاعدة البيانات؛ لا حاجة لـ JWT أو PG حقيقي- تغطية اختبار شاملة: التحقق من Pydantic (وحدة)، سير عمل CRUD (تكامل)، التحكم في الوصول (تكامل)
📝 تمارين
- تمرين أساسي (الصعوبة ⭐): استخدم TestClient لكتابة اختبار لنقطة النهاية
/healthللتحقق من أنها تعيد كود حالة 200 وبنية JSON الصحيحة. تلميح:TestClient(app)+client.get("/health") - تمرين متقدم (الصعوبة ⭐⭐): اكتب اختبارات دورة حياة CRUD—إنشاء واستعلام وتحديث وحذف المنتجات—وتحقق من رموز الحالة والبيانات المرجعة لكل خطوة. استخدم
dependency_overridesلتخطي المصادقة. تلميح:app.dependency_overrides[get_current_user] = mock_fn - تحدي (الصعوبة ⭐⭐⭐): نفذ مجموعة اختبارات كاملة: اختبارات التحقق من Pydantic (البيانات غير الصالحة تثير
ValidationError)، اختبارات المصادقة (401 بدون رمز، 403 للمستخدمين المجانيين، 200 للمستخدمين Pro)، واختبارات التقسيم (إرجاع صحيح لقيمskipوlimit). تلميح:pytest.raises(ValidationError)+ fixtures متعددةdependency_overrides
---|



