وقتی برای اولین بار یاد می‌گیرید که چطور کدهای اصلی برنامه را با ابزارهای تست‌نویسی پایتون ارزیابی کنید، همه چیز جذاب است. اما داستان از جایی فرسایشی می‌شود که مجبور باشید یک تابع را با ده کپی مختلف و مقادیر متفاوت بسنجید.

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

نوشتن کدهای کپی‌پیست شده نه تنها حجم فایل‌های پروژه را بی‌دلیل بالا می‌برد، بلکه نگهداری و عیب‌یابی کدهای مالی را به یک کابوس تبدیل می‌کند. اینجاست که با جادوی تست سناریوهای تکراری در پایتون با parametrize آشنا می‌شوید. فریم‌ورک pytest یک دکوراتور قدرتمند به نام @pytest.mark.parametrize در اختیارتان می‌گذارد که به شما اجازه می‌دهد فقط یک تابع تست هوشمند بنویسید، اما یک لیست کامل از ورودی‌ها و خروجی‌های مختلف را به آن تزریق کنید.

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

پیاده‌سازی متد کسر موجودی (Withdraw) در کلاس اصلی

قبل از اینکه سراغ بهینه‌سازی تست‌ها برویم، باید منطق عملیاتی این ویژگی را در کدهای اصلی پروژه پیاده کنیم. فایل wallet.py را که در درس قبلی ساختیم باز کنید. می‌خواهیم یک متد جدید به نام withdraw را به کلاس DigitalWallet اضافه کنیم تا فرآیند کسر موجودی از حساب کاربر به درستی مدیریت شود.

این خطوط را به انتهای کلاس خود اضافه کنید:

def withdraw(self, amount: float):
        if amount <= 0:
            raise ValueError("مبلغ برداشت باید بیشتر از صفر باشد.")
        if amount > self.balance:
            raise ValueError("موجودی حساب کافی نیست.")
        self.balance -= amount

بیایید جزییات این منطق مالی را با هم بررسی کنیم. این تابع دو لایه امنیتی مهم دارد که قبل از کسر هرگونه رقمی، آن‌ها را ارزیابی می‌کند. در لایه اول، شرط amount <= 0 بررسی می‌کند که مبلغ ورودی حتماً یک عدد مثبت باشد. اگر کاربری تلاش کند عدد صفر یا منفی وارد کند، سیستم بلافاصله یک خطای لایه اپلیکیشن از نوع ValueError پرتاب می‌کند و جلوی پیشروی کد را می‌گیرد.

در لایه دوم، امنیت دارایی کاربر بررسی می‌شود. شرط amount > self.balance جلوی برداشت‌های غیرمجاز را می‌گیرد. اگر مبلغ درخواستی حتی یک ریال بیشتر از متغیر موجودی باشد، برنامه با صادر کردن یک خطای دیگر، عملیات را متوقف می‌کند.

تنها زمانی که مبلغ ورودی از هر دو فیلتر امنیتی به سلامت عبور کند، پایتون خط پایانی یعنی self.balance -= amount را اجرا می‌کند. این متد، شاکله اصلی کدهای مالی ما برای بخش برداشت است. فایل را ذخیره کنید تا در بخش بعدی ببینیم چطور می‌توانیم سناریوهای تکراری و مبالغ مختلف ورودی را روی این تابع تست کنیم.

چالش کپی‌پیست: چرا نوشتن تست‌های تکراری یک اشتباه بزرگ است؟

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

اگر بخواهیم با همان دست فرمان قبلی جلو برویم، فایل test_wallet.py به چنین وضعیتی دچار می‌شود:

from wallet import DigitalWallet

def test_withdraw_small_amount():
    wallet = DigitalWallet(owner="سهراب", balance=100.0)
    wallet.withdraw(10.0)
    assert wallet.balance == 90.0

def test_withdraw_large_amount():
    wallet = DigitalWallet(owner="سهراب", balance=100.0)
    wallet.withdraw(90.0)
    assert wallet.balance == 10.0

def test_withdraw_full_balance():
    wallet = DigitalWallet(owner="سهراب", balance=100.0)
    wallet.withdraw(100.0)
    assert wallet.balance == 0.0

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

به این شیوه، تولید کدهای تکراری یا کدهای کثیف (Anti-pattern) می‌گویند. فرض کنید فردا تصمیم بگیرید نام کلاس اصلی را تغییر دهید یا یک پارامتر جدید به متد سازنده اضافه کنید؛ در این صورت باید ده ها تابع مختلف را به صورت دستی ویرایش کنید. این کار زمان شما را هدر می‌دهد و احتمال بروز خطاهای انسانی را در پوشه تست به شدت بالا می‌برد.

