~/icsd.ir — bash
SYSTEM_ONLINE

Testing – تست با pytest

تست‌نویسی یکی از مهم‌ترین مهارت‌های یک برنامه‌نویس حرفه‌ای است. در این فصل با unittest (داخل پایتون)، pytest (محبوب‌ترین فریم‌ورک)، fixtures، parametrize، mocks و coverage آشنا می‌شویم.

تست‌نویسی یکی از مهم‌ترین مهارت‌های یک برنامه‌نویس حرفه‌ای است. در این فصل با unittest (داخل پایتون)، pytest (محبوب‌ترین فریم‌ورک)، fixtures، parametrize، mocks و coverage آشنا می‌شویم.

چرا تست می‌نویسیم؟

  • اطمینان از درستی کد قبل از deploy
  • کاتچ کردن regression‌ها (وقتی تغییرات جدید قبلی‌ها را خراب می‌کنند)
  • refactoring با اطمینان
  • مستندسازی رفتار کد
  • طراحی بهتر (TDD)

unittest – فریم‌ورک داخلی

# calculator.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("تقسیم بر صفر مجاز نیست")
    return a / b

# test_calculator.py
import unittest
from calculator import add, divide

class TestCalculator(unittest.TestCase):
    def test_add_positive(self):
        self.assertEqual(add(2, 3), 5)
    
    def test_add_negative(self):
        self.assertEqual(add(-1, -1), -2)
    
    def test_divide(self):
        self.assertEqual(divide(10, 2), 5)
    
    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(10, 0)
    
    def test_divide_message(self):
        with self.assertRaises(ValueError) as ctx:
            divide(10, 0)
        self.assertIn("تقسیم بر صفر", str(ctx.exception))

if __name__ == "__main__":
    unittest.main()
python -m unittest test_calculator.py
# یا کشف خودکار همه تست‌ها
python -m unittest discover

setUp و tearDown

class TestDatabase(unittest.TestCase):
    def setUp(self):
        """قبل از هر تست اجرا می‌شود"""
        self.db = Database(":memory:")
        self.db.connect()
    
    def tearDown(self):
        """بعد از هر تست"""
        self.db.close()
    
    def test_insert(self):
        self.db.insert("users", {"name": "علی"})
        self.assertEqual(self.db.count("users"), 1)
    
    def test_delete(self):
        self.db.insert("users", {"name": "علی"})
        self.db.delete("users", 1)
        self.assertEqual(self.db.count("users"), 0)

pytest – راه مدرن

pip install pytest pytest-cov pytest-mock

تفاوت بزرگ pytest: نوشتن تست با assert ساده، بدون نیاز به کلاس:

# test_calculator.py
from calculator import add, divide
import pytest

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_divide():
    assert divide(10, 2) == 5
    assert divide(7, 2) == 3.5

def test_divide_by_zero():
    with pytest.raises(ValueError, match="تقسیم بر صفر"):
        divide(10, 0)
pytest                          # اجرای همه تست‌ها
pytest test_calculator.py       # یک فایل خاص
pytest -v                       # خروجی verbose
pytest -k "divide"              # فقط تست‌هایی با اسم divide
pytest -x                       # توقف در اولین خطا
pytest --tb=short               # traceback کوتاه
pytest -q                       # خروجی کم

Fixtures – راه‌اندازی تست‌ها

import pytest

@pytest.fixture
def sample_data():
    """داده تست - در هر تست fresh ساخته می‌شود"""
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

def test_max(sample_data):
    assert max(sample_data) == 5

# fixture با cleanup
@pytest.fixture
def temp_file(tmp_path):
    f = tmp_path / "test.txt"
    f.write_text("hello")
    yield f
    # cleanup خودکار با tmp_path

def test_read(temp_file):
    assert temp_file.read_text() == "hello"

scope – دامنه fixture

@pytest.fixture(scope="function")  # پیش‌فرض - برای هر تست
def db():
    return create_db()

@pytest.fixture(scope="class")      # یکبار برای کل کلاس
def heavy_object():
    return expensive_setup()

@pytest.fixture(scope="module")     # یکبار برای کل فایل
def shared_resource():
    return create_resource()

@pytest.fixture(scope="session")    # یکبار برای کل اجرا
def database_connection():
    conn = connect_db()
    yield conn
    conn.close()

conftest.py – اشتراک fixture‌ها

# conftest.py - در ریشه پروژه یا تست‌ها
import pytest

@pytest.fixture
def admin_user():
    return User("admin", role="admin")

@pytest.fixture
def regular_user():
    return User("alice", role="user")

# هر فایل تست در همان دایرکتوری یا زیردایرکتوری‌ها
# می‌تواند بدون import از این fixture‌ها استفاده کند

parametrize – تست با چند ورودی

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

# parametrize با id
@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("python", "PYTHON"),
    ("", ""),
], ids=["lowercase", "another_word", "empty"])
def test_upper(input, expected):
    assert input.upper() == expected

# چند parametrize - ضرب دکارتی
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x, y):
    # 6 تست: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)
    assert x < y

Markers – برچسب‌گذاری

import pytest

@pytest.mark.slow
def test_heavy_computation():
    # تست کند
    pass

@pytest.mark.skip(reason="هنوز پیاده نشده")
def test_future_feature():
    pass

@pytest.mark.skipif(sys.version_info < (3, 11),
                     reason="نیاز به پایتون 3.11+")
def test_taskgroup():
    pass

