تابعی که فقط دو عدد را جمع میکند تست راحتی است. ورودی میدهی، خروجی را چک میکنی، تمام. اما برنامههای واقعی اینطور ساده نیستند. کیف پول دیجیتالی که در این دوره ساختیم، باید موجودی کاربر را جایی نگه دارد. اگر برنامه بسته شود و دوباره باز شود، آن پول نباید ناپدید شود. اینجا پای فایل و دیتابیس به وسط میآید. سوال این است: چطور تابعی را تست کنیم که با دنیای بیرون از حافظه برنامه کار میکند؟
چرا تست فایل و دیتابیس فرق دارد؟
تست یک تابع ساده، اثری روی دنیای بیرون نمیگذارد. اما تابعی که فایل مینویسد یا رکورد در دیتابیس ثبت میکند، یک ردپا باقی میگذارد. این ردپا دو خطر دارد. خطر اول، آلودگی است. اگر تست یک فایل واقعی روی سیستم بسازد و پاک نکند، اجرای بعدی تست با باقیمانده اجرای قبلی تداخل پیدا میکند. خطر دوم، وابستگی است. تستی که به یک فایل خاص روی کامپیوتر شخصی وابسته باشد، روی کامپیوتر همکار یا سرور دیگر اصلاً اجرا نمیشود.
راهحل pytest؛ فضای موقت ایزوله
pytest یک فیکسچر داخلی به نام tmp_path ارائه میدهد که برای هر تست، یک پوشه موقت و یکتا میسازد. این پوشه یک شیء از نوع Path است؛ همان ابزار استاندارد پایتون برای کار با مسیر فایلها. نکته مهم این است که pytest پیش از شروع هر تست، این پوشه موقت را میسازد و بعد از پایان تست، خودش پاکسازی میکند. نیازی نیست نگران فراموش کردن حذف فایل باشی؛ این کار به صورت خودکار انجام میشود. نتیجه؟ هر تست در یک محیط تمیز و مستقل اجرا میشود. هیچ تستی روی تست دیگر اثر نمیگذارد.
از فایل ساده تا JSON و دیتابیس
منطق تست فایل متنی، JSON و دیتابیس یکی است: داده را در یک فضای موقت بنویس، تابع را روی همان فضا اجرا کن، نتیجه را بررسی کن.
برای کیف پول دیجیتال، این یعنی میتوانیم موجودی کاربر را در یک فایل JSON موقت ذخیره کنیم، تابع save_balance را روی آن اجرا کنیم و مطمئن شویم داده درست نوشته شده. همین الگو برای یک پایگاه داده سبک مثل SQLite هم کار میکند.
این درس چه چیزی به دوره اضافه میکند؟
تا اینجای دوره با مفاهیمی مثل فیکسچر، اسکوپ و conftest آشنا شدیم. در این درس، این ابزارها را روی یک سناریوی واقعیتر پیاده میکنیم: کدی که با دنیای بیرون از حافظه برنامه ارتباط دارد. این مهارت یکی از پرکاربردترین بخشهای تستنویسی حرفهای در پایتون است؛ چون اکثر برنامههای واقعی، دیر یا زود باید دادهای را جایی ذخیره کنند.
چرا تست فایل با تست توابع معمولی فرق دارد؟
تست تابع deposit در کیف پول دیجیتال ساده بود. عدد وارد میکنی، موجودی را چک میکنی، تمام. هیچ ردپایی باقی نمیماند. حالا تابعی را تصور کن که موجودی کاربر را در یک فایل ذخیره میکند. این تابع، بعد از اجرا شدن، چیزی روی سیستم باقی میگذارد. یک فایل واقعی، در یک مسیر واقعی. این تفاوت، اثر جانبی یا Side Effect نام دارد.
تابع خالص در برابر تابع با اثر جانبی
تابع خالص، یک متد قطعی است که فقط به ورودیهای مشخص خودش وابسته است؛ مثلاً به ساعت سیستم یا یک منبع بیرونی دیگر هیچ وابستگی پنهانی ندارد. تابع add(2, 5) همیشه عدد ۷ را برمیگرداند. نه کمتر، نه بیشتر. نه امروز، نه فردا. اما تابعی که فایل مینویسد اینطور نیست. اثر جانبی یعنی هر منبع ورودی یا خروجی برای یک متد که جزو پارامترها یا مقدار بازگشتی آن نیست؛ مثل نوشتن خروجی در یک فایل.
تست یک تابع خالص آسان است؛ چون فقط کافی است نتیجه فراخوانی را در یک متغیر ذخیره کنی و مقدارش را با چیزی که انتظار داری مقایسه کنی. اما تابعی که فایل مینویسد، چیزی برنمیگرداند که بتوانی مستقیم چکش کنی. باید برویم سراغ خود فایل.
چرا تست واحد باید از فایل واقعی دوری کند؟
تست واحد باید عاری از اثر جانبی باشد تا بتوان آن را بهسادگی و بدون درگیر کردن سیستم دیگری اجرا کرد؛ این یعنی هیچ وابستگیای به سیستمعامل، مثل دسترسی به فایل سیستم، نباید داشته باشد. دلیلش را با یک مثال ببینیم. فرض کن تابع save_balance موجودی را در فایلی به نام wallet_data.json در همان پوشه پروژه ذخیره میکند. اگر تست این تابع را مستقیم روی همین فایل اجرا کنی، چند مشکل پیش میآید.
اول، اگر فایل از قبل وجود داشته باشد، تست داده واقعی را بازنویسی میکند. دوم، اگر دو تست همزمان به همین فایل دسترسی پیدا کنند، نتیجه غیرقابل پیشبینی میشود. سوم، تست روی کامپیوتر همکار یا روی سرور CI ممکن است به این مسیر دسترسی نداشته باشد. سیستم فایل، حتی با سرعت بالای امروزی، همچنان پیچیدگیهایی ایجاد میکند که بهتر است در تست از آن دوری کرد؛ مثل هر وابستگی بیرونی دیگر، باید آن را از منطق اصلی برنامه جدا نگه داشت.
پس راهحل چیست؟
نیاز واقعی این است: یک فضای فایل واقعی داشته باشیم، اما مصنوعی و یکبارمصرف. جایی که تابع بتواند بنویسد و بخواند، بدون اینکه به داده واقعی پروژه دست بزند. این دقیقاً همان چیزی است که تست ایزوله به دنبالش است: بررسی یک بخش مشخص از کد، بدون درگیر شدن با تأثیرات بیرونی غیرمرتبط. pytest برای حل همین مشکل، ابزاری آماده دارد که در بخش بعد بررسی میکنیم: فیکسچری که برای هر تست، یک پوشه موقت و تازه میسازد و بعد از پایان کار، خودش پاکش میکند.
آشنایی با فیکسچر tmp_path
نیاز قبلی روشن بود: یک فضای فایل واقعی، اما ایزوله و یکبارمصرف. pytest این نیاز را با یک فیکسچر داخلی پاسخ میدهد به نام tmp_path.هیچ نصب اضافهای لازم نیست. هیچ ایمپورتی هم لازم نیست. کافی است نام آن را به عنوان پارامتر در تابع تست بنویسی، دقیقاً مثل هر فیکسچر دیگری که در درسهای قبل ساختیم.
tmp_path دقیقاً چه کاری میکند؟
tmp_path یک پوشه موقت میسازد که برای هر تابع تست، یکتا و مستقل است. یعنی اگر ده تست داشته باشی که هر کدام از tmp_path استفاده میکنند، ده پوشه جداگانه ساخته میشود. هیچ تستی به پوشه تست دیگر دسترسی ندارد.
مقداری که این فیکسچر برمیگرداند، یک شیء از نوع pathlib.Path است. این همان ابزار استانداردی است که پایتون برای کار با مسیر فایلها در اختیار میگذارد. یعنی هر کاری که با Path بلد بودی، همینجا هم کار میکند.
یک مثال عملی
کد زیر یک فایل میسازد، در آن مینویسد و دوباره میخواند:
def test_create_file(tmp_path):
file_path = tmp_path / "wallet.txt"
file_path.write_text("balance: 100")
assert file_path.read_text() == "balance: 100"
نگاه کن چه اتفاقی افتاد. خط اول، یک مسیر فایل داخل پوشه موقت میسازد. خط دوم، متنی در آن مینویسد. خط سوم، همان متن را میخواند و با مقدار انتظاری مقایسه میکند. هیچ فایلی در پروژه واقعی دست نخورد. هیچ دادهای جابجا نشد.
پاکسازی خودکار؛ بدون نیاز به دستور اضافه
اینجا جایی است که tmp_path واقعاً ارزشش را نشان میدهد. پیش از شروع هر تست، یک پوشه موقت یکتا ساخته میشود و بعد از پایان آن تست، پاکسازی بهصورت خودکار انجام میشود. یادت هست در درس فیکسچر اسکوپها، چطور با yield کد Teardown مینوشتیم؟ اینجا دیگر نیازی به نوشتن دستی آن نیست. خود pytest این مسئولیت را برعهده میگیرد. این یعنی هر تست در یک محیط ایزوله و پاکیزه اجرا میشود؛ بدون نگرانی از باقیمانده فایلهای تستهای قبلی.
چرا این پوشهها همیشه جدید هستند؟
این پوشههای موقت زیر یک پوشه پایه ساخته میشوند که نام آن از الگوی pytest-{شماره} پیروی میکند و هر بار اجرا، شماره جدیدی میگیرد. همین شمارهگذاری تضمین میکند که اجرای امروز با اجرای دیروز قاطی نشود.
اعمال این فیکسچر روی کیف پول دیجیتال
حالا برگردیم به پروژه خودمان. تابع save_balance_to_file موجودی کاربر را در یک فایل مینویسد. تست آن، با کمک tmp_path، به این شکل است:
from wallet import save_balance_to_file
def test_save_balance_to_file(tmp_path):
file_path = tmp_path / "balance.txt"
save_balance_to_file(file_path, 250.0)
assert file_path.read_text() == "250.0"
این تست، بدون لمس هیچ فایل واقعی در پروژه، رفتار تابع را بررسی میکند. هر بار که اجرا شود، یک پوشه تازه میگیرد و در پایان، چیزی روی سیستم باقی نمیگذارد. در بخش بعد همین الگو را روی دادههای JSON پیاده میکنیم؛ فرمتی که برای ذخیره اطلاعات ساختاریافته مثل موجودی و تاریخچه تراکنشها رایجتر است.
نوشتن اولین تست فایل متنی
مفهوم tmp_path روشن شد. حالا وقت آن است که یک سناریوی واقعی از پروژه کیف پول دیجیتال را قدم به قدم تست کنید.فرض کنید کیف پول دیجیتال یک قابلیت ساده دارد: ثبت گزارش تراکنش در یک فایل متنی. هر بار که کاربر پولی واریز یا برداشت میکند، یک خط توضیح در فایل transaction_log.txt اضافه میشود.تابعی که قرار است تست شودپیش از نوشتن تست، تابع موردنظر را ببینید:
def write_transaction_log(file_path, transaction_text):
with open(file_path, "a") as log_file:
log_file.write(transaction_text + "\n")
این تابع کار پیچیدهای انجام نمیدهد. یک مسیر فایل میگیرد، یک متن میگیرد و آن متن را در انتهای فایل اضافه میکند. حالت "a" در open به معنای Append است؛ یعنی نوشتن بدون پاک کردن محتوای قبلی.
گام اول: ساخت مسیر فایل موقت
اولین قدم در نوشتن تست، ساخت یک مسیر داخل پوشه موقت است:
def test_write_transaction_log(tmp_path):
log_file = tmp_path / "transaction_log.txt"
نکتهای که اینجا اهمیت دارد: فایل transaction_log.txt هنوز وجود ندارد. فقط یک مسیر تعریف شده. این دقیقاً همان چیزی است که در دنیای واقعی هم اتفاق میافتد؛ برنامه معمولاً اول فایلی را که وجود ندارد میسازد.
گام دوم: فراخوانی تابع و بررسی نتیجه
حالا تابع اصلی فراخوانی میشود و نتیجه با assert بررسی میشود:
def test_write_transaction_log(tmp_path):
log_file = tmp_path / "transaction_log.txt"
write_transaction_log(log_file, "واریز ۵۰ تومان")
assert log_file.read_text() == "واریز ۵۰ تومان\n"
سه خط، سه مرحله مشخص. ساخت مسیر، اجرای تابع، بررسی نتیجه. همان الگوی Setup-Action-Assert که در درسهای قبلی دیدید، اینجا هم تکرار شده؛ فقط اینبار با یک فایل واقعی روی دیسک. اجرای این تست با دستور آشنای pytest انجام میشود. اگر همه چیز درست باشد، خروجی سبز رنگ pytest نمایش داده میشود.
بررسی رفتار Append با چند تراکنش
یک تابع که فقط یک بار درست کار کند کافی نیست. باید بررسی شود که آیا تابع، تراکنشهای بعدی را هم درست به فایل اضافه میکند یا محتوای قبلی را پاک میکند.
def test_write_multiple_transactions(tmp_path):
log_file = tmp_path / "transaction_log.txt"
write_transaction_log(log_file, "واریز ۵۰ تومان")
write_transaction_log(log_file, "برداشت ۲۰ تومان")
content = log_file.read_text()
assert "واریز ۵۰ تومان" in content
assert "برداشت ۲۰ تومان" in content
این تست دو بار تابع را فراخوانی میکند و بعد بررسی میکند که هر دو خط، در محتوای نهایی فایل وجود دارند. استفاده از عملگر in به جای == در اینجا منطقیتر است؛ چون هدف، بررسی وجود یک رشته در متن کامل فایل است، نه تطابق دقیق با کل محتوا.
چرا این تست از تست دستی بهتر است؟
بدون pytest، برای بررسی این رفتار باید برنامه را اجرا میکردید، فایل را با یک ویرایشگر متن باز میکردید و با چشم خطها را چک میکردید. این کار با هر تغییر کوچک در کد، باید از نو تکرار میشد. با تست خودکار فایل، این بررسی در کسری از ثانیه و بدون دخالت انسان انجام میشود. همین تفاوت، یکی از دلایلی است که تستنویسی فایل را به بخش جداییناپذیر پروژههای پایتونی واقعی تبدیل کرده است. در بخش بعد، همین الگو را روی دادههای JSON پیاده میکنیم؛ فرمتی که برای ذخیره اطلاعات ساختاریافته مثل موجودی حساب، کاربردیتر از یک فایل متنی ساده است.
تست دادههای JSON
فایل متنی برای یک خط لاگ ساده کافی بود. اما موجودی کیف پول دیجیتال، اطلاعات بیشتری دارد: نام کاربر، مبلغ، تاریخ آخرین تراکنش. ذخیره این نوع داده در یک فایل متنی ساده، خواندن و پردازش آن را پیچیده میکند.اینجا فرمت JSON وارد میشود؛ ساختاری که دادهها را به شکل کلید و مقدار نگه میدارد و تقریباً در همه زبانهای برنامهنویسی قابل خواندن است.
تابعی که اطلاعات کیف پول را ذخیره میکند
تابع زیر، اطلاعات کیف پول دیجیتال را در قالب JSON روی دیسک مینویسد:
import json
def save_wallet_data(file_path, data):
with open(file_path, "w") as json_file:
json.dump(data, json_file)
کتابخانه json بخشی از کتابخانه استاندارد پایتون است؛ نیازی به نصب جداگانه ندارد. متد json.dump یک دیکشنری پایتون میگیرد و آن را در قالب متن JSON داخل فایل مینویسد.
نوشتن تست با ترکیب tmp_path و json
تست این تابع، دو ابزار را با هم ترکیب میکند: فیکسچر tmp_path برای ساخت مسیر موقت، و کتابخانه json برای خواندن دوباره دادهها.
import json
from wallet import save_wallet_data
def test_save_wallet_data(tmp_path):
file_path = tmp_path / "wallet.json"
wallet_info = {"owner": "علی", "balance": 150.0}
save_wallet_data(file_path, wallet_info)
with open(file_path) as json_file:
saved_data = json.load(json_file)
assert saved_data == wallet_info
نگاه کنید به خط آخر. به جای مقایسه متن خام فایل، محتوای آن دوباره با json.load به یک دیکشنری پایتون تبدیل شده. این روش از مقایسه رشتهای دقیقتر است؛ چون فاصلهها یا ترتیب کلیدها در متن خام JSON، اهمیتی در نتیجه نهایی ندارد.
چرا مقایسه دیکشنری بهتر از مقایسه متن است؟
فرض کنید کتابخانه json تصمیم بگیرد فاصله بین کلیدها را کمی تغییر دهد. اگر تست با مقایسه متن خام نوشته شده باشد، این تغییر جزئی باعث شکست تست میشود؛ حتی اگر داده واقعی هیچ مشکلی نداشته باشد.
مقایسه دو دیکشنری پایتون با عملگر == این مشکل را کنار میزند. پایتون فقط مقدار کلیدها را بررسی میکند، نه شکل ظاهری متن.
تست بازیابی داده با تابع جداگانه
یک کیف پول دیجیتال واقعی، فقط داده ذخیره نمیکند؛ باید بتواند آن را دوباره بخواند. تابع زیر همین کار را انجام میدهد:
def load_wallet_data(file_path):
with open(file_path) as json_file:
return json.load(json_file)
تست این تابع، ابتدا یک فایل JSON میسازد و سپس بررسی میکند که تابع، همان داده را به درستی برمیگرداند:
def test_load_wallet_data(tmp_path):
file_path = tmp_path / "wallet.json"
file_path.write_text(json.dumps({"owner": "سارا", "balance": 300.0}))
result = load_wallet_data(file_path)
assert result["owner"] == "سارا"
assert result["balance"] == 300.0
این بار مسیر برعکس شده. به جای فراخوانی تابع برای نوشتن، داده مستقیم با write_text در فایل قرار گرفته و بعد تابع load_wallet_data فراخوانی شده. این الگو به بررسی جداگانه عملکرد خواندن کمک میکند، بدون وابستگی به درستی تابع نوشتن.
نکتهای درباره دادههای فارسی در JSON
وقتی متن فارسی در JSON ذخیره میشود، ممکن است در فایل خام به شکل کدهای یونیکد دیده شود، نه حروف فارسی خوانا. این یک رفتار طبیعی است و مشکلی در داده ایجاد نمیکند؛ چون json.load این کدها را بهدرستی به متن اصلی برمیگرداند. اگر نمایش خوانای فارسی در فایل خروجی هم اهمیت داشته باشد، پارامتر ensure_ascii=False در json.dump این مشکل را حل میکند:
json.dump(data, json_file, ensure_ascii=False)
با ترکیب tmp_path و کتابخانه json، تست دادههای ساختاریافتهای مثل اطلاعات کیف پول دیجیتال، بدون نیاز به دست زدن به فایل واقعی پروژه ممکن میشود. در بخش بعد همین مهارت ایزولهسازی را روی یک دیتابیس واقعی پیاده میکنیم.
تست توابعی که با دیتابیس کار میکنند
فایل متنی و JSON برای دادههای ساده مناسب بودند. اما کیف پول دیجیتالی که چندین کاربر دارد، چندین تراکنش ثبت میکند و باید رکوردها را جستجو کند، به ابزار قویتری نیاز دارد. اینجا دیتابیس وارد میشود.پایتون یک دیتابیس سبک به نام SQLite دارد که در کتابخانه استاندارد جا گرفته. نیازی به نصب سرور جداگانه نیست؛ کل دیتابیس در یک فایل، یا حتی فقط در حافظه، جای میگیرد.
چرا دیتابیس حافظهای برای تست انتخاب بهتری است؟
دو حالت برای ساخت دیتابیس SQLite وجود دارد: ذخیره روی فایل، یا ساخت در حافظه موقت کامپیوتر. برای تست، حالت دوم گزینه بهتری است.با استفاده از قابلیت حافظهای SQLite، میتوان مطمئن شد برنامه بدون نیاز به راهاندازی یک نمونه دیتابیس اختصاصی، در برابر دادههای واقعی محکم عمل میکند. دیتابیس حافظهای، با پایان یافتن تست، بهطور کامل از بین میرود. هیچ فایلی روی دیسک باقی نمیماند. Medium
ساخت فیکسچر دیتابیس
فیکسچری که یک دیتابیس تازه برای هر تست میسازد، به شکل زیر نوشته میشود:
import pytest
import sqlite3
@pytest.fixture
def wallet_db():
connection = sqlite3.connect(":memory:")
cursor = connection.cursor()
cursor.execute(
"CREATE TABLE wallets (id INTEGER PRIMARY KEY, owner TEXT, balance REAL)"
)
connection.commit()
yield connection
connection.close()
رشته ":memory:" به جای نام فایل، به Python میگوید دیتابیس را فقط در حافظه بساز. دستور CREATE TABLE جدولی با ستونهای مشخص میسازد و بعد از yield، اتصال دیتابیس بسته میشود. Pytest with Eric
این الگو، همان چیزی است که در درس فیکسچر اسکوپها دیدید: کد قبل از yield آمادهسازی است، کد بعد از آن پاکسازی.
تست عملیات درج رکورد
تابعی که یک کیف پول جدید در دیتابیس ثبت میکند:
def create_wallet(connection, owner, balance):
cursor = connection.cursor()
cursor.execute(
"INSERT INTO wallets (owner, balance) VALUES (?, ?)", (owner, balance)
)
connection.commit()
تست این تابع از فیکسچر wallet_db استفاده میکند:
def test_create_wallet(wallet_db):
create_wallet(wallet_db, "علی", 100.0)
cursor = wallet_db.cursor()
cursor.execute("SELECT owner, balance FROM wallets")
result = cursor.fetchone()
assert result == ("علی", 100.0)
نام فیکسچر، مستقیم به عنوان پارامتر تابع تست نوشته شده. pytest بهطور خودکار متوجه میشود که باید قبل از اجرای تست، این فیکسچر را فراخوانی کند. علامت سوال در عبارت VALUES (?, ?) یک نکته امنیتی مهم است. به جای قرار دادن مستقیم مقادیر داخل رشته SQL، این مقادیر جداگانه به تابع execute پاس داده میشوند. این روش از حملات تزریق SQL جلوگیری میکند.
تست چند رکورد و عملیات جستجو
یک تست واقعی باید رفتار تابع را با چند داده مختلف بررسی کند:
def test_multiple_wallets(wallet_db):
create_wallet(wallet_db, "علی", 100.0)
create_wallet(wallet_db, "سارا", 250.0)
cursor = wallet_db.cursor()
cursor.execute("SELECT COUNT(*) FROM wallets")
count = cursor.fetchone()[0]
assert count == 2
این تست بررسی میکند که بعد از دو بار فراخوانی create_wallet، تعداد رکوردهای جدول دقیقاً برابر با دو است. اگر تابع به اشتباه رکورد را بازنویسی کند یا یکی از مقادیر را نادیده بگیرد، این تست بلافاصله شکست میخورد.
چرا هر تست باید دیتابیس خودش را داشته باشد؟
فیکسچر دیتابیس حافظهای باید مستقل بودن هر تست را تضمین کند؛ یعنی برای هر اجرا، یک دیتابیس تازه فراهم شود. بدون این جداسازی، رکورد ثبتشده در یک تست، روی نتیجه تست بعدی اثر میگذارد و خطایابی را دشوار میکند.
شروع با یک دیتابیس خالی برای هر تست، خطر آلودگی و نشت داده بین تستها را بهشدت کاهش میدهد. همین اصل ساده، پایه قابلاعتماد بودن تستهای دیتابیس است.
با ترکیب فیکسچر، SQLite حافظهای و الگوی Setup-Action-Assert، تست توابعی که با دیتابیس کار میکنند، به همان اندازه ساده و قابل اعتماد میشود که تست یک تابع جمع ساده بود. در بخش بعد، تفاوت tmp_path با tmp_path_factory بررسی میشود؛ ابزاری که برای اشتراکگذاری منابع موقت بین چند تست به کار میآید.
تفاوت tmp_path و tmp_path_factory
فیکسچر tmp_path در تمام مثالهای قبلی یک محدودیت ساختاری دارد: فقط در سطح یک تابع تست کار میکند. اگر نیاز باشد چند تست از یک پوشه موقت مشترک استفاده کنند، یا یک فایل بزرگ فقط یک بار ساخته شود و بین چند تست به اشتراک برود، tmp_path بهتنهایی کافی نیست.اینجا فیکسچر دوم وارد میشود: tmp_path_factory.
چرا یک فیکسچر دیگر لازم است؟
tmp_path یک فیکسچر با اسکوپ تابع است؛ یعنی اگر از آن در دو تست مختلف استفاده شود، دو پوشه موقت کاملاً جدا در اختیار قرار میگیرد. برای اکثر سناریوها همین رفتار دقیقاً همان چیزی است که لازم است. اما گاهی این جداسازی، هزینه عملکردی ایجاد میکند. اگر چند تابع نیاز داشته باشند بهصورت پیاپی روی فایلهای دیسک کار کنند و رفتار مشترک آنها بررسی شود، یا اگر فایل موردنیاز حجیم باشد، ساخت یک پوشه موقت پایدارتر معنا پیدا میکند. دقیقاً همینجاست که tmp_path_factory کاربرد دارد.
tmp_path_factory چطور کار میکند؟
tmp_path_factory یک فیکسچر با اسکوپ session است؛ یعنی در ابتدای اجرای کل مجموعه تستها ساخته میشود و تا پایان آخرین تست باقی میماند. این یعنی پوشهای که این فیکسچر میسازد، بین تستهای مختلف قابل اشتراکگذاری است. این فیکسچر یک شیء از نوع TempPathFactory برمیگرداند که متد mktemp() دارد؛ با این متد میتوان چند پوشه موقت جداگانه ساخت.
یک مثال کاربردی برای پروژه کیف پول دیجیتال: فرض کنید چند تست نیاز دارند یک فایل پشتیبان بزرگ از دادههای کیف پول را بخوانند. ساخت این فایل ممکن است چند ثانیه طول بکشد. ساخت آن برای هر تست بهطور جداگانه، زمان اجرای کل مجموعه تستها را بالا میبرد.
import pytest
@pytest.fixture(scope="session")
def wallet_backup_dir(tmp_path_factory):
backup_dir = tmp_path_factory.mktemp("wallet_backups")
return backup_dir
این فیکسچر، یکبار در طول کل اجرای تستها ساخته میشود. هر تستی که آن را بهعنوان پارامتر بگیرد، به همان پوشه دسترسی پیدا میکند.
جدول مقایسه دو فیکسچر
| ویژگی | tmp_path | tmp_path_factory |
| سطح اسکوپ | تابع (Function) | جلسه (Session) |
| تعداد پوشه | یک پوشه برای هر تست | چند پوشه با mktemp() |
| نوع بازگشتی | شیء Path | شیء TempPathFactory |
| کاربرد مناسب | ایزولهسازی کامل هر تست | اشتراکگذاری منابع سنگین |
کدام یک را انتخاب کنید؟
قانون کلی ساده است: برای اکثر تستهای فایل، tmp_path انتخاب درستی است. ایزوله بودن کامل، خطر تداخل داده بین تستها را به صفر میرساند و همین ویژگی، اولویت اصلی در نوشتن تست واحد است. با این حال، استفاده از اسکوپهای بزرگتر باید با احتیاط انجام شود؛ چون باید مراقب بود که تستها وضعیت مشترکی را تغییر ندهند.
اگر یک تست محتوای پوشه مشترک را تغییر دهد، تست بعدی ممکن است با دادهای متفاوت از آنچه انتظار دارد روبرو شود. برای پروژه کیف پول دیجیتال، tmp_path_factory تنها در سناریوهای خاصی به کار میآید؛ مثلاً وقتی یک فایل تنظیمات بزرگ یا یک نمونه داده آزمایشی سنگین باید فقط یک بار ساخته شود. برای تستهای روزمره فایل و JSON، همان tmp_path ساده، انتخاب امنتر و قابلاعتمادتری است. در بخش بعد، این دو فیکسچر با فیکسچرهای اختصاصی پروژه کیف پول دیجیتال ترکیب میشوند؛ تا یک محیط تست کامل و آماده برای سناریوهای واقعیتر ساخته شود.
ترکیب tmp_path با فیکسچرهای خودمان
تا اینجا tmp_path همیشه مستقیم در پارامتر تابع تست ظاهر شده. اما الگوی حرفهایتری هم وجود دارد: ساخت یک فیکسچر اختصاصی که خودش از tmp_path استفاده میکند و یک کیف پول دیجیتال کاملاً آماده تحویل میدهد.
این رویکرد، دقیقاً همان مهارتی است که در درس فیکسچرها و درس conftest.py یاد گرفته شد؛ فقط اینبار با ترکیب یک فیکسچر داخلی pytest و منطق پروژه.
مشکل تکرار کد در تستها
نگاه کنید به تستهای قبلی. هر کدام چند خط مشابه دارند: ساخت مسیر فایل، آمادهسازی داده اولیه، سپس تست واقعی. این تکرار، همان مشکلی است که در درس فیکسچرها به آن اشاره شد؛ Setup Duplication.
def test_save_balance(tmp_path):
file_path = tmp_path / "wallet.json"
save_wallet_data(file_path, {"owner": "علی", "balance": 100.0})
...
def test_load_balance(tmp_path):
file_path = tmp_path / "wallet.json"
save_wallet_data(file_path, {"owner": "سارا", "balance": 200.0})
...
دو تست، یک الگوی یکسان. راهحل، استخراج این بخش مشترک به یک فیکسچر اختصاصی است.
ساخت فیکسچر wallet_file
فیکسچر زیر، یک فایل JSON آماده با داده اولیه برمیگرداند:
import pytest
from wallet import save_wallet_data
@pytest.fixture
def wallet_file(tmp_path):
file_path = tmp_path / "wallet.json"
initial_data = {"owner": "علی", "balance": 100.0}
save_wallet_data(file_path, initial_data)
return file_path
نکته مهم این خط است: def wallet_file(tmp_path):. این فیکسچر، خودش tmp_path را بهعنوان پارامتر میگیرد. pytest اجازه میدهد یک فیکسچر، از فیکسچر دیگری استفاده کند؛ همان ویژگی Dependency Injection که در درسهای قبلی بررسی شد.
استفاده از فیکسچر در تستها
حالا تستها بسیار سادهتر و خواناتر میشوند:
def test_wallet_file_exists(wallet_file):
assert wallet_file.exists()
def test_wallet_file_has_correct_owner(wallet_file):
data = load_wallet_data(wallet_file)
assert data["owner"] == "علی"
هیچ خط تکراری برای ساخت فایل وجود ندارد. هر تست مستقیم سراغ بررسی رفتار موردنظر میرود؛ نه سراغ آمادهسازی محیط.
قرار دادن فیکسچر در conftest.py
اگر چند فایل تست مختلف به این فیکسچر نیاز داشته باشند، انتقال آن به conftest.py منطقیتر است. همانطور که در درس مربوط به این فایل بررسی شد، هر فیکسچری که داخل conftest.py تعریف شود، بدون نیاز به import در تمام فایلهای تست همان پوشه در دسترس قرار میگیرد.
# conftest.py
import pytest
from wallet import save_wallet_data
@pytest.fixture
def wallet_file(tmp_path):
file_path = tmp_path / "wallet.json"
save_wallet_data(file_path, {"owner": "علی", "balance": 100.0})
return file_path
با این تغییر، هر فایل تست در پروژه کیف پول دیجیتال میتواند از wallet_file استفاده کند؛ بدون تکرار منطق ساخت فایل.
فیکسچر دیتابیس به همراه داده اولیه
همین الگو روی دیتابیس هم قابل پیادهسازی است. فیکسچر زیر، یک دیتابیس حافظهای با چند رکورد آماده برمیگرداند:
@pytest.fixture
def populated_wallet_db(wallet_db):
create_wallet(wallet_db, "علی", 100.0)
create_wallet(wallet_db, "سارا", 250.0)
return wallet_db
این فیکسچر، خودش از فیکسچر wallet_db که در بخش قبل ساخته شد استفاده میکند. زنجیرهای از فیکسچرها، هرکدام یک لایه آمادهسازی اضافه میکنند.
def test_total_wallets_count(populated_wallet_db):
cursor = populated_wallet_db.cursor()
cursor.execute("SELECT COUNT(*) FROM wallets")
assert cursor.fetchone()[0] == 2
تست نهایی، فقط یک خط منطق دارد. تمام آمادهسازی، در لایههای فیکسچر مخفی شده.
چرا این الگو برای پروژههای واقعی مهم است؟
ترکیب tmp_path با فیکسچرهای اختصاصی، یک مزیت عملی روشن دارد: تستها خوانا میمانند، حتی وقتی پروژه بزرگتر میشود. هر فیکسچر یک مسئولیت مشخص دارد و هر تست، فقط روی رفتاری که قرار است بررسی کند تمرکز میکند.
این همان معماریای است که در پروژههای پایتونی واقعی، بین تستهای فایل، JSON و دیتابیس به کار میرود. یادگیری این الگو، مرز بین نوشتن چند تست پراکنده و ساختن یک مجموعه تست حرفهای و قابل نگهداری را مشخص میکند.