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
- Red: تست fail بنویس
- Green: سادهترین کد برای pass شدن
- 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