🎁 تخفیف ویژه بلک فرایدی تا 10 آذر ماه 🎁

آموزش تست نرم افزار-تست های قابل اعتماد

به طور کلی، تست قابل اعتماد (Trust Worthy) تستی است که این احساس را به شما بدهد که می دانید چه اتفاقی در حال رخ دادن است و می توانید بر اساس شرایط موجود تصمیم گیری کنید.

آموزش تست نرم افزار-تست های قابل اعتماد

برای این که بدانیم یک تست قابل اعتماد است یا نه چندین نشانه مختلف وجود دارد. در صورت Pass شدن تست شما هیچگاه از debugger برای اطمینان از pass شدن تست استفاده نخواهید کرد و به pass شدن آن تست و همچنین درست کار کردن کدی که تست برای آن نوشته شده است (SUT: System Under Test) اطمینان می کنید. برعکس اگر تست fail شود هیچگاه نمی گویید که “اوه، این تست باید fail می شد!” یا این کهfail” شدن این تست به معنای عدم کارکرد صحیح کد (SUT) نیست!”. شما اطمینان دارید که مشکلی درون کد است و نه در تست. به طور کلی، تست قابل اعتماد (Trust Worthy) تستی است که این احساس را به شما بدهد که می دانید چه اتفاقی در حال رخ دادن است و می توانید بر اساس شرایط موجود تصمیم گیری کنید. در ادامه تکنیک هایی را به شما معرفی می کنیم که به شما در موارد زیر کمک می کند:

  1. تصمیم گیری در مورد زمان تغییر یا حذف تست ها
  2. پرهیز از نوشتن منطق برنامه در تست ها (Avoid Test Logic)
  3. همزمان در یک تست فقط و تنها یک عملکرد (Functionality) را تست کنید
  4. اطمیان از Code Coverage

تست هایی که از این تکنیک ها پیروی می کنند نسبت به بقیه تست ها قابل اعتمادتر و قابل اطمینان تر هستند.

تصمیم گیری در مورد زمان تغییر یا حذف تست ها

وقتی تست هایی داریم که بدون مشکل کار می کنند درحالت کلی نباید آنها را حذف کنیم و یا تغییر دهیم. این تست ها یک حاشیه امن ایجاد می کنند، تا زمانی که کدها را تغییر دادیم قادر به شناسایی مشکلات احتمالی ایجاد شده باشیم. حال اجازه دهید به دلایل احتمالی برای حذف یا تغییر تست های موجود بپردازیم. دلیل اصلی برای حذف یک تست زمانی است که آن تست fail می شود. یک تست می تواند به طور ناگهانی fail شود وقتی که:

  • باگ برنامه (Production Bug) – باگ در SUT وجود دارد.
  • باگ تست (Test Bug) – باگ در تست وجود دارد.
  • تغییرات مفاهیم یا API ها (Semantic or API changes) – مفاهیم یا API ها در SUT تغییر کرده است اما رفتار سیستم ثابت مانده است.
  • تضاد یا تست های نامعتبر (Conflicting or invalid tests) - تغییراتی در SUT که باعث به وجود آمدن Conflict در تست ها می شوند.

همچنین فارغ از مشکلاتی که ممکن است برای SUT یا تست به وجود آید، دلایل دیگری برای تغییر یا حذف تست ها وجود دارند مانند:

  • تغییر نام (Rename) یا Refactor‌ کردن تست
  • حذف تست های تکراری

حال به بررسی هر کدام از شرایط و دلایل حذف یا تغییر تست ها می پردازیم.

باگ برنامه (Production Bug)

باگ برنامه زمانی رخ می دهد که شما کد را تغییر می دهید و تست های موجود fail می شوند. در این صورت این یک باگ در SUT است و تست شما مشکلی ندارد و شما نیاز به تغییری در تست ندارید. این بهترین حالت و مطلوب ترین نتیجه از داشتن تست است. چون رخ دادن باگ در برنامه یکی از دلایل اصلی برای نوشتن unit test‌ است، تنها کاری که باید انجام دهیم پیدا کردن و درست کردن باگ در برنامه است و نیازی به تغییر تست نیست.

باگ تست (Test Bug)

