چرا تست بنویسیم وقتی برنامه «کار میکند»؟
امروز برنامه کار میکند. سه ماه بعد که فیلد تازهای به 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 کنید؛ رابط خط فرمان هم تستپذیر است.