تست را از قانون محصول شروع کنید

فرض کنید ارسال سفارش از مبلغ یک میلیون تومان رایگان است. یک تست با مبلغ دو میلیون نشان می‌دهد مسیر رایگان کار می‌کند، ولی درباره خود مرز چیزی نمی‌گوید. اشتباه رایج استفاده از بزرگ‌تر به جای بزرگ‌تر یا مساوی است؛ چنین خطایی دقیقاً برای مشتری روی آستانه رخ می‌دهد. برای انتخاب داده تست، اول قانون را با مثال‌های قابل محاسبه بنویسید و بعد سراغ ابزار بروید.

parametrize در pytest یک تابع آزمون را با چند مجموعه ورودی اجرا می‌کند. مزیت آن صرفاً کم‌شدن تکرار کد نیست؛ هر مورد با نام مستقل گزارش می‌شود و شکست یک سناریو میان بقیه پنهان نمی‌ماند. در نمونه، مبلغ صفر، درست زیر مرز، روی مرز و بالای مرز را بررسی می‌کنیم. برای مبلغ منفی نیز رفتار جداگانه تعریف شده است تا ورودی نامعتبر تصادفاً به یک هزینه عادی تبدیل نشود.

انتخاب سناریوهای مرزی و نام‌گذاری آن‌ها

انتخاب عدد صحیح برای مبلغ، فرض آموزشی این مقاله است. پیش از انتقال کد به محصول، واحد پول را روشن کنید: ریال و تومان نباید بدون تبدیل صریح مخلوط شوند. اگر محاسبه به جزء پول یا نرخ مالیات وابسته است، درباره Decimal و قاعده گردکردن تصمیم بگیرید. تست باید نمونه‌ای از همان قرارداد باشد؛ کپی‌کردن فرمول تابع در تست ممکن است همان اشتباه را دو بار تکرار کند.

نام سناریوهایی مثل at-threshold هنگام شکست CI از شماره بی‌معنا مفیدتر است. داده‌های هر تست نیز باید مستقل بمانند. اگر یک لیست مشترک را داخل تابع تغییر دهید، اجرای بعدی ممکن است از وضعیت قبلی اثر بگیرد. برای آزمون فایل از مسیر موقت و برای زمان از ورودی قابل کنترل استفاده کنید؛ وابستگی به ساعت واقعی و ترتیب اجرا، خطاهای ناپایدار و پرهزینه می‌سازد.

برای بررسی قدرت این تست، موقتاً عملگر >= را به > تغییر دهید و ببینید سناریوی روی مرز شکست می‌خورد یا نه. سپس کد را برگردانید. این تجربه کوچک به دانش‌آموز نشان می‌دهد سبزبودن تست کافی نیست؛ تست باید خطای مرتبط را پیدا کند. این کار را در محیط آموزشی یا شاخه آزمایش انجام دهید و تغییر خراب را منتشر نکنید.

نمونه کد و روش بررسی

پیش‌نیاز و خروجی مورد انتظار در توضیح مثال آمده است. ابتدا نمونه را در محیط آزمایشی اجرا کنید و سپس آن را با داده و محدودیت‌های پروژه خود تطبیق دهید.

1. Python و pytest؛ فایل test_shipping.py

import pytest

def shipping_cost(amount):
    if amount < 0:
        raise ValueError("amount must be non-negative")
    return 0 if amount >= 1_000_000 else 50_000

@pytest.mark.parametrize("amount, expected", [
    pytest.param(0, 50_000, id="zero"),
    pytest.param(999_999, 50_000, id="below-threshold"),
    pytest.param(1_000_000, 0, id="at-threshold"),
    pytest.param(1_000_001, 0, id="above-threshold"),
])
def test_shipping_cost(amount, expected):
    assert shipping_cost(amount) == expected

def test_negative_amount():
    with pytest.raises(ValueError):
        shipping_cost(-1)

پس از python -m pip install pytest، فایل را با python -m pytest -q test_shipping.py اجرا کنید؛ انتظار 5 passed داریم. این مثال عدد صحیح و واحد تومان را فرض می‌کند و اعتبارسنجی نوع ورودی را به لایه فراخواننده واگذار کرده است.

2. جداسازی قانون محصول در shipping.py