اگر باگی در تست است، بنابراین تست باید تغییر کند. در ابتدا شناسایی باگ هایی که در تست ها وجود دارند سخت است، چون تصور ما بر این بوده که تست صحیح کار می کند و مشکلی ندارد. در زیر چند مرحله از کارهایی را که توسعه دهندگان زمانی که با یک باگ در تست مواجه می شوند، انجام می دهند را می بینید:

  • توسعه دهنده یا همان برنامه نویس تلاش می کند تا با گشت و گذار در کد برنامه (sut) باگ را پیدا کند، پس کدها را تغییر می دهد و همین باعث fail شدن مابقی تست ها می شود. او باگ های جدیدی را به سیستم اضافه می کند در حالی که به دنبال باگی می گردد که در اصل در تست ها وجود دارد.
  • توسعه دهنده با همکارش تماس می گیرد، البته اگر در دسترس باشد، و آن ها با هم تلاش می کنند تا باگی را که اصلا وجود ندارد را پیدا کنند!
  • توسعه دهنده با صبوری به بررسی (debug)‌ تست می پردازد و متوجه می شود که مشکلی در تست وجود دارد. این کار می تواند از یک ساعت تا چند روز زمان ببرد.
  • توسعه دهنده در نهایت باگ را پیدا می کند و به پیشانی خود سیلی می زند J از ایکه “وااای، پس مشکل در تست بود نه کد برنامه”.

نکته مهم اینجاست که وقتی شما در نهایت باگ را پیدا کرده و اقدام به رفع آن می کنید، از حل شدن کامل آن باگ اطمینان حاصل کنید و مطمئن شوید که تست به شکل جادویی ( یعنی قرار نبوده پس شود اما می شود!) و از طریق تست کردن کد اشتباه pass نمی شود. بنابراین شما باید مراحل زیر را انجام دهید:

  • باگ مربوط به تست را برطرف کنید.
  • مطمئن شوید که این تست آنگاه که باید، fail می شود.
  • مطمئن شوید که این تست آنگاه که باید، pass می شود.

