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

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

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

چرا طول عمر پیش‌فرض فیکسچرهای pytest همیشه مناسب نیست؟

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

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

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

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

آشنایی با انواع سطوح اسکوپ (From Function to Session)

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

بیایید این چهار سطح اصلی را از کوچک‌ترین به بزرگ‌ترین بررسی کنیم:

۱. سطح تابع (Function Scope)

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

۲. سطح کلاس (Class Scope)

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

۳. سطح ماژول (Module Scope)

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

۴. سطح کل دوره اجرا (Session Scope)

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

انتخاب درست میان این سطوح، هنر یک مهندس نرم‌افزار است. در بخش بعدی کدهای بخش مدیریت کاربران را می‌نویسیم تا در عمل تفاوت سرعت و رفتار این اسکوپ‌ها را روی پروژه‌مان تست کنیم.

توسعه بخش مدیریت کاربران پروژه و راه‌اندازی فایل تست جدید

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

کدهای زیر را در یک فایل جدید به نام users.py در ریشه پروژه ذخیره کنید:

class UserManager:
    def __init__(self):
        self.users = {}

    def register_user(self, username, role="user"):
        if username in self.users:
            raise ValueError("این نام کاربری قبلاً ثبت شده است.")
        self.users[username] = {"role": role, "is_active": True}
        return self.users[username]

    def get_user_count(self):
        return len(self.users)

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

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

کدهای زیر را درون فایل test_users.py بنویسید:

import pytest
from users import UserManager

@pytest.fixture
def user_manager_system():
    manager = UserManager()
    manager.register_user("samandar", role="admin")
    return manager

def test_admin_creation(user_manager_system):
    assert user_manager_system.get_user_count() == 1
    assert user_manager_system.users["samandar"]["role"] == "admin"

def test_add_regular_user(user_manager_system):
    user_manager_system.register_user("ali")
    assert user_manager_system.get_user_count() == 2

در این فایل، تمیزکاری کدهای تست با Fixtures را با تعریف user_manager_system انجام دادیم. این فیکسچر در حالت پیش‌فرضِ فریم‌ورک کار می‌کند. یعنی برای تست اول یک بار اجرا می‌شود، کاربر مدیر را می‌سازد و تست پاس می‌شود. با شروع تست دوم، این فیکسچر دوباره از نو متولد می‌شود؛ دیتای تست قبلی کاملاً پاک شده و شمارنده کاربران دوباره روی عدد یک بازنشانی می‌شود. به همین دلیل در تست دوم، وقتی کاربر جدیدی اضافه می‌کنیم، تعداد کل به درستی برابر با ۲ می‌شود.

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

تنظیم طول عمر فیکسچرها با آرگومان scope

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

بیایید کدهای فایل test_users.py را بازنویسی کنیم و اسکوپ فیکسچر را روی حالت ماژول تنظیم کنیم تا ببینیم چه اتفاقی رخ می‌دهد:

import pytest
from users import UserManager

@pytest.fixture(scope="module")
def user_manager_system():
    manager = UserManager()
    manager.register_user("samandar", role="admin")
    return manager

def test_admin_creation(user_manager_system):
    assert user_manager_system.get_user_count() == 1
    assert user_manager_system.users["samandar"]["role"] == "admin"

def test_add_regular_user(user_manager_system):
    user_manager_system.register_user("ali")
    assert user_manager_system.get_user_count() == 2

با تغییر دکوراتور به @pytest.fixture(scope="module") به ابزار ارزیابی نرم‌افزار اعلام می‌کنیم که این فیکسچر فقط باید یک بار برای کل این فایل ساخته شود.

وقتی دستور اجرای تست را صادر می‌کنید، داستان به این شکل پیش می‌رود:
تست اول (test_admin_creation) اجرا می‌شود. فیکسچر در حافظه سیستم ساخته شده، کاربر مدیر ثبت می‌شود و تست پاس می‌گردد. حالا نوبت به تست دوم (test_add_regular_user) می‌رسد. در این گام، فریم‌ورک دیگر فیکسچر را بازنشانی نمی‌کند، بلکه همان نسخه موجود در حافظه را به تست دوم تحویل می‌دهد. متد ثبت‌نام، کاربر جدیدی به نام «ali» را به همان لیست قبلی اضافه می‌کند و در نتیجه تعداد کل کاربران با موفقیت به عدد ۲ می‌رسد.

با این کار ما در زمان و منابع سیستم صرفه‌جویی عجیبی کردیم، چون فرآیند نمونه‌سازی نو را فقط یک بار انجام دادیم. مدیریت طول عمر فیکسچرها (Fixture Scopes) با این روش به ما اجازه می‌دهد در سناریوهای واقعی پایتون، سرعت اجرای تست‌ها را به حداکثر برسانیم.

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

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

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

بیا یک تابع تست سوم به انتهای فایل test_users.py اضافه کنیم تا این فاجعه را در عمل ببینی:

def test_duplicate_user_error(user_manager_system):
    with pytest.raises(ValueError):
        user_manager_system.register_user("samandar")

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

راهکار چیست؟ چطور هم سرعت اسکوپ‌های بزرگ را داشته باشیم و هم ایزوله‌سازی محیط ارزیابی نرم‌افزار را حفظ کنیم؟ پاسخ در ترکیب فیکسچرهای pytest با دستور yield و تکنیک Teardown یا همان پاک‌سازی هوشمند است.

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

@pytest.fixture(scope="module")
def user_manager_system():
    manager = UserManager()
    manager.register_user("samandar", role="admin")
    yield manager
    # عملیات پاک‌سازی پس از پایان اجرای تست‌ها
    manager.users.clear()

اصل حرف این است: دستور yield مانند یک دکمه مکث عمل می‌کند. فریم‌ورک کدهای قبل از آن را اجرا می‌کند، آبجکت را به توابع تست تحویل می‌دهد و منتظر می‌ماند تا کار تست‌ها تمام شود. به محض پایان کار، به سراغ خطوط بعد از yield می‌آید تا خانه تکانی نهایی را انجام دهد. با این ترفند، مدیریت طول عمر فیکسچرها (Fixture Scopes) به امن‌ترین شکل ممکن هدایت می‌شود و پروژه تو در برابر باگ‌های پنهان، ضدگلوله خواهد شد.