اختبار الوحدات في بايثون: دليل شامل مع أمثلة
اختبار بايثون باستخدام pytest، وTDD، وmocking، وcoverage
اختبار الوحدات يضمن أن كود بايثون الخاص بك يعمل بشكل صحيح ويستمر في العمل مع تطور مشروعك. هذا الدليل الشامل يغطي كل ما تحتاج معرفته حول اختبار الوحدات في بايثون، من المفاهيم الأساسية إلى التقنيات المتقدمة.

لماذا يهم اختبار الوحدات
يقدم اختبار الوحدات فوائد عديدة للمطورين في بايثون:
- اكتشاف الأخطاء مبكرًا: اكتشاف الأخطاء قبل وصولها إلى الإنتاج
- جودة الكود: يجبرك على كتابة كود قابل للاختبار ومُدول
- ثقة إعادة التصميم: قم بتغييرات بأمان مع العلم بأن الاختبارات ستكتشف التراجعات
- التوثيق: تعمل الاختبارات كتوثيق قابل للتنفيذ لكيفية عمل الكود
- تطوير أسرع: الاختبارات التلقائية أسرع من الاختبارات اليدوية
- تصميم أفضل: كتابة كود قابل للاختبار يؤدي إلى تصميم أفضل
فهم مبادئ اختبار الوحدات
ما هو اختبار الوحدة؟
يؤكد اختبار الوحدة على أصغر جزء قابل للاختبار من التطبيق (عادةً دالة أو طريقة) بشكل منعزل. يجب أن يكون:
- سريعًا: يُنفذ في ملي秒
- مستقلًا: مستقل عن الاختبارات الأخرى والأنظمة الخارجية
- قابلًا للتكرار: ينتج نفس النتائج كل مرة
- يؤكد بنفسه: يوضح بشكل واضح نجاحه أو فشله دون فحص يدوي
- مُعدًّا في الوقت المناسب: يتم كتابته قبل أو مع الكود
هرم الاختبار
يجب أن تتبع مجموعة الاختبارات الصحية هرم الاختبار:
/\
/ \ اختبارات E2E (قليل)
/____\
/ \ اختبارات التكامل (بعض)
/_____ ___\
/ \ اختبارات الوحدات (كثير)
/_____ __ ___\
تُشكل اختبارات الوحدات الأساس - فهي عديدة، سريعة، وتقدم ملاحظات سريعة.
مقارنة إطارات اختبار بايثون
unittest: الإطار المدمج
يحتوي مكتبة بايثون القياسية على unittest، وهو مستوحى من JUnit:
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
"""يتم تشغيله قبل كل اختبار"""
self.calc = Calculator()
def tearDown(self):
"""يتم تشغيله بعد كل اختبار"""
self.calc = None
def test_addition(self):
result = self.calc.add(2, 3)
self.assertEqual(result, 5)
def test_division_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calc.divide(10, 0)
if __name__ == '__main__':
unittest.main()
المزايا:
- مدمج، لا حاجة للاستبدال
- مناسب للمطورين المعتادين على إطارات xUnit
- مُحترم في البيئات المؤسسية
العيوب:
- تركيب معقد مع كود مكرر
- يتطلب فئات لتنظيم الاختبارات
- إدارة الموارد أقل مرونة
pytest: الخيار الحديث
يُعتبر pytest الإطار الثالث الشائع لاختبارات بايثون:
import pytest
def test_addition():
calc = Calculator()
assert calc.add(2, 3) == 5
def test_division_by_zero():
calc = Calculator()
with pytest.raises(ZeroDivisionError):
calc.divide(10, 0)
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
calc = Calculator()
assert calc.add(a, b) == expected
المزايا:
- تركيب بسيط وسهل مع بايثون
- نظام موارد قوي
- نظام إضافات ممتاز
- تقارير أخطاء أفضل
- اختبارات مُعلمة مدمجة
العيوب:
- يتطلب تثبيت
- أقل معرفة للمطورين من لغات أخرى
التركيب:
pip install pytest pytest-cov pytest-mock
كتابة أول اختبارات وحدة
لنقم ببناء مثال بسيط من الصفر باستخدام تطوير الاختبار أولاً (TDD). إذا كنت جديدًا في بايثون أو تحتاج إلى مرجع سريع للقواعد النحوية واللغوية، فراجع قائمة مختصرة لبايثون لمراجعة شاملة لأساسيات بايثون.
مثال: وظائف معالجة السلاسل
الخطوة 1: اكتب الاختبار أولاً (الأحمر)
أنشئ ملف test_string_utils.py:
import pytest
from string_utils import reverse_string, is_palindrome, count_vowels
def test_reverse_string():
assert reverse_string("hello") == "olleh"
assert reverse_string("") == ""
assert reverse_string("a") == "a"
def test_is_palindrome():
assert is_palindrome("racecar") == True
assert is_palindrome("hello") == False
assert is_palindrome("") == True
assert is_palindrome("A man a plan a canal Panama") == True
def test_count_vowels():
assert count_vowels("hello") == 2
assert count_vowels("HELLO") == 2
assert count_vowels("xyz") == 0
assert count_vowels("") == 0
الخطوة 2: اكتب الكود الأدنى لتمرير الاختبار (الأخضر)
أنشئ ملف string_utils.py:
def reverse_string(s: str) -> str:
"""عكس سلسلة"""
return s[::-1]
def is_palindrome(s: str) -> bool:
"""التحقق من أن السلسلة هي palindrome (تجاهل الحالة والمسافات)"""
cleaned = ''.join(s.lower().split())
return cleaned == cleaned[::-1]
def count_vowels(s: str) -> int:
"""عد الحروف الصوتية في السلسلة"""
return sum(1 for char in s.lower() if char in 'aeiou')
الخطوة 3: تشغيل الاختبارات
pytest test_string_utils.py -v
الناتج:
test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED
تقنيات اختبار متقدمة
استخدام الموارد لتنظيم الاختبارات
تُوفر الموارد تنظيمًا قابلًا لإعادة الاستخدام للاختبارات:
import pytest
from database import Database
@pytest.fixture
def db():
"""إنشاء قاعدة بيانات للاختبار"""
database = Database(":memory:")
database.create_tables()
yield database # تزود الاختبار
database.close() # تنظيف بعد الاختبار
@pytest.fixture
def sample_users(db):
"""إضافة مستخدمين نموذجيين إلى قاعدة البيانات"""
db.add_user("Alice", "alice@example.com")
db.add_user("Bob", "bob@example.com")
return db
def test_get_user(sample_users):
user = sample_users.get_user_by_email("alice@example.com")
assert user.name == "Alice"
def test_user_count(sample_users):
assert sample_users.count_users() == 2
نطاقات الموارد
تحكم في عمر الموارد باستخدام النطاقات:
@pytest.fixture(scope="function") # الافتراضي: تشغيل لكل اختبار
def func_fixture():
return create_resource()
@pytest.fixture(scope="class") # مرة واحدة لكل فئة اختبار
def class_fixture():
return create_resource()
@pytest.fixture(scope="module") # مرة واحدة لكل وحدة
def module_fixture():
return create_resource()
@pytest.fixture(scope="session") # مرة واحدة لكل جلسة اختبار
def session_fixture():
return create_expensive_resource()
محاكاة الاعتماديات الخارجية
استخدم المحاكاة لعزل الكود عن الاعتماديات الخارجية:
from unittest.mock import Mock, patch, MagicMock
import requests
class WeatherService:
def get_temperature(self, city):
response = requests.get(f"https://api.weather.com/{city}")
return response.json()["temp"]
# اختبار مع محاكاة
def test_get_temperature():
service = WeatherService()
# محاكاة الدالة requests.get
with patch('requests.get') as mock_get:
# تكوين استجابة المحاكاة
mock_response = Mock()
mock_response.json.return_value = {"temp": 72}
mock_get.return_value = mock_response
# اختبار
temp = service.get_temperature("Boston")
assert temp == 72
# التحقق من المكالمة
mock_get.assert_called_once_with("https://api.weather.com/Boston")
استخدام إضافة pytest-mock
تُوفر pytest-mock تركيبًا نظيفًا:
def test_get_temperature(mocker):
service = WeatherService()
# محاكاة باستخدام pytest-mock
mock_response = mocker.Mock()
mock_response.json.return_value = {"temp": 72}
mocker.patch('requests.get', return_value=mock_response)
temp = service.get_temperature("Boston")
assert temp == 72
اختبار متعدد المعلمات
اختبار عدة سيناريوهات بكفاءة:
import pytest
@pytest.mark.parametrize("input,expected", [
("", True),
("a", True),
("ab", False),
("aba", True),
("racecar", True),
("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
assert is_palindrome(input) == expected
@pytest.mark.parametrize("number,is_even", [
(0, True),
(1, False),
(2, True),
(-1, False),
(-2, True),
])
def test_is_even(number, is_even):
assert (number % 2 == 0) == is_even
اختبار الاستثناءات والتعامل مع الأخطاء
import pytest
def divide(a, b):
if b == 0:
raise ValueError("لا يمكن القسمة على الصفر")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="لا يمكن القسمة على الصفر"):
divide(10, 0)
def test_divide_by_zero_with_message():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "صفر" in str(exc_info.value).lower()
# اختبار أن لا استثناء يتم رفعه
def test_divide_success():
result = divide(10, 2)
assert result == 5.0
اختبار الكود غير المتزامن
اختبار الكود غير المتزامن ضروري للتطبيقات الحديثة في بايثون، خاصةً عند العمل مع واجهات برمجة التطبيقات، قواعد البيانات، أو خدمات الذكاء الاصطناعي. إليك كيفية اختبار الدوال غير المتزامنة:
import pytest
import asyncio
async def fetch_data(url):
"""دالة غير متزامنة لاسترداد البيانات"""
await asyncio.sleep(0.1) # محاكاة مكالمة واجهة برمجة التطبيقات
return {"status": "success"}
@pytest.mark.asyncio
async def test_fetch_data():
result = await fetch_data("https://api.example.com")
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
# محاكاة الدالة غير المتزامنة
mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
mocker.patch('module.fetch_data', mock_fetch)
result = await fetch_data("https://api.example.com")
assert result["status"] == "mocked"
لأمثلة عملية لاختبار الكود غير المتزامن مع خدمات الذكاء الاصطناعي، راجع دليلنا حول دمج Ollama مع بايثون.
تغطية الكود
قيّم كم من الكود تم اختباره:
باستخدام pytest-cov
# تشغيل الاختبارات مع التغطية
pytest --cov=myproject tests/
# إنشاء تقرير HTML
pytest --cov=myproject --cov-report=html tests/
# عرض الأسطر المفقودة
pytest --cov=myproject --cov-report=term-missing tests/
تكوين التغطية
أنشئ ملف .coveragerc:
[run]
source = myproject
omit =
*/tests/*
*/venv/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod
أفضل الممارسات للتغطية
- هدف لـ 80%+ من التغطية لمسارات الكود الحرجة
- لا تهتم بالحصول على 100% - ركّز على الاختبارات ذات المعنى
- اختبار الحالات الحدية وليس فقط الحالات السعيدة
- استبعد الكود المكرر من تقارير التغطية
- استخدم التغطية كدليل وليس هدفًا
تنظيم الاختبارات والهيكل المشروع
الهيكل المقترح
myproject/
├── myproject/
│ ├── __init__.py
│ ├── module1.py
│ ├── module2.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # الموارد المشتركة
│ ├── test_module1.py
│ ├── test_module2.py
│ ├── test_utils.py
│ └── integration/
│ ├── __init__.py
│ └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt
conftest.py للموارد المشتركة
# tests/conftest.py
import pytest
from myproject.database import Database
@pytest.fixture(scope="session")
def test_db():
"""قاعدة بيانات للاختبار على مستوى الجلسة"""
db = Database(":memory:")
db.create_schema()
yield db
db.close()
@pytest.fixture
def clean_db(test_db):
"""قاعدة بيانات نظيفة لكل اختبار"""
test_db.clear_all_tables()
return test_db
تكوين pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--cov=myproject
--cov-report=term-missing
--cov-report=html
markers =
slow: يشير إلى اختبارات بطيئة
integration: يشير إلى اختبارات التكامل
unit: يشير إلى اختبارات الوحدات
أفضل الممارسات لاختبار الوحدات
1. اتبع نمط AAA
تُرتيب-العمل-التأكيد يجعل الاختبارات واضحة:
def test_user_creation():
# ترتيب
username = "john_doe"
email = "john@example.com"
# العمل
user = User(username, email)
# التأكيد
assert user.username == username
assert user.email == email
assert user.is_active == True
2. اختبار واحد لكل اختبار (مبدأ، وليس قاعدة)
# جيد: اختبار مركّز
def test_user_username():
user = User("john_doe", "john@example.com")
assert user.username == "john_doe"
def test_user_email():
user = User("john_doe", "john@example.com")
assert user.email == "john@example.com"
# مقبول أيضًا: تأكيدات مرتبطة
def test_user_creation():
user = User("john_doe", "john@example.com")
assert user.username == "john_doe"
assert user.email == "john@example.com"
assert isinstance(user.created_at, datetime)
3. استخدام أسماء اختبارات وصفية
# سيء
def test_user():
pass
# جيد
def test_user_creation_with_valid_data():
pass
def test_user_creation_fails_with_invalid_email():
pass
def test_user_password_is_hashed_after_setting():
pass
4. اختبار الحالات الحدية والحدود
def test_age_validation():
# الحالات الصالحة
assert validate_age(0) == True
assert validate_age(18) == True
assert validate_age(120) == True
# الحالات الحدية
assert validate_age(-1) == False
assert validate_age(121) == False
# الحالات الحدية
with pytest.raises(TypeError):
validate_age("18")
with pytest.raises(TypeError):
validate_age(None)
5. الحفاظ على استقلالية الاختبارات
# سيء: تعتمد الاختبارات على الترتيب
counter = 0
def test_increment():
global counter
counter += 1
assert counter == 1
def test_increment_again(): # يفشل إذا تم تشغيله بمفرده
global counter
counter += 1
assert counter == 2
# جيد: استقلالية الاختبارات
def test_increment():
counter = Counter()
counter.increment()
assert counter.value == 1
def test_increment_multiple_times():
counter = Counter()
counter.increment()
counter.increment()
assert counter.value == 2
6. لا تختبر التفاصيل التنفيذية
# سيء: اختبار التفاصيل التنفيذية
def test_sort_uses_quicksort():
sorter = Sorter()
assert sorter.algorithm == "quicksort"
# جيد: اختبار السلوك
def test_sort_returns_sorted_list():
sorter = Sorter()
result = sorter.sort([3, 1, 2])
assert result == [1, 2, 3]
7. اختبار حالات الاستخدام الواقعية
عند اختبار المكتبات التي تتعامل مع تحويل البيانات أو معالجتها، ركّز على حالات الاستخدام الواقعية. على سبيل المثال، إذا كنت تعمل مع تحميل المواقع أو تحويل المحتوى، فراجع دليلنا حول تحويل HTML إلى Markdown باستخدام بايثون، الذي يحتوي على استراتيجيات اختبار ومقارنات أداء لمكتبات تحويل مختلفة.
دمج اختبارات CI/CD
مثال GitHub Actions
# .github/workflows/tests.yml
name: اختبارات
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.10, 3.11, 3.12]
steps:
- uses: actions/checkout@v3
- name: إعداد بايثون ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: تثبيت الاعتماديات
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: تشغيل الاختبارات
run: |
pytest --cov=myproject --cov-report=xml
- name: رفع التغطية
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
اختبار وظائف AWS Lambda
عند اختبار وظائف AWS Lambda أو التطبيقات بدون خادم، فكّر في استراتيجيات اختبار التكامل إلى جانب اختبارات الوحدات. دليلنا حول بناء AWS Lambda ثنائي النمط مع بايثون وTerraform يغطي استراتيجيات اختبار لتطبيقات بايثون بدون خادم، بما في ذلك كيفية اختبار مُعالجات Lambda، مستهلكي SQS، وتكاملات API Gateway.
قائمة أفضل الممارسات لاختبارات
- اكتب الاختبارات قبل أو مع الكود (TDD)
- الحفاظ على سرعة الاختبارات (< 1 ثانية لكل اختبار)
- جعل الاختبارات مستقلة ومُفصولة
- استخدام أسماء اختبارات وصفية
- اتبع نمط AAA (ترتيب-العمل-التأكيد)
- اختبر الحالات الحدية والأخطاء
- محاكاة الاعتماديات الخارجية
- الهدف من 80%+ من تغطية الكود
- تشغيل الاختبارات في أنبوب CI/CD
- مراجعة وتحسين الاختبارات بانتظام
- توثيق السيناريوهات المعقدة
- استخدام الموارد لتنظيم التكوينات الشائعة
- تعدد الاختبارات المتشابهة
- الحفاظ على الاختبارات بسيطة وقابلة للقراءة
الأنماط الشائعة للاختبار
اختبار الفئات
class TestUser:
@pytest.fixture
def user(self):
return User("john_doe", "john@example.com")
def test_username(self, user):
assert user.username == "john_doe"
def test_email(self, user):
assert user.email == "john@example.com"
def test_full_name(self, user):
user.first_name = "John"
user.last_name = "Doe"
assert user.full_name() == "John Doe"
اختبار مع ملفات مؤقتة
import pytest
from pathlib import Path
@pytest.fixture
def temp_file(tmp_path):
"""إنشاء ملف مؤقت"""
file_path = tmp_path / "test_file.txt"
file_path.write_text("test content")
return file_path
def test_read_file(temp_file):
content = temp_file.read_text()
assert content == "test content"
اختبار إنشاء الملفات
عند اختبار الكود الذي ينشئ ملفات (مثل PDFs، الصور، أو الوثائق)، استخدم المجلدات المؤقتة وتحقق من خصائص الملف:
@pytest.fixture
def temp_output_dir(tmp_path):
"""توفير مجلد إخراج مؤقت"""
output_dir = tmp_path / "output"
output_dir.mkdir()
return output_dir
def test_pdf_generation(temp_output_dir):
pdf_path = temp_output_dir / "output.pdf"
generate_pdf(pdf_path, content="Test")
assert pdf_path.exists()
assert pdf_path.stat().st_size > 0
لأمثلة شاملة لاختبار إنشاء PDF، راجع دليلنا حول إنشاء PDF في بايثون، الذي يغطي استراتيجيات اختبار لمكتبات PDF المختلفة.
اختبار مع Monkeypatch
def test_environment_variable(monkeypatch):
monkeypatch.setenv("API_KEY", "test_key_123")
assert os.getenv("API_KEY") == "test_key_123"
def test_module_attribute(monkeypatch):
monkeypatch.setattr("module.CONSTANT", 42)
assert module.CONSTANT == 42
روابط مفيدة وموارد
- توثيق pytest
- توثيق unittest
- إضافة pytest-cov
- إضافة pytest-mock
- توثيق unittest.mock
- اختبارات مبنية على المثال - Kent Beck
- اختبار بايثون مع pytest - Brian Okken
الموارد ذات الصلة
أساسيات بايثون والممارسات المثلى
- قائمة مختصرة لبايثون - مرجع شامل لقواعد بايثون، الهياكل البيانات، والأنماط الشائعة
اختبار حالات استخدام بايثون المحددة
- دمج Ollama مع بايثون - استراتيجيات اختبار لدمج LLM ودالة مكالمات غير متزامنة لخدمات الذكاء الاصطناعي
- تحويل HTML إلى Markdown مع بايثون - استراتيجيات اختبار لتحميل المواقع وتحويل مكتبات المحتوى
- إنشاء PDF في بايثون - استراتيجيات اختبار لإنشاء PDF وخرج الملفات
اختبارات بدون خادم وسحابة
- بناء AWS Lambda ثنائي النمط مع بايثون وTerraform - اختبارات Lambda، مستهلكي SQS، وتكاملات API Gateway
اختبار الوحدات هو مهارة ضرورية للمطورين في بايثون. سواء اخترت unittest أو pytest، المفتاح هو كتابة الاختبارات بشكل متسق، الحفاظ على صيانتها، ودمجها في تدفق العمل الخاص بك. ابدأ باختبارات بسيطة، تدريجيًا اعتماد تقنيات متقدمة مثل المحاكاة والموارد، واستخدم أدوات التغطية لتحديد الكود غير المختبر.