در این مقاله به بررسی روش ها ، ابزار ها و چالش های تست نویسی که خودمان با آن ها مواجه شدیم خواهیم پرداخت .
همچنین نیاز است که آشنایی اندکی با unit test ها در اندروید داشته باشید و حداقل چند تست نوشته باشید .
بزارید مقاله رو با این سوال شروع کنیم ، اصلا چرا باید تست بنویسیم ؟
خب این سوال قبلا خیلی مطرح تر بود و هر وقت در مورد تست صحبت میکردی یه عده دولوپر ، پا برهنه میپریدن وسط صحبت و کلا لزوم تست نوشتن برای اپلیکیشن ها رو زیر سوال میبردن .
اما مدتی بعد وقتی شرکت های بزرگ تر تست نویسی رو وارد استاندارد های توسعه خودشون کردن و برنامه نویس هارو ملزم به تست نویسی کردن فضا عوض شد .
کم کم حتی شرکت های دیگه هم شروع به اجرای این قوانین کردن و این فرض که تست نوشتن فقط برای اپلیکیشن های بزرگه رو کم رنگ کردن و سوال چرا باید تست بنویسیم به سوال چطور تست بنویسیم تغییر پیدا کرد .
خوشبختانه در ایران هم شرکت ها به این سمت حرکت کردن و دیگه دیدن اگهی استخدامی که یکی از شرایطش تست نوشتن باشه تعجب برانگیز نیست .
اما بریم سراغ سوالمون :
بزارید با یه مثال شروع کنیم ، فرض کنید یه خودرو خریدید ، یکی از اقداماتی که باید انجام بدید بیمه کردن ماشینتونه . احتمالا کار سختیه چون تقریبا هممون میدونیم که پولی که میپردازیم یه هزینه اضافیه
اما ایا این هزینه رو نمیپردازیم ؟ ما حتی ماشینمون رو بدون بیمه تو خیابون هم نمیاریم . چرا ؟
چون این شرط عقله (یکبار این هزینه رو میپردازی اما تا مدت ها خیالت راحته)
تست نوشتن هم شاید از نظر بعضی ها یک هزینه ی اضافی به نظر برسه
ایدشون هم اینه که زمانی که برای یادگیری ، نوشتن و نگهداری تست ها گذاشته میشه زمان زیادیه و همون زمان رو میشه برای توسعه فیچر های جدید گذاشت
تجربه چندین ساله دنیای نرم افزار ثابت کرده که زمانی که برای تست ، ریفکتور و استاندارد کردن کد ها گذاشته میشه به هیچ عنوان هدر دادن زمان نیست بلکه حتی باعث حفظ زمان شما نیز میشود .
مثال بیمه ماشین رو به خاطر بیارید ، آیا شما آن هزینه رو پرداخت نکردید ؟ ؟
در مورد این سوال ساعت ها میشه بحث کرد اما اجازه بدید تا به ذکر موردی برخی از ویژگی های تست نوشتن اکتفا کنیم:
مشخصا تست نوشتن ویژگی های زیادی داره که ذکر همه ی آن ها نیاز به یک مقاله جدا داره اما اینکه ما از همه ی خواص تست نویسی بهره مند بشیم به تست هایی که نوشته ایم بستگی دارد
آیا تست های ما اصولی بوده اند ؟ آیا تمام سناریو های احتمالی را بررسی کرده ایم ؟ ایا تست های ما code coverage خوبی داشته اند ؟
تست نوشتن یک مهارته ، چیزی نیست که یک شبه بشه بهش رسید و نیاز به تلاش مداوم داره
هر چه قدر هم که در تست نوشتن تجربه داشته باشید نمیتونید تضمین کنید که میتونید برای هر کدی تست بنویسید چون اونوقته که ، سوپرایز سوپرایز یه چالش جدید بهتون لبخند میزنه
همچنین تست نوشتن و سرو کله زدن با چالش هاش میتونه خیلی جذاب باشه.
خصوصا لحظه ای که اون تست قرمزه که کلی درگیرش شده بودید سبز میشه .
حالا اجازه بدید تا یک فرمول اساسی برای نوشتن یونیت تست ها اراِئه بدیم :
1) از کلاسی که میخواهید برای آن تست بنویسید ، آبجکت بگیرید(ماک نکنید)
2) دیپندنسی ها و api های اندروید رو که بهشون احتیاجی ندارید رو ماک کنید
3) عملکرد کد را به لحاظ آن چه مورد انتظار است بررسی کنید (کاری به implementation نداشته باشید)
اکنون به توضیح هر مورد خواهیم پرداخت:
مفهوم این جمله ، ساده اما مهم است
فرض کنید که میخواهید برای کلاس A تست بنویسید ، شما به ابجکتی از آن کلاس نیاز دارید که شامل تمام خواص آن کلاس از جمله پیاده سازی متد ها و ... باشد
اگر آن را ماک کنید ، آبجکت ماک شده همه ی خواص آن کلاس را نخواهد داشت .(دلیلش رو بعدا توضیح میدیم)
در این جا انتظار میره که شما با Mockito کار کرده باشین و باهاش آشنا باشید
هر چه قدر که درک بهتری از ماکیتو داشته باشین تست نوشتن براتون کار راحت تری میشه
معمولا بسیاری از چالش ها و ارور های unit test ها مربوط به همین ماکیتو است بنابراین سعی میکنیم در این مقاله با مفاهیم ماکیتو و چالش های اون بیش تر آشنا بشیم
ابتدا ماک کردن رو بیش تر توضیح میدیم :
فرض کنید که کلاسی به نام A دارید که قراره محاسباتی رو روی یه عدد انجام بده و اون عدد رو خروجی بده
در قسمتی از از این کد از تابعی استفاده کردید که عمل جمع رو انجام میده و این تابع مربوط به api خود اندرویده (شما ننوشتیدش)
class A{ fun cal(input : Int):Int{ // محاسبات شما val num=Math.sum(input) //محاسبات شما } }
در مثال بالا فرض کنید که sum مربوط به api های اندرویده
حالا شما میخواهید برای کلاس A تست بنویسید تا ببینید به ازای input مثلا 2
خروجی تابع cal برابر با 100 میشود یا نه .
در این مثال ما باید تست رو با فرض درست بودن تابع sum انجام بدیم چون هدف ما تست عملکرد جمع نیست
در این جا عملکرد تابع sum اهمیتی در تست ما نداره و فقط خروجی اون مهمه پس ما اون رو ماک میکنیم و خروجی ای که لازم داریم رو براش مشخص میکنیم
val mock=Mockito.mock(Math::class) Mockito.when(mock.sum(2)).thenReturn(5)
در رشته کد بالا ما از کلاس Math یک ابجکت ماک شده گرفتیم و برای تابع sum از کلاس math تعریف کردیم که به ازای ورودی 2 به ما خروجی 5 دهد .
حالا به کلاس A بر گردیم حین اجرای تست ، وقتی به تابع sum میرسیم بدون در نظر گرفتن عملکرد sum خروجی 5 برای ما return میشود و متغیر num مقدار 5 می گیرد.
حالا اگر کلاس Math وتابع sum رو خودمون نوشته بودیم چه میشد؟
با فرض صحیح بودن عملکرد آن باز هم آن را ماک میکردیم زیرا عملکرد تابع sum در تست ما موضوعیتی نداره
اگر به دنبال بررسی عملکرد تابع sum هستید باید برای کلاس Math تستی جداگونه بنویسید و عملکرد آن را در اون تست مورد بررسی قرار بدهید .نه در کلاس A که هدف دیگری را دنبال میکند .
تا این جا باید با دلیل ماک کردن دیپندنسی ها آشنا شده باشید ، حالا با آبجکت های ماک بیش تر آشنا میشویم:
توجه به این مفهوم بسیار بسیار مهم است و از مشکلات بسیار زیادی در نوشتن تست ها جلوگیری می کند .
به شکل زیر نگاه کنید :
کادر آبی رنگ یک آبجکت حقیقی از کلاس A و قسمت زرد رنگ آبجکت ماک شده از همان کلاس A را نشان میدهد.
چه تفاوتی میبینید ؟ بله آبجکت ماک شده شامل پیاده سازی متد ها نیست و از آن ها خبر ندارد
اگر بگوییم A.sum(1,2) چه اتفاقی می افتد ؟ واضح است مقدار محاسبه میشود
اما اگر همین کار را با ابجکت ماک شده کنیم چه اتفاقی می افتد ؟
تابع sum همواره نال بر میگرداند و Mockito ارور زیر را میدهد
wanted but not invoked ..
there is Zero interaction with this mock
این ارور را در ذهن خود داشته باشید ، احتمالا با آن زیاد مواجه خواهید شد
اما چرا ؟ این منطقی است ، وقتی ماک میکنید به این معناست که عملکرد آن کد برایتان مهم نیست
فقط به دنبال آن هستید که آن قطعه کد انتظار شما را بر آورده کند
اصل اول فرمول unit test نویسی که در بالا به آن اشاره شد را به خاطر بیاورید
به همین خاطر گفتیم که کلاسی که میخواهید تست کنید را ماک نکنید !!
اما برای رفع این ارور باید چه کاری انجام بدهیم ؟
ساده است ، انتظار خودتان از آن متد را مشخص کنید . اینگونه :
Mockito.when(mockObject.sum(1,2)).thenReturn(3)
اکنون با خیال راحت از ماک آبجکت خود استفاده کنید .
به همین خاطر است که به ابجکت های ماک شده ، آبجکت مجازی نیز گفته میشود .
البته در Mockito آبجکت دیگری تحت عنوان Spy نیز وجود دارد .
این ها (Spy) به آبجکت های جاسوس نیز معروفند . Spy ها به نوعی در میان Real Object ها و Mock Object قرار میگیرند و عملکردی دوگانه دارند .
برای ماک کردن اینترفیس ها مجبور به استفاده از Spy هستید ، چون اینترفیس ها کلاس های بدون پیاده سازی اند.
تا اینجای کار با فهم عملکرد ماکیتو از مشکلات بزرگی جلوگیری کردیم :
حالا در این جا به نکته ی کوچکی اشاره میکنم :
درست است که ماک آبجکت ها فاقد پیاده سازی کد ها هستند اما آیا میتوان برای کدی که عملکرد اشتباهی دارد ماک درست کرد و با Mockito.when از آن انتظار خروجی درست داشت ؟ خیر
اصولا ماکیتو توانایی ماک کردن کلاس های final را ندارد , از آن جایی که کلاس ها در کاتلین به طور پیشفرض final هستند برای جلوگیری از ارور باید لایبرری mockito - inline را به پروژه کاتلینی خود اظافه کنید:
// https://jarcasting.com/artifacts/org.mockito/mockito-inline/ implementation("org.mockito:mockito-inline:4.2.0")
این لایبرری مثل یک اکستنشن به Mockito اصلی پروژه اظافه میشه و باعث میشه که دیگه ارور final کلاس هارو نبینید .
حتی اگر تست هاتون بدون مشکل اجرا میشن فراموش نکنید که این لایبرری رو به پروژه های کاتلینی خودتون اظافه کنید .?
این یکی از ارور های رایج در استفاده از ماکیتو است که میگوید "متد ماک نشده اما از آن استفاده شده"
ابتدا یکی از شرایط وقوع این مشکل را بررسی می کنیم ، فرض کنید که در بخشی از کدتان که قصد تست آن را دارید از یکی از api های اندروید استفاده کرده اید (منظور کد های موجود در android sdk است مثلا از کلاس Intent یا...)
خب همانطور که قبلا گفته شد آن را ماک می کنیم اما حین اجرا باز هم به ارور Method Not Mocked بر میخوریم مشکل از کجاست ؟ ما که آن را ماک کرده ایم?
اگر خود کلاسی (از Api اندروید) که ماک کرده اید به یک کلاس دیگر دیپندنسی داشته باشد آن وقت چه ؟
واضح است باید دیپندنسی آن را نیز ماک کنیم تا به ارور Method Not Mocked بر نخوریم
اما یک سول مهم : اگر تعداد دیپندنسی ها زیاد بود یا ماک کردن همه ی آن ها کاری پیچیده بود آن وقت چه ؟
راحت است ! کد زیر را به گردل (سطح ماژول) اظافه کنید :
//kotlin android{ testOptions { unitTests{ isReturnDefaultValues=true } } }
//Groovy android { ... testOptions { unitTests.returnDefaultValues = true }
در کد بالا ما به گردل گفتیم که در یونیت تست ها مقدار های پیشفرض return شوند ، اما چرا؟
اجازه دهید تا با جزئیات بیش تری این مورد را بررسی کنیم :
اندروید برای اجرای یونیت تست ها از فایل android.jar استفاده میکند . این فایل شامل هیچ کد حقیقی ای نیست و از پیاده سازی کد ها اطلاعی ندارد ، فقط از آنها یک تصویر در اختیار دارد .
به همین دلیل تمام متد های sdk به طور پیشفرض exception پرتاب میکنند .
با اظافه کردن کد بالا به گردل شما رفتار android.jar را تغییر دادید
یعنی در متد ها به جای پرتاب اکسپشن مقدار دیفالت را بر میگرداند
به همین خاطر از شر ارور های Method Not Mocked خلاص شدیم .
هشدار:
راه حل بالا اگر چه بسیار کار ساز است اما باید توجه داشت که به عنوان آخرین روش مورد استفاده قرار گیرد .
زیرا ممکن است باعث pass شدن تست های مشکل دار شود .
پاور ماک یک لایبرری قدرتمند برای ماک کردن است که از امکان رفلکشن استفاده میکند .
اصولا Mockito توانایی ماک کردن static ها ، final ، private ، و کانسترکتور ها در کلاس ها و متد ها را ندارد
برای ماک کردن این نوع از کلاس ها از پاور ماکیتو استفاده کنید .
در مواردی که در unit test به بن بست میرسید استفاده از این ابزار را فراموش نکنید.
این ارور نیز یکی دیگر از ارور های رایج در ماکیتو است
تصور کنید تستی را می نویسید ، آن را اجرا می کنید . همه چیز بدون مشکل است و تست شما پاس می شود .
اما به محض آن که همه ی تست های موجود در تست کلاس خود را اجرا می کنید علارغم پاس شدن همه ی تست ها با این ارور نیز مواجه میشوید .
هر گاه برای یک ابجک ماک شده شرایطی را تعریف کنیم (استفاده از Mockito.when)
در چند مورد با این ارور مواجه میشویم:
1) شرایط (when) مورد استفاده قرار نگیرد.
این کد ، کد مرده حساب میشود و طبق دایکیومنت mockito به جهت جلوگیری از سر بار باید حذف شود
2) منطق اشتباهی در حال mock شدن است { در منطق تجدید نظر شود }
3) قبل از اجرا شدن when تست fail میشود
این ارور از ماکیتو ورژن 2 به بعد به جهت
1) Detect unused stubs in the test code
2) Reduce test code duplication and unnecessary test code
3) Promote cleaner tests by removing ‘dead' code
4) Help improve debuggability and productivity
داده میشود.
به منظور اصلاح ارور ، توسعه دهنده یا باید موارد شامل ارور را مرتفع سازد یا آن هارا نادیده بگیرد .
به منظور نادیده گرفتن یک شرط میتوان از کد زیر استفاده کرد . که به آن خنثی سازی ملایم نیز گفته میشود
Mockito.lenient().when()
اما برای سرکوب تمام exception ها میتوان از کد زیر در بالای تست کلاس استفاده کرد
@RunWith(MockitoJUnitRunner.Silent.class)
اما طبق داکیومنت ماکیتو این روش توصیه شده نیست و توسعه دهنده باید موارد شامل ارور را بر طرف کند.
نکته : در بسیاری از موارد با حذف کردن یکی از شرایط (Mockito.when) که در تست کاربردی ندارد میتوان این ارور را برطرف کرد
در این جا ما با مهم ترین ابزار ها و چالش های ماک کردن آشنا شدیم . در انتهای مقاله نیز با مثال هایی از ماکیتو آشنا خواهیم شد.
اکنون به سراغ اصل سوم از فرمولمان می رویم.
این اصل به ما میگوید که برای تست نویسی هدف مهم است نه مسیر !
با یک مثال توضیح میدهیم :
فرض کنید که متدی دارید که دو عدد را دریافت میکند و حاصل جمع آن ها را محاسبه میکند
در این جا چه چیزی باید تست شود ؟
ما عملکرد متد را به ازای ورود چند عدد مختلف تست میکنیم مشخصا این متد باید خروجی های مورد انتظار ما را حاصل کند .
اما این که این متد چگونه و با چه روشی عمل جمع را انجام میدهد برای ما اهمیتی ندارد (در واقع به پیاده سازی متد کاری نداریم )
رعایت این نکته باعث میشود که تست های نوشته شده اصولی باشند و درگیر جزئیات پیاده سازی نشوند.
با Assertation ها میتوان به این امکان دست یافت ، Assert ها کد هایی executable هستند که پاس شدن یا فیل شدن تست های ما به آن ها وابسته است .
این نکته نیز حائز اهمیت است گاهی از اوقات آن چه از یک متد انتظار می رود یک خروجی مشخص نیست بلکه انجام کاری (مثلا کال کردن یک متد دیگه است)
در این موارد به جای assert از متد verify در ماکیتو استفاده میکنیم :
Mockito.verify(mockedList,VerificationModeFactory.atLeast(1)).size()
در کد بالا مشخص کرده ایم که آیا متد ()size در آبجکت ماک شده ی mockedList حداقل 1 بار ( (1)atLast )
کال شده است یا خیر
نکته مورد توجه در verify این است که فقط روی آبجکت های ماک شده یا Spy کار می کند .
در نهایت نیز به بیان چند نکته ی کاربردی در تست نویسی میپردازیم:
1) در یونیت تست ها ابتدا سناریویی که تست در آن fail یا به مشکل میخورد را بررسی کنید .
زیرا ممکن است که در ابتدا با pass شدن تست تصور کنید که تست درستی نوشته اید در حالی که تست شما قبل از رسیدن به متدهای assert با شکست مواجه شده است ومتد های assert اجرا نشده اند . در نتیجه تست شما همواره پاس میشود حتی به ازای (1==2)assert
در نتیجه تستی که نوشته اید را از جهات مختلف با مقادیر متفاوت بررسی کنید .(اگر چه ، هر چه در تست نویسی ماهر تر شوید کم تر با این موضوع مواجه میشوید)
2) در تست نویسی بن بستی وجود ندارد پس اگر به مشکل خوردید به دنبال راه حل باشید .
گاهی اوقات ، راه حل در اصلاح کد های موجود است
گاهی اوقات در استفاده ی درست از ابزار ها،یا افزودن یک لایبرری به پروژه
گاهی اوقات راه حل در خلاقیت به خرج دادن است یا گاهی اوقات جستجوی بیشتر
پس برای خود محدودیتی قائل نشوید .
3) بعد از تکمیل کردن تست های یک تست کلاس ، در اندروید استادیو دوبار روی (Ctrl) کلیک کنید و در کادر باز شده یکی از اسکریپت های زیر را وارد کنید :
gradlew test //windows ./gradlew test //linux
بعد از اینتر زدن همه ی تست های پروژه شما شروع به اجرا میکند .
این در زمانی مناسب است که بخواهید از یک ابزار Ci /Cd استفاده کنید ، در نتیجه مطمئن خواهید شد که تست های شما در سرور های Ci نیز به درستی کار میکنند و pipeline را fail نمیکنند .
گاهی اوقات ممکن است که تست های شما در تست کلاس یا ماژول مشخصی درست کار کنند اما وقتی که اسکریپت بالا را اجرا میکنید و همه ی تست ها اجرا میشوند ، ممکن است متوجه مشکلی در یکی از تست های خود شوید .
نکته ی بعدی یه نکته خیلی مهم و کاربردیه :?
4) میتوانید برای آماده سازی شرایط تست ، در تست کلاس خود کد هایی را بنویسید تا عملیات تست نوشتن را آسان تر کنید.
مثل زیر را در نظر بگیرید :
class A { protected fun init() { } // will be called by internal logic }
همان طور که مشاهده می کنید کلاس A شامل متدی protected به نام init است
همان طور که می دانید در کاتلین متد های protected در پکیج های دیگر در دسترس نیستند .
بنابراین در تست هایمان با آبجکت گرفتن از A نمیتوانیم به init دسترسی داشته باشیم .
راه حل چیست ؟
شاید power Mock بتواند کمک کند ..
اما یک راه حل دیگر ساختن کلاسی کمکی است تا با ارث بری کردن از کلاس A به متد init دسترسی داشته باشد تا بتوانیم به جای کلاس A از آن استفاده کنیم .
طبق نکته ی گفته شده میتوانیم این کار را در خود تست کلاسمان انجام دهیم:
@SmallTest @RunWith(MockitoJUnitRunner::class) class BaseRepositoryTest { @VisibleForTesting private class AClassAccessor() : A { fun getInitMethod() = init() } @Test fun `test`() { AClassAccessor().getInitMethod() //access To init } }
همانطور که مشاهده میکنید در تست کلاس، یک کلاس دیگر با نام AClassAccessor ساختیم تا با ارث بری کردن از کلاس A بتواند دسترسی به متد protected را برای ما فراهم کند
حالا در تست هایمان میتوانیم از AClassAccessor استفاده کنیم تا با متد getInitMethod به init دسترسی داشته باشیم .
در واقع طبق نکته گفته شده با استفاده از چند خط کد توانستیم چالش دسترسی به متد های protected حل کنیم .
همچنین میتوانیم مقادیری که تست های ما به آن ها احتیاج دارند را در تست کلاس های خود جنریت کنیم .
به طور خلاصه تست کلاس های ما الزاما شامل تست متد ها(متد هایی که با @Test علامت گذاری شده اند) نیستند و میتوانند حاوی توابع و کلاس های دیگری باشند که به عمل تست نویسی کمک میکند .
در انتها نیز به بررسی یک مثال خواهیم پرداخت .
این مثال از آن جهت انتخاب شده است که شامل دو کامپوننت (live data , coroutines) است
تست نویسی برای این دو مورد شامل نکاتی است که آن ها را در این مثال بررسی میکنیم :
@HiltViewModel class LoginViewModel @Inject constructor(val loginUseCase: LoginUseCase) : BaseViewModel() { private var _liveData: MutableLiveData<LoginResponse> = MutableLiveData() val liveData = _liveData fun sendLoginRequest(phoneNumber: String) { viewModelScope.launch { val result = loginUseCase.run(LoginUseCase.Params(phoneNumber)) result.onSuccess { _liveData.postValue(it.result) } } } }
کد بالا ویو مدلی است که شامل یک تابع برای ارسال درخواست لاگین به سرور میباشد
لاگین ویو مدل یک useCase را که وظیفه ارسال درخواست را بر عهده دارد را به عنوان ورودی دریافت میکند .
همچنین این ویو مدل شامل لایو دیتایی است نتیجه ی درخواست لاگین در صورت success بودن برای آن فرستاده میشود .
اکنون به عملکرد تابع sendLoginRequest میپردازیم:
حالا به تست این کلاس خواهیم پرداخت:
/** * LoginViewModel unit test run on jvm */ @SmallTest @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) class LoginViewModelTest { companion object { const val PHONE_VALID_NUMBER = "09190000000" } @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @ExperimentalCoroutinesApi @get:Rule var coroutineTestRule = CoroutineTestRule() lateinit var viewModel: LoginViewModel @Mock lateinit var loginUseCase: LoginUseCase @Before fun setup() { viewModel = LoginViewModel(loginUseCase) runBlockingTest { `when`(loginUseCase.run(LoginUseCase.Params(PHONE_VALID_NUMBER))).thenReturn( Either.Right(BaseResult(1, "message", LoginResponse("Token", "Name"))) ) } @Test fun `success user login`() { runBlockingTest { viewModel.sendLoginRequest(PHONE_VALID_NUMBER1) val result = viewModel.liveData.getOrAwaitValueTest() assert(result.token == "Token") } } }
در پکیج Test(نه androidTest ) کلاس LoginViewModelTest را ایجاد کردیم:
در این جا برای استفاده از این انوتیشن باید لایبرری ماکیتو را به پروژه اظافه کرده باشیم
حالا به قوانین یا همان Rule ها میرسیم :
java.lang.NullPointerException at androidx.arch.core.executor.DefaultTaskExecutor.isMainThread(DefaultTaskExecutor.java:7) at androidx.arch.core.executor.ArchTaskExecutor.isMainThread(ArchTaskExecutor.java:116) at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:486) at androidx.lifecycle.LiveData.observeForever(LiveData.java:224)
همچنین لازم است بدانیم که برای استفاده از InstantTaskExecutorRule باید کتابخانه زیر را به پروژه اظافه کنیم:
androidTestImplementation("androidx.arch.core:core-testing:2.1.0")
در صورت عدم استفاده از این رول با ارور زیر مواجه میشویم:
Exception in thread "main @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
این رول به طور پیشفرض وجود ندارد و باید خودمان آن را بنویسیم
بنابراین در پکیج تست (پکیجی که یونیت تست های خود را در آن مینویسید)، کلاسی با نام CoroutineTestRule ایجاد میکنیم و کد های زیر را به آن اظافه می کنیم :
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description import kotlin.coroutines.ContinuationInterceptor @ExperimentalCoroutinesApi class CoroutineTestRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() { override fun starting(description: Description) { super.starting(description) Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) } override fun finished(description: Description) { super.finished(description) Dispatchers.resetMain() } }
بعضی از کلاس های استفاده شده در کد بالا از لایبرری coroutines test استفاده میکنند پس نیاز است آن را نیز به گردل سطح ماژول خود اظافه کنید :
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:x.x.x") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x")
به جای x.x.x ورژن کاتلین کروتین خود را وارد کنید .
این لایبرری امکان استفاده از runBlockingTest را نیز میدهد که به آن خواهیم پرداخت .
حالا میتوانید از این کلاس به عنوان یک Rule در کلاس های تست خود استفاده کنید .
به ادامه ی توضیح تست کلاس خود میپردازیم :
در این متد ابتدا یک آبجکت برای کلاسی که میخواهیم تست کنیم تهیه کرده ایم و ماک آبجکت loginUseCase را به آن پاس داده ایم .(ما نمیخواهیم که loginUseCase یک درخواست واقعی به سرور بفرستد بلکه تنها ورودی و خروجی ها آن برای ما مهم است ، پس آن را ماک کرده ایم)
در خط بعد به کمک Mockito.when انتظارات خود از loginUseCase را مشخص کرده ایم ، که به ازای ورود یک شماره تلفن مشخص باید خروجی ای از نوع کلاس Either که شامل یک ریسپانس مشخص است را برگرداند.
اما در این جا کاربرد runBlockingTest چیست؟
متد run از کلاس loginUseCase یک متد suspend است ، همانطور که میدانید متد های ساسپند در متد های ساسپند دیگر میتوانند کال شوند . اما متد تست ما نمیتواند suspend باشد بنابراین به پلی میان این دو نیاز داریم
runBlocking ها بلاک هایی هستد که میتوانیم کد های ساسپند را در آن ها اجرا کنیم .
اما RunBlockingTest یک بلاک ارائه شده در لایبرری coroutines test است که برای تست متد ها کاستومایز شده است .
از این بلاک استفاده کردیم تا متد run قابل اجرا باشد .
حالا به سراغ تست متد میرویم :
چه چیزی را میخواهیم تست کنیم ؟ میخواهیم بدانیم که اگر یوزکیس ما درخواستی را بفرستد خروجی مورد نظر حاصل میشود و آن خروجی به درستی به لایو دیتا ارسال میشود یا خیر .
بنبراین متد sendLoginRequest را با همان شماره تلفنی که برای ماک آبجکت مشخص کردیم فراخوانی میکنیم
اکنون لایو دیتا باید خروجی مورد انتظار ما را داشته باشد .
پس باید به محتویات آن دسترسی داشته باشیم (آن را observe کنیم )
اما observe کردن لایو دیتا یک چالش است ، چرا که ما در محیط تست هستیم و به همان صورت که در کد هایمان عمل میکردیم در این جا امکان پذیر نیست .
گوگل با ارائه یک کلاس کمکی این چالش را برطرف کرده است ، بنابراین یک کاتلین فایل با نام LiveDataUtilTest بسازید و کد های زیر را به آن اضافه کنید :
import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException /** * Gets the value of a [LiveData] or waits for it to have one, with a timeout. * * Use this extension from host-side (JVM) tests. It's recommended to use it alongside * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. */ @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun <T> LiveData<T>.getOrAwaitValueTest( time: Long = 2, timeUnit: TimeUnit = TimeUnit.SECONDS, afterObserve: () -> Unit = {} ): T { var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer<T> { override fun d(o: T?) { data = o latch.countDown() this@getOrAwaitValueTest.removeObserver(this) } } this.observeForever(observer) try { afterObserve.invoke() // Don't wait indefinitely if the LiveData is not set. if (!latch.await(time, timeUnit)) { throw TimeoutException("LiveData value was never set.") } } finally { this.removeObserver(observer) } @Suppress("UNCHECKED_CAST") return data as T }
با کمک متد getOrAwaitValueTest میتوانید هر لایو دیتایی در تست های خود را observe کنید .
حالا همانطور که در تست کلاس ، مشاهده می کنید ما لایو دیتا را به کمک این متد observe کرده ایم و خروجی آن که همان کلاس LoginResponse ای است که در ماک آبجکت خود مشخص کردیم را در یک متغیر result ریخته ایم .
پس زمانی که لایو دیتا داشتیم از InstantTaskExecutorRule و getOrAwaitValueTest
و زمانی که کروتین داشتیم از CoroutineTestRule و runBlockingTest استفاده می کنیم .
این تست پاس میشود چون آن چه انتظار داشتیم حاصل شده است.
ما در این مقاله با مهم ترین مواردی که برای نوشتن unit test ها به آن احتیاج داشتیم آشنا شدیم و فرمولی برای آن معرفی کردیم اما این تازه شروع ماجرا بود ، بقیه اش تمرین و پیگیری و استمرار است .
همانطور که در دبیرستان با حفظ همه ی فرمول های مشتق هم نمیشد به همه ی سوال های آن پاسخ داد و به تلاش و تمرین احتیاج بود .
این مقاله همه اش شامل تجربه ، مطالعه و تا حدودی درک شخصی من از مبحث تست نویسی بود
بنابراین اگر در جایی از آن ایرادی دیدید ، ممنون میشوم که آن را به من بگویید .
همچنین اگر سوالی داشتید همین جا در کامنت ها یا در لینکدین من بپرسید .تا جایی که اطلاع داشته باشم قطعا جواب میدم ?
مرسی که تا انتها همراه من بودید:))