در این سری مقالات میخوایم بااصول unit test و نحوه پیاده سازیش در Angular آشنا بشیم. بخش هایی که در این سری مقالات بررسی میکنیم شامل موارد زیر میشه:
بخش اول:
سه نکته:
۱. در این مقاله در مورد اینکه اول تست رو بنویسیم بعد کد رو پیاده سازی کنیم یا برعکس، صحبت نمیکنیم. در مورد تست های end to end و ابزارهاشون هم صحبتی نمیکنیم.
۲. نیازمندی های مطالعه این مقاله آشنایی کافی با جاوااسکریپت و فریم ورک انگولار هست.
۳.اگر پروژه ای دارید که از توسعه ش مدتی گذشته و بزرگ شده و میخواید شروع کنید براش تست بنویسید، با اولین قدم که اجرای تست هاست، ممکنه به انبوهی از خطاها برسید. لطفا حتما بیخیال نشید!! پیشنهاد میکنم تمرینات این مقاله و مقاله بعدی رو کامل بخوانید و با یک اپلیکیشن تازه و خالی تمرین کنید. به موضوع تسلط نسبی پیدا کنید و بعد خطاهای پروژه اصلی رو یکی یکی رفع کنید.
فایل تست های نوشته شده رو میتونید در گیت هاب ببینید. یا پروژه رو دانلود و نصب کنید.
لینک پروژه این سری مقالات: https://github.com/rohamtehrani/angular-unit-tests
این لیست صرفا خلاصه مطالعات و تجربیات من از نوشتن unit test هست:
یکی از مهم ترین مفاهیم unit test مفهوم mocking هست. هدف ایزوله کردن بخشی از کده که داریم تست میگیریم.
خیلی از کلاس ها و component ها ممکنه یک یا چند dependency داشته باشند. ممکنه اون بخش از کد که میخوایم تست بگیریم وابسته به یک dependency باشه. ما نمیخوایم عملکرد این dependency (ها) روی تست ما اثر بگذاره.
اینجاست که مفهوم mocking مطرح میشه. در واقع بجای آبجکت اصلی dependency که کد ما بهش وابسته ست، کلاس یا سرویس مشابهی میشینه و ادای کد اصلی رو در میاره. در واقع عملکرد مورد نظر ما رو برای تست پیاده میکنه.
این کار به ما اجازه میده که در یک تست، فقط کد مدنظر مون رو تست بگیریم و عملکرد درست یا غلط dependency ها رو حذف کنیم.
چهار نوع Mock داریم:
مدل Dummies:
فقط جای آبجکت اصلی میشینه و property ها و متدهای مشابه و لازم برای تست رو شبیه سازی میکنه. هیچ حرکت دیگه ای انجام نمیده.
مدل Stubs:
آبجکتی که کارش بررسی صدا زدن ها (calls) و نتیجه ها (results) ست.
مدل Spies:
به ما اطلاع میده که چه متدی صدا شده یا چند بار صدا شده یا چه پارامترهایی برای صدا زدنش استفاده شده.
مدل True Mocks
رفتاری را در dependency پیاده سازی میکنه که ما موقع تست کدمون انتظار داریم. صرف نظر از اینکه رفتار واقعی dependency درسته یا نه.
لازمه دو تا نکته رو در نظر داشته باشیم:
در Angular دو مدل unit test داریم:
تست isolated
در این مدل تست، به طور خاص یک کامپوننت، سرویس یا پایپ رو دستی میسازیم و پارامترهای مورد نظرمون رو بهش پاس میدیم و رفتارش رو بررسی میکنیم.
تست integration
در این مدل تست، ما یک ماژول رو دستی میسازیم . کامپوننت مون رو داخلش اجرا میکنیم . کامپوننت و تمپلتش رو بررسی میکنیم.
تست integration رو به دو صورت انجام میدیم
فریم ورک Angular پس از نصب شدن، دو ابزار رو برای نوشتن unit test در اختیارمون قرار میده.
کتابخانه Karma یک test runner هست که تست ها رو روی مرورگر اجرا میکنه.
کتابخانه Jasmine ابزارهایی برای ساخت mock ها و بررسی رفتار کد رو در اختیارمون قرار میده
ابزارهای دیگه ای هم هستند که برای نوشتن unit test ها در Angular میتونند استفاده بشن:
Jest
Mocha/Chai
Sinon
TestDouble
Wallaby
لیست شون رو نوشتم که اگر دوست داشتید بهشون سر بزنین و در موردشون مطالعه کنین.
داخل پوشه /src/app یک فایل جدید میسازیم به اسم first-test.spec.ts .
به صورت پیش فرض، هنگام اجرای تست ها، همه فایل هایی که اسمشون به .spec.ts ختم میشه، فایل تست در نظر گرفته میشن
اول کد رو ببینیم و بعد بریم سراغ توضیحش
در ادامه هر قسمت رو توضیح میدم. اگر لازم بود، لطفا برگردید و کد رو مرور کنید.
تابع describe
یک تابع از کتابخانه Jasmine که به ما اجازه میده یک گروه از تست ها رو بنویسیم.
این تابع دو تا ورودی داره. پارامتر اول یک متن توضیح درباره گروه تسته . پارامتر دوم یک تابع callback هست که داخلش کد تست ها رو می نویسیم
کلمه sut
مخفف System Under Test هست. شامل اون بخشی از کده که میخوایم بزاریمش زیر تست اتوماتیک. البته sut صرفا اسم متغیره. من معمولا از comp برای کامپوننت زیر تست، از service برای سرویس زیر تست و از pipe برای پایپ زیر تست استفاده میکنم.
تابع beforeEach
تست های اتوماتیک پشت سر هم اجرا میشن. یکی از اصولی که باید برای نوشتن تست در نظر بگیریم، اینه که هیچ تستی در نتیجه تست های دیگه مداخله نکنه. برای رسیدن به این هدف باید هر تست رو در محیط ایزوله شده خودش انجام بدیم.
وظیفه تابع beforeEach اینه که قبل از اجرای هر تست این گروه، اجرا بشه و یک محیط ایزوله رو برای تست پیش رو فراهم کنه. در واقع هر تست رو به وضعیت اولیه برمیگردونه
تابع it
هر تست رو داخل یک تابع it مینویسیم. این تابع شامل دو پارامتر ورودیه. پارامتر اول توضیح مربوط به اون تست و پارامتر دوم یک تابع callback هست که داخلش کد تست مون رو مینویسیم.
بهتره طوری توضیح رو بنویسیم که وقتی توضیح describe رو میخوانیم و بعدش توضیح it رو میخوانیم، انگار داریم یک جمله کامل از تست رو میخوانیم:
مثلا داریم یک گروه تست برای OrderService مینویسیم و میخوایم تابع getOrder رو تست کنیم:
Order Service getOrder should retrieve the correct order
یا مثلا این طوری هم میتونیم دسته بندی کنیم:
الگوی AAA
هر unit test شامل یک الگوی سه مرحله ایه که همشون با A شروع میشن:
در مرحله Arrange همه شرایط لازم برای اجرای تست مون رو فراهم میکنیم
در مرحله Act کد هدف رو اجرا میکنیم و در واقع زیر تست میبریمش
در مرحله Assert بررسی میکنیم ببینیم به نتیجه مورد نظر رسیدیم یا نه ( وضعیت جدید درسته یا خیر )
مثال اول یک مثال ساده بود. در مثال های بعدی عمیق تر با این الگو آشنا میشیم.
مفاهیم DAMP و DRY
مفهوم DRY یا Don't Repeat Yourself رو باهاش آشنایی داریم. یعنی خودت رو تکرار نکن. در واقع کد تکراری داخل برنامه ننویسیم.
مفهوم DAMP مخفف Descriptive And Meaningful Phrases هست. یعنی کد تست ما روایتگر رفتار کد ماست. در واقع موقع نوشتن تست، داریم یک داستان کامل از رفتار کد رو به صورت قابل فهم توصیف کنیم. پس بهتره کدهای تست قابل فهم و واضح نوشته بشن.
در واقع اگر تکرار برخی از قسمت های تست به روایت ما از کد کمک کنه، میتونیم هر چقدر که لازمه تکرارش کنیم. این قانون نیست بلکه ترجیحه.
تکنیک هایی هست که به پیاده سازی مفهوم DAMP کمک میکنه:
اگر فایل package.json رو باز کنیم، در بخش scripts یک لیست از اسکریپت ها رو میبینیم:
اسکریپتی که الان باهاش کار داریم، test هست. یعنی میتونی به دو صورت اجراش کنیم:
که در واقع دستور ng test رو اجرا میکنه . یا مستقیما دستور تست رو اجرا کنیم:
بعد از اجرای دستور تست، یک مرورگر باز میشه و شما میتونید فضای کلی karma رو ببینید که تست ها داخلش اجرا شدند:
انتهای command line هم می تونیم خلاصه ای از تست های اجرا شده رو ببینیم.
بریم سراغ نوشتن تست برای پایپ ها که ساده ترین قسمت نوشتن تست در Angular هست. پایپ ها در واقع یک کلاس هستند که تابع transform دارند. این تابع یک ورودی میگیره و خروجی تحویل میده.
فرض کنید واحد پولی که اپ بر اساسش کار میکنه ریاله. ولی ما میخوایم به کاربر تومن نمایش بدیم. پس یک pipe داریم که قیمت ها رو به ریال میگیره و به تومن برمیگردونه:
اول میخوایم مطمئن بشیم پایپ به درستی ساخته میشه. بعد میخوایم مطمئن بشیم اگر عدد ۱۰۰۰ رو بهش بدیم، عدد ۱۰۰ برمیگردونه. به توابع it اولیه نگاه کنید. مراحل الگوی AAA رو هم نوشتم:
حالا به تست های زیر دقت کنید.
نکته اول:
قسمت Arrange از تست ها برداشته شده و داخل beforeEach نوشته شده. در واقع ساخت آبجکت پایپ هم Arrange حساب میشه هم ایزوله کردن محیط تست هست. درباره این موضوع که ساخت آبجکت کجا اتفاق بیفته ، بین علما اختلافه :)) درواقع الگوی AAA داخل تابع it رعایت نشده.میتونیم سفت و سخت بگیریم یا نگیریم. به نظر من مهم رفتار کده که درست پوشش داده بشه و اینجا درست پوشش داده شده.
نکته دوم:
به داستانی دقت کنیم که تست ها از TomanPipe برامون روایت میکنن:
هر تغییری که به رفتار پایپ بدیم، روایتی ازش باید در کدهای تست باشه.
مثلا قراره به ازای مقدار null عدد صفر رو برگردونه. پس تست زیر رو هم اضافه میکنیم:
حالا باید کد پایپ رو طوری عوض کنیم که نتیجه مورد نظر ما رو به درستی برگردونه:
کد جدید پایپ تغییر کرده و تست بالا رو پاس میکنه. اگر میخوایم تعریف دقیق تری از رفتار پایپ داشته باشیم باید به فایل تست ، داستان های بیشتری اضافه کنیم.
تست ها همیشه بخشی از رفتار کد رو پوشش میدن (بین صفر ! تا صد !). وقتی باگی پیدا میشه یعنی بخش مهمی از رفتار کد، توسط تست ها پوشش داده نشده. بنابراین الزاما پوشش ۱۰۰٪ رفتار کد بهترین حالت نیست. هدف نهایی از نوشتن تست کد جلوگیری از باگ های احتمالی و تکرار باگ های گذشته ست.
میزان پوشش رفتار کد (٪) هدف اصلی نوشتن تست نیست.
با توجه به قسمت های قبل، دیگه تو نوشتن کدهای تست مشکلی نداریم. فقط باید ببینیم سرویس رو چطوری تست کنیم.
فرض کنید یک سرویس ساده به اسم MessageService داریم در فایل message.service.ts :
میخوایم
ببینید چطور کد تست زیر در فایل message.service.spec.ts با روایت های بالا همخوانی داره.
فرض کنید برای کامپوننت Heroes میخوایم تست بنویسیم در فایل heroes.component.ts:
این کامپوننت یک لیست بنام heroes داره و سه تابع بنام های getHeros، add و delete
شروع میکنیم به نوشتن فایل تست heroes.component.spec.ts :
همون طور که می بینیم، در قسمت constructor کلاس کامپوننت، ما سرویس Hero Service رو inject کردیم به کلاس. بنابراین برای ساخت کامپوننت بهش نیاز داریم. موضوع اینه که نمیخوایم از سرویس واقعی Hero Service استفاده کنیم. چون ممکنه باگ های اون سرویس روی نحوه کار کامپوننت ما تاثیر بگذاره . ما میخوایم کامپوننت رو در محیط ایزوله ای تست کنیم و از رفتارش مطمئن بشیم.
پس اینجا نیاز داریم یک Mock از سرویس Hero Service بسازیم. بریم یک نگاه کنیم ببینیم سرویس Hero Service در کامپوننت ما چطوری استفاده شده؟
همون طور که در مثال زیر مشخصه ، سه تابع این سرویس در کامپوننت استفاده شده:
بنابر این به کمک ابزار jasmine.createSpyObj یک آبجکت از heroService میسازیمو به کامپوننت پاس میدیم:
در قدم بعدی میریم روی تابع delete تست مینویسیم ببینیم درست کار میکنه یا نه :
بعد از یک مدت دیگه نیاز نیست مراحل رو با کامنت توضیح بدیم. ولی فعلا برای مرور مراحل الگوی AAA کامنت میگذارم.
خب حالا وقتی تست رو اجرا میکنیم به مشکل میخوریم:
خطا میگه که تست موقع صدا زدن subscribe به مشکل خورده. وقتی برگردیم و کامپوننت رو نگاه کنیم، می بینیم که داخل تابع delete کامپوننت، به صورت زیر تعریف شده:
در واقع سرویس heroService تابع deleteHero رو صدا کرده و بعد subscribe رو صدا کرده. درحالیکه در mock object ما امکانی برای subscribe وجود نداره. پس باید یک کم mock object رو تغییر بدیم. آماده کردن mock object در فضای Arrange تست انجام میشه:
در واقع در کد بالا، تابع deleteHero رو طوری تغییر دادیم که وقتی صدا زده میشه، یک observable برگردونه که بشه داخلش subscribe کرد.
در صورت نیاز به آشنایی با کتابخانه RxJS و توابعش مثل of یا subscribe میتونید این مقاله رو مطالعه کنید.
با تغییر mock object تست ما آماده اجراست و با موفقیت کار میکنه.
حالا میخوایم بررسی کنیم و ببینیم وقتی که تابع delete کامپوننت رو صدا میزنیم، حتما تابع deleteHero از سرویس Hero Service صدا زده میشه.
برای این کار یک تست دیگه به فایل تست مون اضافه میکنیم:
برای این کار، کتابخانه jasmine تابع toHaveBeenCalled رو در اختیارمون قرار داده.
اگر به کد کامپوننت بریم و خطی رو که تابع deleteHero صدا زده شده رو کامنت کنیم، تست مون به خطا میخوره و این یعنی درسته:
می بینیم که تست مون به خطا میخوره:
تست درست، شرایط درست رو پاس میکنه و شرایط غلط رو پاس نمیکنه. قرار نیست تست ما صرفا کدی رو که نوشتیم تایید کنه.
برای تست نحوه صدا کردن deleteHero، شاید اینکه فقط صدا زده میشه یا نه مد نظرمون نباشه. شاید بخوایم ببینیم تابع با پارامتر درستی صدا زده میشه یا نه. برای این تست از تابع toHaveBeenCalledWith استفاده میکنیم:
همینطور ممکنه بخوایم ببینیم تابع deleteHero فقط یک بار صدا زده میشه. دوبار یا بیشتر صدا زده نمیشه:
برای خلاص شدن از دست sourcemap ها میتونید با اضافه کردن یک flag بنام source-map-- به دستور ng test اون ها رو حذف کنید:
قبل از اینکه بریم سراغ تست های integration یه نکته خیلی مفید رو مرور کنیم. وقتی نرم افزار بزرگ و بزرگتر میشه تعداد تست ها بیشتر و بیشتر میشه. قابل تصوره که در یک نرم افزار توسعه یافته، با اجرای ng test حجم زیادی از تست ها با هم اجرا بشن.
فرض کنیم که الان میخوایم یک کامپوننت جدید بنویسیم و میخوایم تا انتهای توسعه کامپوننت فقط سناریوهای همین کامپوننت رو تست کنیم. کاری با بقیه سیستم نداریم. چه کار کنیم؟
دو راه برای این موضوع هست.
راه حل اول : استفاده از fdescribe و fit
وقتی پیشوند f ( مخفف force ) رو به به توابع describe و it بچسبونیم، test runner همه سناریوهای موجود در نرم افزار رو جکع آوری میکنه. ولی فقط اونهایی رو اجرا میکنه که در fdescribe و fit تعریف شدند. بقیه سناریوها رو در حد نمایش و به صورت disable نمایش میده.
بیایم این وضعیت رو روی تست Heroes Component اجرا کنیم و تست اولش رو از it به fit تغییر بدیم:
نتیجه اجرای تست هامون به صورت زیر میشه:
همه تست ها شامل ( TomanPipe و Heroes Component ) جمع آوری شدند ولی فقط سناریوی delete hero اجرا شد.
راه حل دوم: استفاده از پارامتر include هنگام اجرای ng test
در این حالت فقط فایل هایی تست میشن که شامل تعریف مسیر میشوند.
میتونیم مستقیم آدرس یک فایل تست رو به پارامتر include بدیم و فقط روی همون فایل تست بگیریم.
میتونیم بیش از یک بار از پارامتر include استفاده کنیم.
در راه حل اول
ولی در راه حل دوم
در مقاله بعدی ادامه بحث تست Angular رو با intergration test پیش میبریم.
برای مشاهده پست های بیشتر و ارتباط با من از طریق لینکدین اینجا کلیک کنید.
صفحه لینکدین من : https://www.linkedin.com/in/rohamtehrani
چند تا مقاله دیگه رو هم لینک میدم که اگر دوست داشتید مطالعه کنید:
کلاس های standalone در Angular
آشنایی با انگولار سیگنال ، فیچر جذاب نسخه ۱۶
تشخیص آنلاین/آفلاین بودن اپلیکیشن در انگولار
موفق باشید:)