نقاط استراتژیکی که Unit Test باید بر روی آن نظارت کند!


در مقاله‌ی قبل،‌ درباره‌ی سطوح مختلف تست نرم‌افزار صحبت کردیم و گفتیم فرآیند تست نرم‌افزار شامل تست اجزاء (Unit Test)،‌ تست یکپارچگی (Integration Test)،‌ تست اعتبار سنجی(Validation Test) و در نهایت تست کلی سیستمی(System Test) می‌شود. fا توجه به مطالبی که تا اینجا گفته‌شده است؛ هرکدام از این مراحل،‌ برای شناخت انواع متفاوتی از خطاها که مربوط به جنبه‌های مختلف نرم‌افزار می‌شود، طراحی شده‌اند. در این مقاله و مقاله‌های بعدی،‌ میخواهیم مشخص کنیم که در هنگام پیاده‌سازی هر مرحله، بیشتر باید روی چه بخش‌هایی از برنامه تمرکز کنیم و به دنبال چه نوع خطاهایی باشیم؟ برای این کار، ابتدا با اولین مرحله‌ی تست نرم‌افزار یعنی Unit Test شروع می‌کنیم که به بررسی عملکرد ساختار داخلی برنامه مربوط می‌شود.


شکل کلی مواردی که باید در Unit Test بررسی شود.
شکل کلی مواردی که باید در Unit Test بررسی شود.


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

آیا جریان اطلاعاتی که وارد ماژول شده و از آن خارج می شود درست و معتبر هستند؟

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

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


آیا ساختمان‌داده هایی که به‌صورت محلی (local) تعریف شده‌اند،‌ در تمام طول برنامه یکپارچگی خود را حفظ می‌کنند؟

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

رابطه‌ی بین متغیرهای محلی(Local Variables) و متغیرهای کلی(Global Variables)
رابطه‌ی بین متغیرهای محلی(Local Variables) و متغیرهای کلی(Global Variables)


شرایط مرزی در ماژول‌های دارای محدودیت،‌ خطاخیزترین بخش‌های اجرای برنامه هستند!

آزمایش مرزهای عملکردی ماژول‌های مختلف، یکی از مهمترین وظایف واحد آزمایش است. یکی از جاهایی که اغلب برنامه ها شکست می‌خورند، در مرزهای مشخص‌شده برای آنها است. برای مثال بیشتر خطاها هنگامی رخ می دهند که در یک حلقه با متغیر i ، برنامه در حال بررسی آخرین مقدارهای قابل قبول برای متغیر i است و باید تصمیم بگیرد که حلقه قطع شود و یا ادامه پیدا کند؟ یا زمانیکه میخواهد عنصر nام یک آرایه n-بعدی را پردازش کند و یا هنگامی‌که در حال چک‌کردن حداکثر یا حداقل مقدارِ مجاز برای یک متغیرِخاص (که حتما باید مقادیر موجود در یک رنج مشخص را به خود بگیرد و نسبت به مقادیری که به آن نسبت‌داده می‌شود،‌ عملیات‌های مختلفی را انجام دهد)می‌باشد. در بخش‌هایی که ساختارهای شرطی گفته‌شده وجود داشته باشد؛ شرایط مرزی برنامه و محدودیت های آن مورد آزمایش قرار می گیرد تا اطمینان حاصل شود که ماژول در محدوده‌هایی که برای آن تعیین شده و با شرایطی که برای آن مشخص شده است، درست رفتار می‌کند یا خیر؟!

بعضی از متغیرها،‌ بسته به مقداری که به آنها اده می‌شود، کارهای متفاوتی انجام‌دهند.
بعضی از متغیرها،‌ بسته به مقداری که به آنها اده می‌شود، کارهای متفاوتی انجام‌دهند.


باید تمام راههایی که ممکن است برنامه طی کند را خودمان یکبار طی کنیم تا مطمئن شویم که مشکلی پیش نخواهد آمد!

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

اگر در متن برنامه، دستوراتی وجود داشته باشد که در صورت تست تمام راههای ممکن،‌ حتی یک بار هم اجرا نشوند،‌ ممکن است نشان دهنده ی دو حالت زیر باشد:

1- در فایل های برنامه‌ی ما کدهایی وجود دارد که از جریان برنامه اصلی خارج شده اند و اضافی می‌باشند که برای رعایت اصول تمیزی کد،‌ بهتر است آنها را برای همیشه پاک کنیم تا کد بهتری داشته باشیم.

