اختبار الوحدات في بايثون: دليل شامل مع أمثلة

اختبار بايثون باستخدام pytest، وTDD، وmocking، وcoverage

Page content

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

اختبار الوحدات في بايثون

لماذا يهم اختبار الوحدات

يقدم اختبار الوحدات فوائد عديدة للمطورين في بايثون:

  • اكتشاف الأخطاء مبكرًا: اكتشاف الأخطاء قبل وصولها إلى الإنتاج
  • جودة الكود: يجبرك على كتابة كود قابل للاختبار ومُدول
  • ثقة إعادة التصميم: قم بتغييرات بأمان مع العلم بأن الاختبارات ستكتشف التراجعات
  • التوثيق: تعمل الاختبارات كتوثيق قابل للتنفيذ لكيفية عمل الكود
  • تطوير أسرع: الاختبارات التلقائية أسرع من الاختبارات اليدوية
  • تصميم أفضل: كتابة كود قابل للاختبار يؤدي إلى تصميم أفضل

فهم مبادئ اختبار الوحدات

ما هو اختبار الوحدة؟

يؤكد اختبار الوحدة على أصغر جزء قابل للاختبار من التطبيق (عادةً دالة أو طريقة) بشكل منعزل. يجب أن يكون:

  • سريعًا: يُنفذ في ملي秒
  • مستقلًا: مستقل عن الاختبارات الأخرى والأنظمة الخارجية
  • قابلًا للتكرار: ينتج نفس النتائج كل مرة
  • يؤكد بنفسه: يوضح بشكل واضح نجاحه أو فشله دون فحص يدوي
  • مُعدًّا في الوقت المناسب: يتم كتابته قبل أو مع الكود

هرم الاختبار

يجب أن تتبع مجموعة الاختبارات الصحية هرم الاختبار:

           /\
          /  \     اختبارات 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

أفضل الممارسات للتغطية

  1. هدف لـ 80%+ من التغطية لمسارات الكود الحرجة
  2. لا تهتم بالحصول على 100% - ركّز على الاختبارات ذات المعنى
  3. اختبار الحالات الحدية وليس فقط الحالات السعيدة
  4. استبعد الكود المكرر من تقارير التغطية
  5. استخدم التغطية كدليل وليس هدفًا

تنظيم الاختبارات والهيكل المشروع

الهيكل المقترح

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

روابط مفيدة وموارد

الموارد ذات الصلة

أساسيات بايثون والممارسات المثلى

اختبار حالات استخدام بايثون المحددة

اختبارات بدون خادم وسحابة


اختبار الوحدات هو مهارة ضرورية للمطورين في بايثون. سواء اخترت unittest أو pytest، المفتاح هو كتابة الاختبارات بشكل متسق، الحفاظ على صيانتها، ودمجها في تدفق العمل الخاص بك. ابدأ باختبارات بسيطة، تدريجيًا اعتماد تقنيات متقدمة مثل المحاكاة والموارد، واستخدم أدوات التغطية لتحديد الكود غير المختبر.