کوروش فرجی
13 یادداشت منتشر شدهچطور تست بنویسیم؟ هرم تست، اشتباه های رایج و شروع درست
حتما برای شما هم پیش آمده است: کدی را می نویسید که به نظر درست کار می کند، اما چند هفته بعد یک تغییر کوچک در یک گوشه ی برنامه، جایی دیگر را خراب می کند؛ و شما فقط وقتی متوجه می شوید که کاربر گزارش باگ داده است. این جاست که تست نویسی وارد می شود؛ نه به عنوان یک تشریفات اضافه، بلکه به عنوان کمربند ایمنی کد شما.
اما تست نویسی هم مثل هر مهارت دیگری، درست و غلط دارد. تستی که فقط برای «سبز شدن» نوشته شده باشد، نه تنها کمکی نمی کند، بلکه خودش تبدیل به بار نگهداری می شود. در این یادداشت می بینیم تست خوب چه ویژگی هایی دارد، چه چیزی را باید تست کنیم، و چه اشتباه هایی تست ها را بی فایده می کند.
## چرا اصلا تست بنویسیم؟
هدف اصلی تست نویسی، امن تر کردن تغییرهاست؛ نه بیشینه کردن تعداد

تست ها. وقتی مجموعه ی تست های خوبی داشته باشید، چند چیز به دست می آورید:
**محافظت در برابر رگرسیون:** یعنی اگر تغییری، رفتار قبلی را بشکند، تست ها سریع خبرتان می کنند؛ پیش از آن که باگ به دست کاربر برسد.
**مستندسازی اجرایی:** یک تست خوب، به زبان کد می گوید «این تابع قرار است در این شرایط، دقیقا این کار را بکند». این مستند، برخلاف کامنت ها، هرگز قدیمی نمی شود؛ چون اگر دروغ بگوید، قرمز می شود.
**بازخورد طراحی:** کدی که تست کردنش دشوار است، معمولا یک پیام دارد: مسئولیت ها درهم تنیده اند یا وابستگی ها زیادند. سختی تست نویسی، اغلب نشانه ی نیاز به بازطراحی است، نه نشانه ی بی فایده بودن تست.
**اطمینان برای بازآفرینی (Refactoring):** با یک مجموعه ی تست قابل اعتماد، می توانید ساختار داخلی کد را با خیال راحت تغییر دهید؛ چون تست ها نگهبان رفتار بیرونی اند.
اما یک نکته ی مهم را فراموش نکنید: گذشتن همه ی تست های واحد، اثبات کارکرد کامل برنامه نیست. نگاشت پایگاه داده، قرارداد سرویس ها، پیکربندی و گردش کامل کاربر، به تست های گسترده تری نیاز دارند. تست واحد، یک لایه از اطمینان است، نه همه ی آن.
## اصل مرکزی: رفتار را تست کنید، نه پیاده سازی را
مهم ترین اصل تست نویسی این است: تست باید بپرسد «با این ورودی، خروجی درست تولید می شود؟» نه «این متد داخلی صدا زده شد؟». اگر با تغییر نام یک متد داخلی یا جابه جایی یک حلقه، تست شما بشکند در حالی که رفتار برنامه عین قبل است، آن تست دارد جزئیات پیاده سازی را تست می کند؛ و چنین تستی در برابر هر بازآفرینی مقاومت می کند و شما را از بهبود کد می ترساند.
پس به جای assert کردن فراخوانی متدهای خصوصی یا ترتیب فراخوانی ها، این ها را بررسی کنید: خروجی عمومی تابع، تغییر حالت قابل مشاهده، و اثرهای خارجی معنادار (مثلا ذخیره شدن یک رکورد یا ارسال یک پیام).
## هرم تست: کجا چقدر تست بنویسیم؟
هرم تست، راهنمایی برای توازن سرعت بازخورد و اطمینان است؛ نه یک تقسیم درصدی الزامی. سه لایه دارد:
**پایه ی هرم — تست های واحد:** کوچک، سریع و پرتعداد. هر تست یک تابع یا یک کلاس را جدا از بقیه بررسی می کند؛ بدون پایگاه داده و بدون شبکه. هر کدام در حد میلی ثانیه اجرا می شوند، پس می توانید صدها تای آن ها را در چند ثانیه اجرا کنید.
**لایه ی میانی — تست های یکپارچه سازی و قرارداد:** تعدادشان کمتر است. این جاست که مرزهای واقعی بررسی می شوند: آیا کوئری شما واقعا روی پایگاه داده ی واقعی جواب درست می دهد؟ آیا پاسخ سرویس خارجی درست تجزیه می شود؟
**نوک هرم — تست های سرتاسری (End-to-End):** فقط چند تست برای مهم ترین گردش های کاربر، از ورود تا خرید یا ثبت نام، در کل سیستم. این تست ها کند و شکننده اند، پس باید کم باشند.
نسبت معروف «۷۰٪ واحد، ۲۰٪ یکپارچه سازی، ۱۰٪ سرتاسری» یک حدس تجربی قدیمی است، نه یک قانون جهانی؛ توازن درست به معماری پروژه ی شما بستگی دارد. قاعده ی عملی این است: از ارزان ترین لایه ای استفاده کنید که رفتار را اثبات می کند. ترکیب های پیچیده ی قوانین و حالت های مرزی را در تست واحد بگذارید، رفتار پایگاه داده را با پایگاه داده ی واقعی در لایه ی یکپارچه سازی بسنجید، و گردش های حیاتی را با چند تست سرتاسری محافظت کنید.
## چه چیزی را تست کنیم؟
اولویت با کدی است که نتیجه ی نادرست در آن اهمیت دارد و تصمیم های معنادار می گیرد:
- **محاسبات مهم کسب وکار:** قیمت گذاری، تخفیف، مالیات، امتیازدهی؛ جایی که یک اشتباه کوچک، پول یا اعتماد از دست می دهد.
- **قوانین مجوزدهی و اعتبارسنجی:** چه کسی به چه چیزی دسترسی دارد؟ ورودی نامعتبر چه می شود؟
- **تبدیل ها و تجزیه (Parsing):** تبدیل تاریخ، واحد پول، قالب فایل؛ این جاها باگ ها پنهان می شوند.
- **مدیریت خطا:** وقتی سرویس پایین است، وقتی داده خالی است، وقتی ورودی ناقص است، برنامه چه می کند؟
برای هر رفتار، این دسته ها را در نظر بگیرید: حالت عادی (مسیر خوش)، مرزها (عدد صفر، رشته ی خالی، مقدار بیشینه)، ورودی خالی یا گمشده، ورودی نامعتبر، مسیرهای شکست، تغییر حالت، و رگرسیون. تست رگرسیون قاعده ی طلایی دارد: پیش از اصلاح، باید نقص را بازتولید کند و پس از اصلاح، بگذرد.
و چه چیزی را تست نکنیم؟ گتر و ستر ساده بدون منطق، متدهای خصوصی (آن ها را از طریق API عمومی تست کنید)، کد خودکار تولیدشده، و قالب بندی های بدیهی فریم ورک؛ تست این ها وقت می گیرد و اطمینان نمی دهد.
## ساختار یک تست خوب: Arrange–Act–Assert
هر تست خوب، یک داستان روشن می گوید و سه مرحله دارد:
۱. **Arrange (آماده سازی):** ورودی ها و شرایط را بچینید.
۲. **Act (اجرا):** همان یک کاری را که می خواهید بسنجید، صدا بزنید.
۳. **Assert (بررسی):** نتیجه را با انتظار مقایسه کنید.
یک رفتار در هر تست؛ نه لزوما یک assertion. اگر چند assertion یک بررسی منطقی واحد را تشکیل می دهند (مثلا چند فیلد یک شیء برگشتی)، اشکالی ندارد؛ اما دو رفتار نامرتبط را در یک تست نگنجانید. نام تست را هم بر اساس رفتار بگذارید، نه نام متد؛ مثلا «وقتی موجودی صفر است، سفارش رد می شود» به جای «test_order».
تست ها باید مستقل و قطعی باشند: بدون حالت مشترک قابل تغییر بین تست ها، و بدون وابستگی کنترل نشده به ساعت واقعی، شبکه یا عدد تصادفی. برای زمان یا تصادف، مقدار قابل کنترل تزریق کنید؛ وگرنه تستی خواهید داشت که بعضی روزها، در CI، یا یک درصد مواقع، بی دلیل قرمز می شود.
در مورد test doubleها هم گزینشی عمل کنید: stub یا fake برای رفتار کنترل شده ی یک وابستگی (مثلا یک پایگاه داده ی درون حافظه)، و mock یا spy فقط وقتی خود تعامل اهمیت دارد. اگر تستی پنج وابستگی را mock می کند و هر فراخوانی را assert می کند، احتمالا دارد گراف پیاده سازی را تست می کند، نه رفتار را؛ و تابعی که این قدر mock می خواهد، احتمالا دارد کار زیادی می کند.
## اشتباه های رایج
**دنبال کردن پوشش ۱۰۰٪:** پوشش (Coverage) فقط می گوید چه خط هایی اجرا شده اند، نه این که آیا رفتار درست بررسی شده است. تابعی که روی ورودی بد به جای خطا، مقدار None برمی گرداند می تواند ۱۰۰٪ پوشش داشته باشد و در عین حال رفتارش غلط باشد. از پوشش برای یافتن شکاف ها استفاده کنید، نه به عنوان هدف.
**تست جزئیات پیاده سازی:** گفتیم؛ این تست ها با هر بازآفرینی می شکنند و شما را از بهبود کد می ترسانند.
**Mock کردن همه چیز:** تستی که همه چیز را mock کرده، دارد mockهای خودش را تست می کند، نه کد شما را.
**تست فقط مسیر خوش:** بیشتر باگ ها در شاخه های خطا زندگی می کنند. ورودی نامعتبر، سرویس ازکارافتاده و داده ی خالی را هم تست کنید.
**Setup حجیم و fixtureهای مشترک قابل تغییر:** اگر beforeEach شما پنجاه خط است، تست بیش از حد به زیرساخت وابسته است؛ آن کار را به یک factory یا builder بسپارید.
**نادیده گرفتن تست های flaky:** تستی که گاهی بی دلیل قرمز می شود را نباید با «دوباره اجرا کن» پذیرفت؛ علت nondeterminism را پیدا کنید و رفع کنید.
یک ابزار تکمیلی جالب هم «تست جهش» (Mutation Testing) است: پیاده سازی را عمدا کمی تغییر می دهید (مثلا > را به >= تبدیل می کنید) و بررسی می کنید آیا تست ها این اشتباه را می گیرند یا نه. اگر نگرفتند، یعنی assertionهای شما به اندازه ی کافی سخت گیر نیستند.
## تست نویسی در ۲۰۲۶: هوش مصنوعی کمک می کند، جایگزین نمی شود
در سال ۲۰۲۶، ابزارهای هوش مصنوعی مثل GitHub Copilot و Cursor می توانند پیش نویس تست ها را سریع بنویسند، و در جاوااسکریپت، Vitest با سرعت بیشتر و پشتیبانی بومی از ESM جای Jest را به عنوان انتخاب پیش فرض پروژه های تازه گرفته است. همچنین تست مبتنی بر ویژگی (Property-based testing) با ابزارهایی مثل Hypothesis در پایتون و fast-check در جاوااسکریپت، از حاشیه به یک مکمل توصیه شده برای منطق پیچیده تبدیل شده است.
اما نکته ی مهم این است: هوش مصنوعی می تواند قالب و موارد تکراری را بنویسد، اما طراحی تست — این که چه رفتاری مهم است و چه حالتی باید بررسی شود — همچنان به توجه انسان نیاز دارد. تست های تولیدشده با هوش مصنوعی گاهی جزئیات پیاده سازی را assert می کنند؛ آن ها را بازبینی و اصلاح کنید، نه این که چشم بسته بپذیرید.
گردش کار عملی پیشنهادی برای ۲۰۲۶ چنین است: رفتار مورد انتظار را پیش یا هم زمان با پیاده سازی تعریف کنید؛ برای قوانین، مرزها و مدیریت شکست تست واحد متمرکز بنویسید؛ مرزهای واقعی پایگاه داده و سرویس را با تست یکپارچه سازی بپوشانید؛ گردش های حیاتی را با مجموعه ای کوچک از تست های سرتاسری محافظت کنید؛ بررسی های سریع را در هر تغییر و بررسی های گسترده تر را در CI اجرا کنید؛ هر رفع باگ را به یک تست رگرسیون تبدیل کنید؛ و کیفیت تست را بازبینی کنید، نه فقط تعداد تست ها یا درصد پوشش را.
## از کجا شروع کنیم؟
اگر تا حالا تست ننوشته اید، از همین سه قدم شروع کنید:
۱. **با تابع های خالص شروع کنید:** تابعی که ورودی می گیرد و خروجی می دهد، بدون پایگاه داده و بدون شبکه؛ راحت ترین تست ها همین ها هستند.
۲. **اول مسیر خوش، بعد حالت های مرزی:** اول بنویسید تابع در حالت عادی درست کار می کند؛ بعد ورودی خالی، عدد منفی و داده ی نامعتبر را اضافه کنید.
۳. **هر باگ، یک تست:** وقتی باگی را رفع می کنید، اول تستی بنویسید که آن باگ را بازتولید کند، بعد اصلاح کنید. این طوری آن باگ دیگر هرگز برنمی گردد.
تست نویسی، هزینه نیست؛ سرمایه است. کمی وقت امروز، ده ها ساعت دیباگ و استرس فردا را کم می کند. کدی که تست دارد، کدی است که می توانید با اعتماد تغییرش دهید؛ و در دنیای واقعی نرم افزار، همین اعتماد، ارزشمندترین دارایی یک برنامه نویس است.
منابع: پاسخ پژوهشی Perplexity (جلسه ی ۸ نوامبر ۲۰۲۶) با استناد به Microsoft Learn («Best practices for writing unit tests — .NET»)، TestRail، QAPractices («The Test Pyramid Explained with Examples»)، digitalapplied («Software Testing Strategy 2026: The Engineering Guide»)، Educative، GeeksforGeeks و HelpMeTest؛ همچنین راهنمای ByteLedger برای تست واحد در ۲۰۲۶ و راهنمای تست در dev.to.