تست سناریوهای تکراری در پایتون با parametrize دقیقاً برای حل همین چالش ابداع شده است. در بخش بعدی می‌بینیم که چطور این حجم از خطوط تکراری را فشرده می‌کنیم و به یک تابع واحد و هوشمند تبدیل می‌کنیم.

ورود به دنیای حرفه‌ای‌ها با دکوراتور pytest.mark.parametrize

برای خلاص شدن از شر کدهای کپی‌پیست شده، فریم‌ورک یک ابزار تخصصی به نام دکوراتور @pytest.mark.parametrize را در اختیارتان می‌گذارد. این ویژگی به شما اجازه می‌دهد ماتریس داده‌های ورودی و خروجی را از منطق خود تابع مستقل کنید. با این روش، شما فقط یک بار ساختار سناریو را تعریف می‌کنید و بقیه کارها را به مکانیزم هوشمند ابزار می‌سپارید.

طرز کار این دکوراتور بسیار ساده و بر پایه ساختار تاپل‌ها (Tuples) در پایتون است. برای استفاده از آن، ابتدا باید پکیج اصلی را در بالای فایل ایمپورت کنید. ساختار کلی چیدمان این ابزار به شکل زیر است:

import pytest

@pytest.mark.parametrize("نام_متغیر_اول, نام_متغیر_دوم", [
    (ورودی_اول_۱, ورودی_دوم_۱),
    (ورودی_اول_۲, ورودی_دوم_۲),
])
def test_example(نام_متغیر_اول, نام_متغیر_دوم):
    # کدهای تست

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

آرگومان دوم، یک لیست پایتونی است. درون این لیست، به تعداد سناریوهایی که دارید، تاپل قرار می‌دهید. هر تاپل شامل مقادیر واقعی است که می‌خواهید به ترتیب در متغیرها جای‌گذاری شوند.

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

بازنویسی سناریوی واریز و برداشت با ورودی‌های چندگانه

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

کدهای زیر را جایگزین کدهای قبلی فایل تست کنید:

import pytest
from wallet import DigitalWallet

@pytest.mark.parametrize("initial_balance, withdraw_amount, expected_balance", [
    (100.0, 10.0, 90.0),   # برداشت خرد
    (100.0, 90.0, 10.0),   # برداشت عمده
    (100.0, 100.0, 0.0),   # تخلیه کامل حساب
])
def test_wallet_withdraw_various_amounts(initial_balance, withdraw_amount, expected_balance):
    # ۱. ساخت نمونه با دارایی اولیه متفاوت
    wallet = DigitalWallet(owner="سهراب", balance=initial_balance)
    
    # ۲. اجرای متد کسر موجودی
    wallet.withdraw(withdraw_amount)
    
    # ۳. صحت‌سنجی نهایی مانده حساب
    assert wallet.balance == expected_balance

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

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

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

اجرای تست‌های موازی و تحلیل خروجی تفکیک‌شده در ترمینال

برای دیدن نتیجه این معماری جدید، ترمینال را باز کنید و در دایرکتوری اصلی پروژه، دستور زیر را به همراه یک فلگ کاربردی اجرا کنید:

pytest -v

استفاده از فلگ -v (مخفف verbose) در خط فرمان باعث می‌شود که فریم‌ورک جزئیات هر دیتاست را به صورت کاملاً تفکیک‌شده نمایش دهد. خروجی ترمینال شما چنین شکلی به خود می‌گیرد:

============================== test session starts ==============================
platform linux -- Python 3.11.5, pytest-8.3.2
rootdir: /home/user/digital_wallet_project
collected 3 items

tests/test_wallet.py::test_wallet_withdraw_various_amounts[100.0-10.0-90.0] PASSED [ 33%]
tests/test_wallet.py::test_wallet_withdraw_various_amounts[100.0-90.0-10.0] PASSED [ 66%]
tests/test_wallet.py::test_wallet_withdraw_various_amounts[100.0-100.0-0.0] PASSED [100%]

============================== 3 passed in 0.03s ==============================

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

عبارت PASSED جلوی هر خط نشان می‌دهد که ادعاهای مطرح‌شده در دستور assert برای هر سه حالت به درستی کار کرده‌اند. اگر در آینده یکی از این سناریوها (مثلاً تخلیه کامل حساب) به خاطر باگ محاسباتی شکست بخورد، فریم‌ورک فقط همان خط را با وضعیت Failed قرمز می‌کند و بقیه تست‌های موازی به راه خود ادامه می‌دهند.

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

تست هم‌زمان استثناها و ورودی‌های نامعتبر با parametrize

