فصل ۸: پروژه و حرفه‌ای شدن — پروژه‌ی پایانی، API، تست، دیباگ و کلینیک خطا

تست خودکار با pytest: assert ساده، fixture، tmp_path و parametrize

چرا تست بنویسیم وقتی برنامه «کار می‌کند»؟

امروز برنامه کار می‌کند. سه ماه بعد که فیلد تازه‌ای به Order اضافه می‌کنید، از کجا می‌دانید next_code یا تبدیل ارقام فارسی خراب نشده؟ تست خودکار یعنی کدی که کد شما را امتحان می‌کند و در یک ثانیه می‌گوید چیزی شکسته یا نه. pytest محبوب‌ترین ابزار تست پایتون است چون تست در آن فقط یک تابع با assert ساده است.

python -m pip install pytest
python -m pytest -q

اولین فایل تست

pytest فایل‌هایی را که نامشان با test_ شروع می‌شود و تابع‌های test_* داخل آن‌ها را خودکار پیدا و اجرا می‌کند:

# tests/test_store.py
import pytest

from store import Order, load_orders, next_code, save_orders, toman


def make(code="KSH-101", **changes):
    data = dict(code=code, customer="مریم کاشانی", design="افشان", size="3x4", price=57_000_000)
    data.update(changes)
    return Order(**data)


def test_next_code_on_empty_list():
    assert next_code([]) == "KSH-101"


def test_next_code_uses_max_not_last():
    assert next_code([make("KSH-105"), make("KSH-102")]) == "KSH-106"


def test_zero_price_is_rejected():
    with pytest.raises(ValueError, match="قیمت"):
        make(price=0)


def test_missing_file_gives_empty_list(tmp_path):
    assert load_orders(tmp_path / "nothing.json") == []


def test_save_and_load_round_trip(tmp_path):
    path = tmp_path / "orders.json"
    save_orders([make(), make("KSH-102", status="ready")], path)
    loaded = load_orders(path)
    assert [o.code for o in loaded] == ["KSH-101", "KSH-102"]
    assert loaded[1].status == "ready"
    assert "مریم" in path.read_text(encoding="utf-8")   # فارسی، نه م


@pytest.mark.parametrize("text, expected", [
    ("57000000", 57_000_000),
    ("۵۷٬۰۰۰٬۰۰۰", 57_000_000),
    ("31,500,000", 31_500_000),
])
def test_toman_accepts_persian_and_commas(text, expected):
    assert toman(text) == expected

سه ابزار اصلی

  • assert ساده: pytest عبارت را تحلیل می‌کند و در شکست، مقدار دو طرف را نشان می‌دهد؛ دیگر لازم نیست assertEqual حفظ کنید.
  • fixture: آرگومانی مثل tmp_path را pytest خودش می‌سازد و تحویل می‌دهد؛ این یکی پوشه‌ی موقت تازه‌ای برای هر تست است، پس تست‌ها هرگز به orders.json واقعی دست نمی‌زنند. fixture خودتان را با @pytest.fixture می‌سازید.
  • parametrize: یک تست، چند ورودی؛ هر ردیف یک تست جدا در گزارش است.

پیکربندی و اجرای هدفمند

# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
دستورکار
python -m pytest -qاجرای همه، خروجی کوتاه
python -m pytest -k tomanفقط تست‌هایی که نامشان toman دارد
python -m pytest -x --lfتوقف در اولین شکست؛ فقط شکست‌خورده‌های دفعه‌ی قبل
python -m pytest -sنمایش print ها (pytest آن‌ها را پنهان می‌کند)

نکته‌هایی که کمتر کسی می‌داند

  • اگر pytest خالی ModuleNotFoundError: No module named 'store' داد ولی python -m pytest کار کرد، دلیلش این است که فقط حالت دوم پوشه‌ی جاری را به sys.path اضافه می‌کند؛ تنظیم pythonpath بالا هر دو را یکسان می‌کند.
  • آرگومان match در pytest.raises یک الگوی regex است و با re.search بررسی می‌شود؛ برای پیامی که پرانتز یا نقطه دارد از re.escape استفاده کنید.
  • تست‌ها باید مستقل باشند: اگر یک تست فقط وقتی پاس می‌شود که تست دیگری قبلش اجرا شده باشد، روزی که با -k تنها اجرایش کنید، بی‌دلیل می‌شکند.
  • fixture ای که به‌جای return از yield استفاده کند، کد بعد از yield را پس از پایان تست اجرا می‌کند؛ جای مناسب پاک‌سازی، حتی وقتی تست شکست خورده.
  • capsys یک fixture آماده است که خروجی print را می‌گیرد: main(["report"]) را صدا بزنید و capsys.readouterr().out را assert کنید؛ رابط خط فرمان هم تست‌پذیر است.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.