@pytest.mark.xfail(reason="باگ شناخته شده")
def test_known_bug():
    assert False  # تست fail می‌شود اما با xfail نمی‌فتد

# اجرای فقط تست‌های slow
# pytest -m slow

# اجرای همه به‌جز slow
# pytest -m "not slow"

# ثبت marker در pyproject.toml یا pytest.ini
[tool.pytest.ini_options]
markers = [
    "slow: تست‌های کند",
    "integration: تست‌های یکپارچگی",
]

Mocking – شبیه‌سازی

وقتی نمی‌خواهیم به API/دیتابیس واقعی متصل شویم:

from unittest.mock import Mock, MagicMock, patch

# Mock ساده
mock = Mock()
mock.return_value = 42
print(mock())  # 42
print(mock.called)        # True
print(mock.call_count)    # 1

# تنظیم متدها
mock = Mock()
mock.get_user.return_value = {"id": 1, "name": "علی"}
print(mock.get_user(123))   # {"id": 1, "name": "علی"}

# بررسی فراخوانی
mock.method(1, 2, key="value")
mock.method.assert_called_once_with(1, 2, key="value")

# MagicMock - magic methods را هم پیاده می‌کند
mock = MagicMock()
len(mock)         # کار می‌کند
mock["key"]       # کار می‌کند
for x in mock: pass  # کار می‌کند

patch – جایگزین کردن

from unittest.mock import patch

# myapp.py
import requests

def get_user_name(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()["name"]

# test_myapp.py
@patch("myapp.requests.get")
def test_get_user_name(mock_get):
    # تنظیم return value
    mock_get.return_value.json.return_value = {"name": "علی"}
    
    result = get_user_name(123)
    
    assert result == "علی"
    mock_get.assert_called_once_with("https://api.example.com/users/123")

# با context manager
def test_get_user_name_v2():
    with patch("myapp.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"name": "سارا"}
        assert get_user_name(456) == "سارا"

pytest-mock – راحت‌تر

# با pytest-mock، fixture mocker در دسترس است
def test_with_mocker(mocker):
    mock_get = mocker.patch("myapp.requests.get")
    mock_get.return_value.json.return_value = {"name": "محمد"}
    
    assert get_user_name(789) == "محمد"

پوشش کد (Coverage)

pip install pytest-cov

# اجرا با coverage
pytest --cov=myapp

# گزارش HTML
pytest --cov=myapp --cov-report=html
# باز کردن htmlcov/index.html

# گزارش term با missing lines
pytest --cov=myapp --cov-report=term-missing

# fail اگر coverage کمتر از 80% باشد
pytest --cov=myapp --cov-fail-under=80

پیکربندی .coveragerc

[run]
source = myapp
omit =
    */tests/*
    */migrations/*
    */__init__.py

[report]
exclude_lines =
    pragma: no cover
    raise NotImplementedError
    if __name__ == .__main__.:

TDD – Test-Driven Development

چرخه: Red → Green → Refactor

  1. Red: تست fail بنویس
  2. Green: ساده‌ترین کد برای pass شدن
  3. Refactor: کد را تمیز کن
# 1. Red - تست
def test_calculate_discount():
    assert calculate_discount(1000, 10) == 900
    assert calculate_discount(500, 50) == 250
    assert calculate_discount(100, 0) == 100

# 2. Green - حداقل کد
def calculate_discount(price, percentage):
    return price * (1 - percentage / 100)

# 3. Refactor - بهبود
def calculate_discount(price: float, percentage: float) -> float:
    """محاسبه قیمت با تخفیف
    
    Args:
        price: قیمت اصلی
        percentage: درصد تخفیف (0-100)
    """
    if not 0 <= percentage <= 100:
        raise ValueError("درصد باید بین 0 تا 100 باشد")
    return price * (1 - percentage / 100)

Unit vs Integration

نوع تست می‌کند سرعت
Unit یک تابع/کلاس بدون وابستگی میلی‌ثانیه
Integration چند بخش با هم (DB، API) ثانیه
E2E کل سیستم دقیقه
# Unit test
def test_calculate_total():
    assert calculate_total([10, 20, 30]) == 60

# Integration test
@pytest.mark.integration
def test_save_order_to_db(db):
    order = Order(items=[Item("a", 100)])
    save_order(db, order)
    saved = db.query(Order).first()
    assert saved.total == 100

بهترین شیوه‌ها

  • اسم تست‌ها توصیفی: test_user_can_login_with_valid_password
  • هر تست یک چیز را تست کند (Single Assertion)
  • ساختار AAA: Arrange، Act، Assert
  • تست‌ها مستقل از هم باشند (ترتیب اجرا مهم نباشد)
  • fixture‌ها بهتر از setUp/tearDown
  • mock فقط مرز سیستم (DB، API، فایل‌سیستم)
  • coverage هدف نیست – کیفیت تست مهم‌تر است

جمع‌بندی

  • unittest داخل پایتون، pytest محبوب‌تر و قدرتمندتر
  • fixtures برای راه‌اندازی و cleanup
  • parametrize برای تست با چند ورودی
  • Mocking برای جدا کردن از وابستگی‌ها
  • Coverage برای اندازه‌گیری
  • TDD: Red → Green → Refactor

نمایش سایت

رنگ سایت
حالت نمایش
اندازهٔ متن
خوانایی

این تنظیمات فقط روی مرورگر شما ذخیره می‌شود.