برای استفاده بیرون از تست، منطق را در shipping.py قرار دهید. این نسخه علاوه بر مقدار، نوع ورودی را هم بررسی می‌کند. bool در پایتون زیرنوع int است، اما True مبلغ سفارش معناداری نیست و صریحاً رد می‌شود.

# shipping.py
FREE_FROM = 1_000_000
STANDARD_FEE = 50_000

def shipping_cost(amount):
    if isinstance(amount, bool) or not isinstance(amount, int):
        raise TypeError("amount must be an integer currency value")
    if amount < 0:
        raise ValueError("amount must be non-negative")
    return 0 if amount >= FREE_FROM else STANDARD_FEE

واحد قرارداد همان تومان و مبلغ صحیح است. اگر محصول به عدد اعشاری یا ریال نیاز دارد، قرارداد و تست را هماهنگ تغییر دهید. ثابت‌ها در یک محل‌اند، ولی انتظار تست را از همین ثابت‌ها نسازید؛ در آن صورت تغییر اشتباه هم‌زمانِ آستانه ممکن است از دید تست پنهان بماند.

3. آزمون قرارداد، انواع نامعتبر و مقدار منفی

این فایل کنار shipping.py قرار می‌گیرد. مرز و انتظارها از قانون محصول آمده‌اند و فهرست ورودی‌های نامعتبر، تبدیل ضمنی رشته یا bool به پول را عمداً رد می‌کند. هر ورودی یک مورد جدا در گزارش pytest است.

# test_shipping_contract.py
import pytest
from shipping import shipping_cost

@pytest.mark.parametrize("amount, expected", [
    (0, 50_000), (999_999, 50_000),
    (1_000_000, 0), (1_000_001, 0),
])
def test_boundary_contract(amount, expected):
    assert shipping_cost(amount) == expected

@pytest.mark.parametrize("amount", [None, "1000000", True, 1.5])
def test_rejects_non_integer_values(amount):
    with pytest.raises(TypeError):
        shipping_cost(amount)

@pytest.mark.parametrize("amount", [-1, -100_000])
def test_rejects_negative_values(amount):
    with pytest.raises(ValueError):
        shipping_cost(amount)

فقط این فایل باید 10 passed بدهد. اگر تابع را موقتاً به > تغییر دهید، مورد مبلغ روی مرز شکست می‌خورد. تغییر آزمایشی را برگردانید؛ نتیجه قرمز این مرحله، نشانه حساس‌بودن تست به خطای هدف است.

4. اجرای انتخابی و ساخت گزارش قابل نگهداری

فرمان‌ها را از پوشه دو فایل اجرا کنید. استفاده از python -m کمک می‌کند pytest همان مفسر فعال اجرا شود. در مخزن واقعی، نسخه وابستگی‌ها را در ابزار مدیریت پروژه ثبت کنید؛ این دستور نصب برای محیط آموزشی است.

python -m pip install pytest
python -m pytest -q test_shipping_contract.py
python -m pytest -q test_shipping_contract.py -k boundary
python -m pytest test_shipping_contract.py --maxfail=1 --junitxml=reports/shipping.xml

اجرای کامل ۱۰ مورد و انتخاب boundary چهار مورد را می‌سنجد. خروج غیرصفر باید مرحله CI را ناموفق کند؛ فایل JUnit برای نگهداری نتیجه است و شکست تست را به موفقیت تبدیل نمی‌کند. گزارش حاوی ورودی حساس را در artifact عمومی منتشر نکنید.

یک تست خوب چه چیزی را ثابت می‌کند؟

این مجموعه فقط قانون هزینه ارسال را بررسی می‌کند و درباره اتصال درگاه، رابط کاربر یا عملکرد زیر بار ادعایی ندارد. وقتی رفتار محصول تغییر کرد، ابتدا قرارداد جدید را با مالک محصول روشن کنید و بعد سناریوها را اصلاح کنید. مجموعه‌ای کوچک از مثال‌های معنی‌دار معمولاً از صدها داده تصادفی بی‌هدف قابل نگهداری‌تر است؛ معیار انتخاب، ریسک و مرز تصمیم است، نه صرفاً تعداد تست.

چک‌لیست اجرای عملی

  • واحد پول و قاعده مرز را صریح بنویسید.
  • زیر مرز، روی مرز و بالای مرز تست شوند.
  • ورودی نامعتبر رفتار مشخص داشته باشد.
  • تست باید تغییر اشتباه عملگر را تشخیص دهد.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.