مقدمه
سالها پیش با گروهی روی یک پروژه کار میکردم. هرزگاهی متوجه میشدیم که بخشی از برنامه که قبلا به درستی کار میکرده، از کار افتاده. همه از این موضوع تعجب کرده بودیم. این موضوع آنقدر عجیب و خنده دار شده بود که به شوخی گفته میشد یک جن در پروژه وجود داره و کد ها رو تغییر میدهد.
دلیل این مشکل نبود تست بود. ما تستی نداشتیم که با اجرایش مطمئن شویم همچنان همه بخش های برنامه به درستی کار میکند یا خیر.
این تجربه، طرز فکر مرا عوض کرد. تستنویسی، یک کار اضافی نیست. مثل تور ایمنی زیر بندباز است. با تور، میتوانید حرکتهای جسورانه انجام دهید. بدون تور، فقط میتوانید بایستید و نگاه کنید.
در این فصل، با pytest تست مینویسیم. با fixtureها تکرار را حذف میکنیم. با تزریق وابستگی و Mock، کد را از دنیای بیرون جدا میکنیم. و مهمتر از همه، یاد میگیریم که چه چیزی را تست کنیم و چه چیزی را نه.
چرا تستنویسی در بازار کار ارزش بالایی دارد؟
آگهیهای استخدام پایتون را باز کنید. کنار Django و SQL، تقریباً همیشه یک جمله هست: «آشنا با تست خودکار / pytest». این یک مد نیست؛ یک ضرورت اقتصادی است.
وقتی یک باگ در محیط توسعه پیدا میشود، رفع آن شاید چند دقیقه یا چند ساعت زمان ببرد. اما وقتی همان باگ به دست کاربر نهایی برسد، هزینهاش میتواند دهها برابر باشد. نه فقط پول، بلکه اعتماد کاربر را هم از دست میدهید. تستنویسی، باگ را قبل از اینکه کاربر ببیند، شکار میکند.
کد بدون تست، به سختی تغییر میکند. هر تغییری، یک قمار است. «امیدوارم این تغییر، جای دیگری را خراب نکرده باشد.» چنین کدی نهتنها یک دارایی نیست؛ یک بدهی است که هر روز سنگینتر میشود.
علاوه بر این، تستها یک مستندات زنده هستند. یک تست خوب به شما نشان میدهد که یک کلاس چگونه باید استفاده شود. و این مستندات هرگز از کد عقب نمیافتند، چون اگر عقب بیفتند، تست قرمز میشود و به شما میگوید: «هی، اینجا مشکلی هست!»
یک باور غلط را از همان ابتدا کنار بگذاریم: «تستنویسی وقت اضافه میخواهد». درست است، تستنویسی وقت جلوتر میخواهد. اما این وقت را چند برابر در آینده پس میدهد. هر بار که با خیال راحت کد را تغییر میدهید، هر بار که یک باگ قبل از انتشار کشف میشود، و هر بار که یک همکار جدید از روی تستها سیستم را یاد میگیرد، ارزش تستنویسی را میفهمید.
اولین تست: pytest در شصت ثانیه
پایتون یک ماژول به نام unittest در کتابخانهی استاندارد خود دارد. اما راستش، بیشتر برنامهنویسان حرفهایی از آن استفاده نمیکنند. آنها به pytest کوچ کردهاند. چرا؟ چون pytest سادهتر، خواناتر، و کمتشریفاتتر است.
قدم اول: نصب pytest
pip install pytest
قدم دوم: یک تابع ساده برای تست
فرض کنید میخواهیم هزینهی ارسال پست را بر اساس وزن محاسبه کنیم:
# shipping.py
def shipping_cost(weight_kg: float) -> int:
if weight_kg <= 0:
raise ValueError("وزن باید مثبت باشد")
if weight_kg <= 1:
return 30_000
return 30_000 + int((weight_kg - 1) * 10_000)این تابع سه حالت دارد:
- اگر وزن کمتر یا مساوی صفر باشد، خطا میدهد.
- اگر وزن کمتر یا مساوی یک کیلوگرم باشد، هزینه ۳۰,۰۰۰ تومان است.
- اگر وزن بیشتر از یک کیلوگرم باشد، به ازای هر کیلوگرم اضافی، ۱۰,۰۰۰ تومان اضافه میشود.
قدم سوم: نوشتن تست
حالا یک فایل جدید به نام test_shipping.py میسازیم:
# test_shipping.py
from shipping import shipping_cost
def test_light_package_has_base_cost():
assert shipping_cost(0.5) == 30_000
def test_heavy_package_costs_more():
assert shipping_cost(3) == 50_000قدم چهارم: اجرای تست
pytest
خروجی:
========== test session starts ==========
collected 2 items
test_shipping.py .. [100%]
========== 2 passed in 0.01s ==========
چه اتفاقی افتاد؟ pytest خودش فایلهایی که با test_ شروع میشوند را پیدا کرد، توابع داخلشان را اجرا کرد، و نتیجه را به ما نشان داد.
دو قانون ساده برای pytest:
- نام فایل تست باید با
test_شروع شود. - نام تابع تست باید با
test_شروع شود.
یک نکتهی مهم در نامگذاری: test_light_package_has_base_cost یک جملهی خبری است. وقتی این تست در آینده شکست بخورد، از همان اسم میفهمید که چه چیزی خراب شده است. نامگذاری خوب، نصف دیباگ است.
ساختار تستها: الگوی سهمرحلهای AAA
برای نوشتن تست، یک الگوی جهانی وجود دارد که به آن AAA میگویند. بیایید با یک مثال واقعی آن را ببینیم:
- Arrange (آمادهسازی): دادهها و اشیاء مورد نیاز را آماده کنید.
- Act (اقدام): عملی که میخواهید تست کنید را انجام دهید.
- Assert (ادعا): نتیجه را با مقدار مورد انتظار مقایسه کنید.
مثال: کلاس حساب بانکی
# bank.py
class InsufficientBalanceError(Exception):
pass
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("مبلغ واریز باید مثبت باشد")
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientBalanceError(f"موجودی شما {self.balance} است")
self.balance -= amountحالا تست مربوط به واریز وجه را با الگوی AAA مینویسیم:
# test_bank.py
import pytest
from bank import BankAccount, InsufficientBalanceError
def test_deposit_increases_balance():
# Arrange: یک حساب با موجودی ۱۰۰ بسازید
account = BankAccount("ali", balance=100)
# Act: مبلغ ۵۰ را واریز کنید
account.deposit(50)
# Assert: بررسی کنید که موجودی به ۱۵۰ رسیده است
assert account.balance == 150
def test_new_account_starts_at_zero():
# Arrange
account = BankAccount("sara")
# Assert: حساب تازه باید موجودی صفر داشته باشد
assert account.balance == 0چرا هر تست باید مستقل باشد؟ هر تست، شیء خودش را میسازد. تغییرات در یک تست، روی تست دیگر تأثیر نمیگذارد. این استقلال خیلی مهم است. اگر تستی به تست قبلی وابسته باشد، با تغییر ترتیب اجرا، آن تست شکست میخورد. پیدا کردن علت چنین شکستی، ساعتها وقت شما را میگیرد.
حالا میتوانید دلیل مخالفت با Singleton را بهتر درک کنید. Singleton یک حالت سراسری ایجاد میکند که بین تستها مشترک است. این اشتراک، استقلال تستها را از بین میبرد.
تست استثناها
رفتار خطا هم بخشی از رفتار برنامه است و باید تست شود. برای این کار از pytest.raises استفاده میکنیم:
def test_withdraw_more_than_balance_fails():
account = BankAccount("ali", balance=100)
# انتظار داریم که برداشت بیش از موجودی، خطای InsufficientBalanceError را پرتاب کند
with pytest.raises(InsufficientBalanceError):
account.withdraw(500)
def test_negative_deposit_is_rejected():
account = BankAccount("ali")
# با match میتوانیم متن پیام خطا را هم بررسی کنیم
with pytest.raises(ValueError, match="مبلغ واریز باید مثبت باشد"):
account.deposit(-10)pytest.raises چکار میکند؟ کد داخل بلوک را اجرا میکند. اگر خطای مورد انتظار رخ دهد، تست موفق است. اگر خطایی رخ ندهد، یا خطای دیگری رخ دهد، تست شکست میخورد.
تستهای پارامتری: یک تست، چندین سناریو
گاهی یک منطق خاص باید با ورودیهای مختلف تست شود. نوشتن یک تست جداگانه برای هر ورودی، کار تکراری است. راه حل، تست پارامتری است:
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize("weight, expected", [
(0.5, 30_000), # بستهی سبک
(1, 30_000), # دقیقاً در مرز
(2, 40_000), # بستهی سنگین
(10, 120_000), # بستهی خیلی سنگین
])
def test_shipping_cost(weight, expected):
assert shipping_cost(weight) == expectedpytest این تست را به عنوان چهار تست جداگانه اجرا میکند. اگر یکی از آنها شکست بخورد، دقیقاً مشخص میکند که کدام ورودی باعث شکست شده است.
نکتهی مهم دربارهی مرزها: به (1, 30_000) دقت کنید. مقدار ۱ یک مقدار مرزی است. تجربه نشان میدهد که باگها عاشق مرزها هستند. صفر، یک، آخرین عضو یک لیست، و لیست خالی، همگی نقاط حساسی هستند که احتمال خطا در آنها بیشتر است. تستهای مرزی را هرگز فراموش نکنید.
Fixture: آمادهسازی مشترک، بدون تکرار
وقتی چندین تست به یک شیء یکسان نیاز دارند، آمادهسازی آن را در قالب یک fixture انجام دهید:
import pytest
from bank import BankAccount
@pytest.fixture
def rich_account():
"""هر تستی که این fixture را درخواست کند، یک حساب تازه با موجودی ۱۰۰۰ دریافت میکند."""
return BankAccount("ali", balance=1000)
def test_withdraw(rich_account):
rich_account.withdraw(300)
assert rich_account.balance == 700
def test_two_operations(rich_account):
rich_account.deposit(500)
rich_account.withdraw(200)
assert rich_account.balance == 1300سازوکار ساده است: تابع تست، fixture را با نام پارامتر خود صدا میزند. pytest خودش آن را میسازد و به تابع تست تزریق میکند. هر تست یک نسخهی تازه از fixture دریافت میکند، پس استقلال تستها حفظ میشود.
fixture آمادهی tmp_path
یکی از fixtureهای آمادهی pytest که خیلی به کارتان میآید، tmp_path است. این fixture یک پوشهی موقت واقعی ایجاد میکند که بعد از اجرای تست، خودش پاک میشود. برای تست کدهایی که با فایل کار میکنند، بسیار کاربردی است:
import json
def save_users(users: list, path):
path.write_text(json.dumps(users), encoding="utf-8")
def test_save_users_writes_valid_json(tmp_path):
target = tmp_path / "users.json"
save_users([{"name": "ali"}], target)
assert json.loads(target.read_text(encoding="utf-8")) == [{"name": "ali"}]جداسازی از دنیای بیرون: Fake و Mock
تا اینجا کلاسهایی را تست کردیم که به منابع خارجی وابسته نبودند. اما در دنیای واقعی، کلاسها به دیتابیس، درگاه پرداخت، سرویس ایمیل، یا ساعت سیستم متصل میشوند. تستی که واقعاً ایمیل بفرستد یا از کارت اعتباری پول برداشت کند، نه سریع است، نه قابل تکرار، و نه بیخطر.
راهحل را از فصل هفتم و هشتم به یاد دارید: وابستگی را تزریق کنید. حالا زمان برداشت محصول آن فرا رسیده است.
Fake: بدل دستساز
یک Fake، یک پیادهسازی ساده و دستساز از یک سرویس است که رفتار واقعی را شبیهسازی میکند. مثلاً به جای یک درگاه پرداخت واقعی، یک Fake میسازیم که فقط فراخوانیها را در یک لیست ذخیره میکند:
# order_service.py
class OrderService:
def __init__(self, payment_gateway, notifier):
self.gateway = payment_gateway
self.notifier = notifier
def checkout(self, order_id, amount, card):
receipt = self.gateway.charge(card, amount)
self.notifier.send(f"order {order_id} paid: {receipt}")
return receipt# test_order_service.py
class FakeGateway:
def __init__(self):
self.charged = []
def charge(self, card, amount):
self.charged.append((card, amount))
return f"receipt-{len(self.charged)}"
class FakeNotifier:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message)
def test_checkout_charges_and_notifies():
gateway = FakeGateway()
notifier = FakeNotifier()
service = OrderService(gateway, notifier)
receipt = service.checkout(1, 90_000, "1234")
assert receipt == "receipt-1"
assert gateway.charged == [("1234", 90_000)]
assert notifier.sent == ["order 1 paid: receipt-1"]به لطف Duck Typing، Fakeها نیازی به ارثبری از یک کلاس خاص ندارند. فقط کافی است متدهای مورد انتظار را داشته باشند. این تست بدون اتصال به اینترنت، بدون کارت اعتباری واقعی، و در چند میلیثانیه اجرا میشود.
Mock: بدل آماده از کتابخانهی استاندارد
برای بدلهای یکبار مصرف، unittest.mock ابزارهای آمادهای دارد:
from unittest.mock import Mock
def test_checkout_with_mocks():
gateway = Mock()
gateway.charge.return_value = "receipt-42" # بهش میگوییم چه جوابی بدهد
notifier = Mock()
service = OrderService(gateway, notifier)
receipt = service.checkout(7, 50_000, "9876")
assert receipt == "receipt-42"
gateway.charge.assert_called_once_with("9876", 50_000)
notifier.send.assert_called_once_with("order 7 paid: receipt-42")Mock هر attributeای که بخواهید را در لحظه میسازد و هر فراخوانی را ضبط میکند. متدهای assert_called_* برای بازجویی از آن فراخوانیها در اختیار شما قرار میدهند.
با side_effect میتوانید خطاهای مختلف را نیز شبیهسازی کنید:
def test_checkout_when_gateway_is_down():
gateway = Mock()
gateway.charge.side_effect = ConnectionError("bank is down")
service = OrderService(gateway, Mock())
with pytest.raises(ConnectionError):
service.checkout(1, 10_000, "1111")patch: وقتی تزریق وابستگی ممکن نیست
بعضی از کدها وابستگیهای خود را با تزریق نمیگیرند. مثلاً مستقیماً از datetime.now() استفاده میکنند. در چنین مواردی، از patch استفاده میکنیم:
from unittest.mock import patch
def greeting():
from datetime import datetime
hour = datetime.now().hour
return "good morning" if hour < 12 else "good evening"
def test_greeting_in_the_morning():
with patch("datetime.datetime") as fake_dt:
fake_dt.now.return_value.hour = 9
assert greeting() == "good morning"یک نکتهی مهم: هر بار که مجبور به استفاده از patch میشوید، طراحی شما دارد به شما میگوید که مشکلی دارد. کدی که درست طراحی شده باشد، وابستگیهای خود را با تزریق میگیرد و نیازی به patch ندارد.
قاعدهی کلی: اول تزریق وابستگی، بعد Fake، و patch فقط برای کدی که نمیتوانید تغییر دهید.
چه چیزی را تست کنیم؟ و چه چیزی را نه؟
هنر تستنویسی در انتخاب است. همهی کدها ارزش یکسان ندارند.
رفتار را تست کنید، نه پیادهسازی را. تست خوب میگوید «بعد از برداشت ۳۰۰، موجودی ۷۰۰ است». تست بد میگوید «متد _recalculate باید دقیقاً دو بار صدا زده شود». تست اول با تغییرات داخلی کد، همچنان سبز باقی میماند. تست دوم با هر تغییر بیضرر داخلی، میشکند.
منطق را تست کنید، نه خود زبان را. تستی که فقط میسنجد که dataclass فیلدهایش را درست مقداردهی کرده، در واقع خود پایتون را تست میکند. وقت خود را روی منطق شرطی، محاسبات، مرزها و خطاها متمرکز کنید.
اولویت را به بخشهای پرریسک بدهید. منطق پول و پرداخت، قوانین کسبوکار، و هر جایی که یک باگ میتواند خبرساز شود، اولویت بالاتری دارند.
برای اینکه بدانید چقدر از کدتان زیر پوشش تست است:
pip install pytest-cov
pytest --cov=myproject
عدد پوشش را راهنما بگیرید، نه هدف نهایی. کدی با پوشش صد در صدی که فقط مسیر خوشبینانه را تست کرده، از کدی با پوشش هفتاد درصد که مرزها و خطاها را تست کرده، ضعیفتر است.
تستپذیری: آزمون نهایی طراحی
بیایید این فصل را با یک جمعبندی مهم به پایان برسانیم.
تستپذیری، نشانهی طراحی خوب است. اگر برای تست یک کلاس مجبورید دیتابیس واقعی راهاندازی کنید، پنج شیء دیگر بسازید و سه چیز را patch کنید، تستها دارند فریاد میزنند که طراحی مشکل دارد. وابستگی پنهان، مسئولیت زیاد، یا جفتشدگی شدید، همه در این فریادها شنیده میشوند.
هرچقدر یک کلاس سادهتر تست شود، طراحی بهتری دارد. کلاسی که برای تست کردنش فقط به سه خط کد نیاز داشته باشید، یعنی طراحیاش تمیز و بینقص است.
به همین دلیل، برنامهنویسهای حرفهای تستنویسی را همزمان با نوشتن کد شروع میکنند. هدفشان فقط پیدا کردن باگ نیست. هدف اصلی این است که طراحی ضعیف را زودتر تشخیص بدهند. اگر تستنویسی برای یک کلاس سخت باشد، یعنی آن کلاس مشکل طراحی دارد. شنیدن این هشدار در روزهای اول توسعه، خیلی کمهزینهتر از شنیدن آن در محیط تولید است.
خلاصه
نکتهی طلایی: تست ننوشتن، صرفهجویی نیست. قرضگرفتن از آینده با بهرهی سنگین است. اگر تستی به سختی نوشته میشود، قبل از جنگیدن با تست، به طراحی کد شک کنید.
| مفهوم | یکخطی | چه زمانی استفاده کنیم؟ |
|---|---|---|
| pytest | فریمورک استاندارد صنعت با کشف خودکار و assert ساده | همیشه برای نوشتن تست در پایتون |
| AAA | ساختار هر تست: Arrange، Act، Assert | برای سازماندهی هر تست |
pytest.raises | تست رفتار خطا و استثناهای سفارشی | وقتی میخواهید مطمئن شوید کدتان در شرایط خطا، درست کار میکند |
parametrize | یک منطق تست با چندین سناریو؛ مرزها را فراموش نکنید | وقتی یک تابع را با چندین ورودی متفاوت باید تست کنید |
| fixture | آمادهسازی مشترک با قابلیت تزریق؛ هر تست یک نسخهی تازه دریافت میکند | وقتی چندین تست به یک شیء یا دادهی مشابه نیاز دارند |
| Fake | بدل دستساز ساده که رفتار را شبیهسازی میکند | وقتی میخواهید یک سرویس را برای چندین تست، شبیهسازی کنید و به منطق سادهای نیاز دارید. مثلاً یک FakeDatabase که دادهها را در یک لیست نگه میدارد. |
| Mock | بدل آماده از unittest.mock که فراخوانیها را ضبط و بازجویی میکند | وقتی فقط برای یک تست خاص به بدل نیاز دارید و میخواهید بررسی کنید که متدها با پارامترهای درست صدا زده شدهاند. مثلاً چک کردن اینکه send_email دقیقاً یک بار با ایمیل درست صدا زده شده. |
| patch | تعویض موقت وابستگیهای تزریقنشده؛ نشانهی مشکل در طراحی | فقط وقتی که نمیتوانید کد را تغییر دهید. مثلاً کدهای قدیمی یا کتابخانههای شخص ثالث که وابستگیشان را تزریق نمیکنند. اگر از patch استفاده میکنید، یعنی طراحی کدتان مشکل دارد. |
| رفتار نه پیادهسازی | تست شکننده = تستی که به جزئیات داخلی وابسته شده است | همیشه؛ هرگز تست را به پیادهسازی داخلی گره نزنید |
راهنمای سریع انتخاب بین Fake، Mock و patch
| سناریو | ابزار مناسب |
|---|---|
| یک سرویس ساده مثل ارسال ایمیل یا درگاه پرداخت را برای چندین تست شبیهسازی کنید | Fake |
| فقط برای یک تست خاص، یک وابستگی را شبیهسازی کنید و فراخوانیهایش را بررسی کنید | Mock |
کد قدیمی که وابستگیاش را تزریق نمیکند را تست کنید (مثل datetime.now()) | patch |
| یک دیتابیس ساده در حافظه (In-memory) برای تست بسازید | Fake |
| بررسی کنید که یک متد دقیقاً با پارامترهای درست صدا زده شده است | Mock |
| کدی که قابل تغییر نیست (مثل کتابخانهی شخص ثالث) را تست کنید | patch |
یک قاعدهی کلی طلایی
اول تزریق وابستگی، بعد Fake، بعد Mock، و patch فقط برای کدی که نمیتوانید تغییر دهید.
اگر در حال طراحی یک کلاس جدید هستید، وابستگیها را تزریق کنید. این کار باعث میشود تستنویسی بسیار سادهتر شود و به ندرت به Mock یا patch نیاز پیدا کنید.
پرسشهای مصاحبه
۱. «برای کلاس BankAccount چه تستهایی مینویسید؟»
یک پاسخ خوب، تستها را دستهبندی میکند: مسیر خوشبینانه (واریز و برداشت عادی)، مرزها (برداشت دقیقاً به اندازهی موجودی، واریز صفر)، خطاها (برداشت بیش از موجودی، مبلغ منفی)، و حالت اولیه (حساب تازه). گفتن «مرزها و خطاها» شما را از کسانی که فقط مسیر خوشبینانه را تست میکنند، جدا میکند.
۲. «فرق Mock، Fake و Stub چیست؟»
Fake یک پیادهسازی سادهی واقعی است (مثل Gateway که در لیست ضبط میکند). Stub فقط یک پاسخ از پیش تعیین شده برمیگرداند. Mock علاوه بر پاسخ، فراخوانیها را ضبط میکند و انتظارات را میسنجد (assert_called_once_with). در عمل، unittest.mock.Mock هر سه نقش را میتواند بازی کند.
۳. «تست شکننده (Brittle) یعنی چه و چطور از آن دوری میکنید؟»
تستی که با یک تغییر بیضرر داخلی میشکند، تست شکننده است. این تست به پیادهسازی وابسته شده، نه به رفتار قابل مشاهده. برای جلوگیری، روی خروجی و تغییر حالت عمومی تمرکز کنید. Mock را فقط در مرزهای سیستم به کار ببرید، نه برای اشیای داخلی خودتان.
۴. «چرا تزریق وابستگی تست را آسان میکند؟»
چون نقطهی تعویض دنیای واقعی با بدل را از قبل در سازنده قرار داده است. OrderService(gateway, notifier) در تست، همان امضا را با Fake دریافت میکند و نیازی به patch نیست. مثال ساعت: تزریق now_fn به جای datetime.now()، تست منطق انقضا را از «انتظار واقعی» به «پاس دادن زمان ساختگی» تبدیل میکند.
در فصل بعد: از دنیای طراحی و کیفیت، به زیر سطح میرویم. مدیریت حافظه را بررسی میکنیم. میبینیم اشیاء در کجای حافظه زندگی میکنند، __slots__ چطور میلیونها شیء را سبکتر میکند، و weakref چطور از نشت حافظه جلوگیری میکند. از اینجا به بعد، وارد عمق پایتون میشویم.