تست کردن مبالغ درست و دیدن نوار سبز عالی است، اما امنیت دارایی کاربر زمانی تضمین می‌شود که مطمئن شویم لایه امنیتی نرم‌افزار در برابر دیتای خراب هم درست عمل می‌کند. در متد کسر موجودی، ما دو شرط برای پرتاب کردن ValueError نوشتیم: یکی برای مبالغ منفی یا صفر و دیگری برای برداشت‌های بیشتر از موجودی. حالا چطور این سناریوهای منفی را به صورت تکراری و یکجا تست کنیم؟

پاسخ در ترکیب دکوراتور با یکی از ابزارهای بومی فریم‌ورک یعنی pytest.raises است. برای این کار، فایل test_wallet.py را باز کنید و این تابع هوشمند را به انتهای آن اضافه کنید:

@pytest.mark.parametrize("initial_balance, withdraw_amount", [
    (100.0, -50.0),  # مبلغ منفی
    (100.0, 0.0),    # مبلغ صفر
    (50.0, 60.0),    # برداشت بیشتر از موجودی حساب
])
def test_wallet_withdraw_exceptions(initial_balance, withdraw_amount):
    wallet = DigitalWallet(owner="سهراب", balance=initial_balance)
    
    # صحت‌سنجی پرتاب شدن خطای لایه اپلیکیشن
    with pytest.raises(ValueError):
        wallet.withdraw(withdraw_amount)

بیایید این منطق عیب‌یابی کدهای مالی را بررسی کنیم. ما در لیست دکوراتور، تمام حالت‌های ممنوعه را چیده‌ایم. داخل بدنه تابع، کار اصلی را بلاک مدیریت استثنا یا همان with pytest.raises(ValueError) انجام می‌دهد. این بلاک به فریم‌ورک می‌گوید: «من انتظار دارم کدی که در خط بعدی اجرا می‌شود، حتماً یک خطای ValueError پرتاب کند.»

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

با این تکنیک، شما توانسته‌اید بدون تولید کدهای کثیف، تمام لایه‌های امنیتی و سناریوهای منفی پروژه کیف پول دیجیتال خود را به صورت یکجا و خودکار صحت‌سنجی کنید.

تمرین: سناریوی شارژ حساب یا واریزهای تکراری رو تو بنویس!

درک شهودی و عمیق فرآیند ارزیابی نرم‌افزار، صرفاً از طریق پیاده‌سازی عملی و مواجهه با چالش‌های برنامه‌نویسی محقق می‌شود. اکنون که با نحوه مدیریت ورودی‌های چندگانه در دکوراتور پارامتریایز جهت ارزیابی متد برداشت آشنا شده‌اید، زمان آن است که متد افزایش موجودی (deposit) را که در درس گذشته توسعه داده‌ایم، در قالب سناریوهای تکراری و با مبالغ مختلف مورد آزمایش قرار دهید.

برای حل این چالش فنی، گام‌های زیر را در ساختار پروژه خود پیاده‌سازی کنید:

گام اول: توسعه تابع تست با دکوراتور پویا

در فایل test_wallet.py یک تابع ارزیابی جدید با پیشوند استاندارد فریم‌ورک تحت عنوان test_wallet_deposit_various_amounts تعریف کنید. سپس با استفاده از دکوراتور @pytest.mark.parametrize بالای سر این تابع، سه متغیر ساختاریافته شامل دارایی اولیه، مبلغ واریز و مانده حساب مورد انتظار را تدارک ببینید.

گام دوم: طراحی ماتریس داده‌های ورودی و خروجی

یک لیست پایتونی از تاپل‌ها ایجاد کنید که حداقل ۳ سناریوی مالی مجزا را به دکوراتور تزریق کند؛ به عنوان نمونه:

  • دارایی اولیه ۵۰ واحد، مبلغ واریز ۳۰ واحد، مانده حساب مورد انتظار ۸۰ واحد.
  • دارایی اولیه صفر واحد، مبلغ واریز ۱۰۰ واحد، مانده حساب مورد انتظار ۱۰۰ واحد.
  • دارایی اولیه ۱۰ واحد، مبلغ واریز ۵۰۰ واحد، مانده حساب مورد انتظار ۵۱۰ واحد.

در نهایت، داخل بدنه تابع، نمونه‌سازی از کلاس کیف پول دیجیتال را با مقادیر داینامیک انجام دهید، متد واریز را فراخوانی کنید و در خط پایانی با دستور assert صحت‌سنجی نهایی مانده حساب را پیاده‌سازی کنید. با اجرای دستور pytest -v در محیط ترمینال، خروجی تفکیک‌شده و گزارش موفقیت این تست‌های موازی را مشاهده خواهید کرد. این تمرین معماری کدهای ارزیابی شما را بهینه‌سازی کرده و از تولید کدهای تکراری جلوگیری می‌کند.