مرحله اول کاملا مشخص است، اما دو مرحله بعدی برای این است که مطمئن شوید که درحال تست کردن همان چیزی هستید که می خواهید (‌همان عملکردی را تست می کنید که از ابتدا مدنظر شما بوده)، که درنهایت این تست قابل اعتماد خواهد بود. زمانی که باگ تست را برطرف کردید، به کد تحت تست (SUT) برگشته، آن را تغییر دهید تا باگی که تست مربوط به این کد چک میکند را (به طور عمدی) تولید کنید. سپس تست را اجرا کنید. اگر تست fail شد یعنی نیمی از کار انجام شده. نیمه دیگر آن در مرحله سوم از مراحل بالا انجام خواهد شد. اما اگر تست fail نشد و pass شد، این یعنی دارید چیز اشتباهی را تست می کنید( انجام مرحله دوم بسیار مهم است، من developer هایی را دیده ام که وقتی به رفع باگ تست مشغول اند، به طور اتفاقی assert موجود در تست را پاک می کنند! زمانی که fail شدن تست را دیدید، کد تحت تست را تغییر دهید و خواهید دید که باگ دیگر وجود نخواهد داشت. حالا تست باید pass شود. اگر نشد، حتما تست شما هنوز باگ دارد، یا درحال تست کردن چیز اشتباهی هستید.

تغییرات مفاهیم یا API ها (Semantic or API changes)

یک تست می تواند زمانی که کد تحت تست (SUT) تغییر می کند، fail شود بنابراین باید از این کد به نحوه دیگری استفاده کنید حتی اگر نتیجه نهایی و خروجی آن هنوز یکسان باقی بماند. به این تست ساده دقت کنید:

آموزش تست نرم افزار

کد بالا یک تست ساده است که درحال تست کردن کلاس LogAnalyzer می باشد. فرض کنید که یک تغییر مفهومی در کلاس LogAnalyzer به وجود آمده و ما باید قبل از صدا کردن هر متدی در این کلاس، متدی به نام Initialize را فراخوانی کنیم. اگر این تغییر را در کلاس LogAnalyzer اعمال کنیم، خطی که مسئول انجام Assert در تست است Exception می دهد زیرا متد Initialize فراخوانی نشده است. تست fail می شود اما هنوز تست معتبری است. عملکری را که این تست مشغول تست کردن آن است ( یعنی متد ()IsValid ) همچنان کار می کند اما مفهوم و قواعد و قوانین استفاده از کلاس تحت تست (LogAnalyzer) تغییر کرده است.

در این شرایط، ما باید تست را به این شکل تغییر دهیم تا با مفهوم جدید همخوانی داشته باشد:

آموزش تست نرم افزار

تغییرات اینچنینی دلیل بسیاری از تجربیات بد توسعه دهندگان در زمان نوشتن و نگهداری unit test ها است زیرا بار مسئولیت تغییر تست ها وقتی که API ها دائما تغییر می کنند همینطور بیشتر و بیشتر می شوند. در زیر نسخه ای بهتر و تعمیر پذیر تری از تست را به شما ارائه داده ایم:

آموزش تست نرم افزار

در این مثال، تست پالایش شده (Refactored Test) و از یک Factory Method استفاده می کند. ما می توانیم از این Factory Method برای تست های دیگر هم استفاده کنیم. حال اگر مفاهیم یا API کلاس LogAnalyzer مجددا نیاز به تغییر داشته باشد، نیازی به تغییر همه تست هایی که از این کلاس استفاده می کنند نیست و فقط کافی است متد MakeDefaultLogAnalyzer را تغییر دهیم چون همه تست هایی که نیاز به کلاس LogAnalyzer دارند این متد را فراخوانی کرده اند.

تضاد یا تست های نامعتبر  (Conflicting or invalid tests)

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

فرض کنید مشتری انتظار دارد که کلاس LogAnalyzer اجازه ثبت فایل با نام کوتاه تر از ۳ کاراکتر را ندهد. وقتی فایل با نام کمتر از ۳ کاراکتر وارد می شود، باید Exception رخ دهد. بسیار خب، بنا به درخواست مشتری این ویژگی را پیاده سازی و تست می کنیم.

مدتی بعد، مشتری متوجه این موضوع می شود که فایل با نام کوتاه تر از ۳ کاراکتر نیز برای مصارف خاص کاربرد دارد. پس تیم توسعه کد را تغییر می دهد و این ویژگی را اضافه می کند. حال تست های جدیدی می نویسیم که بر اساس آن ها کد برنامه دیگر Exception نمی دهد. ناگهان، یک تست قدیمی )همان تستی که نوشته شده بود تا وقتی فایل با نام کمتر از ۳ کاراکتر وارد می شود، انتظار Exception داشته باشد( fail می شود. همچنین تغییر کد برنامه برای Pass کردن این تست قدیمی سبب Fail شدن تست های جدید ما مبنی بر مجاز بودن کمتر از ۳ کارکتر خواهد شد.

در این حالت که فقط می توان یکی از ۲ تست را Pass کرد باید متوجه تضاد (Conflict) بین تست ها شویم. در این شرایط ابتدا باید از وقوع این تضاد اطمینان حاصل کنیم. وقتی این تضاد تایید شد، باید تصمیم بگیریم که کدام نیازمندی را نگه داریم و کدام را دور بریزیم. پس از آن باید تست و کد نامعتبر را حذف کنیم.

گاهی اوقات تضاد بین تست ها اشاره به مشکل یا تضاد در درخواست های مشتری دارد و او باید تصمیم بگیرد که کدام درخواست معتبر است و باید نگهداری شود، و کدام درخواست نامعتبر است و باید دور ریخته شود.

تغییر نام (Rename) یا پالایش (Refactor) کردن تست

یک تست ناخوانا (Unreadable) مشکل بسیار جدی محسوب می شود. این مشکل می تواند مانع از خوانایی کد و درک شما از خطاهایی که تست پیدا می کند شود. اگر با تستی مواجه شدید که نام نامناسبی دارد یا می تواند بهتر و تعمیرپذیرتر شود، درنگ نکنید و آن تست را تغییر دهید اما ساختار و عملکرد آن تست را تغییر ندهید. در تصاویر بالا مثالی را دیدیم که از MakeDefaultLogAnalyzer استفاده کردیم که باعث افزایش Maintainability و Readability تست شد.

حذف تست های تکراری

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

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

همچنین، معایب داشتن تست های تکراری عبارتند از:

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

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

  • پرهیز از نوشتن منطق برنامه (logic) در تست ها (Avoid Test Logic)

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

  • Dynamically changing logic
  • Random-number generating
  • Thread-creating
  • File-writing

تست هایی که به هیولاهایی تبدیل شده اند و هر کدامشان مثل یک موتور تست نویسی می مانند!‌ متاسفانه چون این تست ها فقط و فقط [Fact] (یا [Test]) را بالای سر خود دارند، نویسنده به این موضوع که این تست ها نیز می توانند باگ داشته باشند یا باید به صورت قابل نگهداری (Maintainable) نوشته شود، توجه نکرده است. این گروه از تست ها (هیولا ها!!!) بیش از آن که باعث صرفه جویی در زمان شوند، باعث هدر دادن زمان برای رفع خطاها و باگ های خودشان می شوند.

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

اگر هر کدام از موارد زیر را در تست ها دارید، بدانید تست شما logic دارد که نباید داشته باشد:

  • switch, if or else
  • foreach, for, or while

اغلب اوقات تست که logic دارد، در حال تست کردن بیش از یک عملکرد است که این کار اصلا پیشنهاد نمی شود چون باعث کاهش خوانایی تست شده و تست ها را شکننده می کند. همچنین logic در تست پیچیدگی تست را بالا می برد که خود ممکن است موجب بروز باگ های پنهان در تست شود. تست ها به طور کلی باید یک سری از فراخوانی متدها بدون هیچگونه ترتیب خاصی باشند، حتی try-catch هم نباید داخل تست وجود داشته باشد چون logic محسوب می شود، و در نهایت شامل یک Assert باشد. هر چیز پیچیده تری که اضافه کنیم باعث بروز مشکلات زیر می شود:

  • خوانایی تست کم می شود و درک آن دشتوارتر خواهد شد.
  • بازسازی تست ها سخت تر خواهد شد (مثلا فرض کنید که یک تست Multithread یا تستی که از اعداد تصادفی استفاده می کند به صورت ناگهانی fail شود)
  • احتمال وجود باگ در تست و یا تست کردن عملکرد اشتباه، بالا است.
  • نام گذاری تست سخت می شود زیرا وظیفه این تست، تست کردن چند عملکرد متفاوت است و تمرکز روی یک چیز ندارد پس نام گذاری آن سخت تر خواهد شد.

همزمان در یک تست فقط و تنها یک عملکرد (Functionality) را تست کنید

اگر تستی که نوشته اید شامل بیشتر از یک Assert ساده است، احتمالا دارد بیش از یک عملکرد را تست می کند. این مشکل زمانی نمایان می شود که می خواهید این تست را نام گذاری کنید یا در زمان اجرای اولین Assert، تست fail می شود. نام گذاری تست ها ممکن است کار ساده ای به نظر برسد اما اگر درحال تست کردن بیش از یک عملکرد در یک تست هستید، ارائه یک نام خوب و مناسب برای تست که مشخص کند چه چیزی در حال تست شدن است تقریبا غیر ممکن خواهد شد. وقتی فقط یک عملکرد را تست می کنید، نام گذاری تست ها آسان خواهد شد.

در بیشتر فریم ورک های تست ( مثل XUnit, NUnit) وقتی تست fail می شود، یک Exception رخ می دهد و فریم ورک آن Exception را دریافت کرده و پیغام خطا را نشان می دهد. متاسفانه، طبیعت Exception این است که وقتی رخ می دهد، برنامه همانجا متوقف شده و خط بعدی اجرا نمی شود. به مثال زیر دقت کنید. اگر Assert اول (False()) fail شود، Exception رخ می دهد و بنابر توضیحات بالا Assert بعدی هیچگاه اجرا نمی شود.

آموزش تست نرم افزار

پیام های خطایی که Assert ها برمی گردانند را مانند علائم یک بیماری در نظر بگیرید. هرچه علائم بیشتری پیدا کنید، بیماری را راحت تر تشخیص می دهید. بعد از اولین failure، Assert های بعدی اجرا نخواهند شد و شما مشاهده دیگر علائم را که می توانستند حاوی اطلاعاتی ارزشمند به جهت پیدا کردن مشکلات دیگر باشند را از دست خواهید داد. اجرای چند Assert در یک تست پیچیدگی تست را افزایش می دهند و ارزش کمی نیز دارد. برای فهمیدن این که چه چیزی واقعا fail شده است، باید هر کدام از Assert ها را در یک تست جداگانه اجرا کنید.

اطمیان از Code Coverage

برای اطمینان از Coverage (به این معنی که چند درصد از کد شما تحت پوشش تست ها است و تست شده است) مناسب کدهای جدیدی که به سیستم اضافه می کنید، می توانید از ابزارهای Coverage مانند NCover, Resharper Code Coverage استفاده کنید. یکی از ابزارها را انتخاب کنید و دقت کنید که هیچگاه Coverage سیستم کمتر از ۲۰ درصد نباشد. کمتر از ۲۰ درصد به این معنی است که شما بسیاری از کدها را تست نکرده اید و به فکر توسعه دهنده بعد از خودتان که قرار است این برنامه را توسعه دهد نبوده اید. ممکن است او بخواهد اشتباها یک خط مهم از برنامه را حذف کند و این کار را انجام دهد بدون آن که تست ها جلویش را بگیرند زیرا اصولا تستی برای آن ناحیه از کد نوشته نشده است.

آموزش تست نرم افزار دوره آموزش تست نرم افزار آموزش Test Driven Development آموزش TDD&BDD آموزش نوشتن تست قابل اعتماد