2- برنامه مشکل منطقی دارد که تمام راههایی که برای حالتهای مختلف طراحی کرده‌ایم، مورد استفاده قرار نمی‌گیرد(برای مثال به دلایل منطقی،‌یک متغیر boolean خاص همواره مقدار true را به خود می‌گیرد و هیچوقت false نمی‌شود تا کدهای مربوط به آن اجرا شود!‌ پس کدهایی که برای حالت false آن نوشته‌ایم،‌ همیشه بلا استفاده باقی می‌مانند و هیچوقت نمیتوانیم امکانی که این حالت در برنامه ایجاد می‌کند را مورداستفاده قرار دهیم.)

تصویر زیر، به خوبی می‌تواند شرایط بالا را توصیف کند. در این تصویر،‌ یک متغیر به نام flag تعریف شده‌است که "عمدا" طوری تنظیم شده‌است که همواره دربرگیرنده‌ی مقدار true باشد. تابع این کد را به وسیله‌ی JUnit‌و در محیط intelliJ IDEA به‌شکلی اجرا کرده‌ایم که code coverage را نیز لحاظ کند و شکل زیر،‌خروجی HTML حاصل از اجرای‌این فرآیند است. همانطور که در تصویر می‌بینید،‌ با استفاده از کد رنگ سبز و قرمز،‌ مشخص شده‌است که حالت else شرط مورد نظر هیچگاه اجرا نمی‌شود و این مسئله برای برنامه نویسِ این بخش،‌ یک زنگ خطر محسوب می‌شود.

نمونه‌ای از خروجی HTML حاصل از اجرای تست تابع (با قابلیت coverage) در محیط intelliJ IDEA
نمونه‌ای از خروجی HTML حاصل از اجرای تست تابع (با قابلیت coverage) در محیط intelliJ IDEA


برای درک بهتر حالت‌های مختلف اجرای برنامه،‌ State Diagramها بهترین ابزار هستند.

برای بهتر درک کردن مسیرهای متفاوت نرم‌افزار و طراحی دقیق‌تر test caseها ، بهتر است از state diagram یا دیاگرام‌های حالت، ‌استفاده کنیم. چراکه این دیاگرام‌ها، علاوه براینکه می‌توانند برای درک جریان عملکردیِ حالتهای ممکنِ اجرای نرم‌افزار به ما کمک کنند؛ این امکان را ایجاد می‌کند که به کمک قوانین جبرفرآیندی (Process Algebra) و نظریه‌ی ماشین‌ها،‌ تمام حالتهای ممکن در جریان اجرای برنامه را ارزیابی کرده و از درست‌بودن عملکرد آنها مطمئن شویم.

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

یک نمونه‌ی ساده از یک State Diagram
یک نمونه‌ی ساده از یک State Diagram


نرم‌افزار نهایی،‌ نه تنها باید درست کار کند؛ بلکه حتی باید درست خطا بدهد!

وقتی حتی خطاها هم باید درست باشد!
وقتی حتی خطاها هم باید درست باشد!


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

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

خطاها باید چه ویژگی‌هایی داشته باشند تا بگوییم "خطاها خوب مدیریت شده اند"؟

برنامه نباید به هیچ وجه زیر بار خطاها کمر خم کند!
برنامه نباید به هیچ وجه زیر بار خطاها کمر خم کند!


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

1. شرح خطا نامفهوم است.

خطایی که در برنامه پیش می‌آید،‌ باید جوری شرح داده شود که مخاطب آن (که بسته به نوع و دلیل وقوع خطا،‌ ممکن است کاربر یا مسئول مانیتور سیستم یا مسئول بخش‌های مختلف تیم توسعه و یا حتی برنامه نویس باشد) کاملا متوجه مفهوم آن شود و بتواند آن را رفع کند.

2. خطای ذکر شده با خطای روی داده مطابقت ندارد.

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

3. شرایط خطا باعث دخالت سیستم قبل از رسیدگی به خطا می شود.

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

البته حتی با این وجود،‌ بازهم ممکن است خطاهای خیلی بزرگی رخ دهد که باعث بسته شدن برنامه شود! باید توجه داشته‌باشیم که حتی اگر خطای خیلی بزرگ و غیرقابل پیش‌بینی‌ای رخ داده بود؛‌ به طوریکه به هیچ‌وجه نمیتوانستیم جلوی وقوع آن را بگیریم هم،‌ حداقل باید بتوانیم خودمان با روشی که تجربه‌ی کاربری بدی ایجاد نکند،‌ برنامه را ببندیم و خطای پیش آمده، باعث بسته‌شدن آن توسط سیستم‌عامل (یا به اصطلاح crashکردن برنامه) و یا ساخته شدن خروجی های نادرست نشود!

4. پردازش exception نادرست است.

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

5. شرح خطا، اطلاعات کافی برای کمک به محل علت خطا ارائه نمی دهد.

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


Source : Roger S. Pressman, Bruce R. Maxim. "Software Engineering A Practtitioner's Approach" - 9th Edition

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