یونیت تست در فلاتر: Business Component

موضوع TDD و یا توسعه با رویکرد تست، در روند توسعه‌ی یک برنامه همیشه بخش جدایی ناپذیری از علم برنامه‌نویسی بوده و ما امروز در این نوشته به بررسی نحوه تست‌نویسی در اپلیکیشن‌های فلاتری می‌پردازیم. در حال حاضر مهم‌ترین پکیج‌هایی که در این رابطه به کمک ما خواهند اومد پکیج‌های test و flutter test هستن که بخش عمده‌ای از کار رو برای ما آسون میکنن.در این مقاله ما یاد میگیریم که چطور برای بخش‌هایی (فانکشن‌هایی) از برنامه‌ی خودمون که جزئی از Business Logic نرم‌افزار ما هستنن، یونیت تست‌هایی در قالب درست و مفید پیاده کنیم.در رابطه با Business Components یا اجزای تجاری در کدهای یک برنامه باید گفت: به اون قسمت‌هایی از تکه کد‌های نوشته شده اطلاق میشه که وظیفه‌ی کارکرد درست در پلیکیشن رو برعهده دارن مثل محاسبات، ارتباط با سرور، ذخیره‌ی اطلاعات کاربر، نمایش دیتا درخواستی درست، تاریخچه و ... که همین موضوع نشون دهنده‌ی این مهم هست که یونیت‌تست‌های نوشته شده با این رویکرد اغلب از موضوع کارکرد درست ویجت‌ها و به شکل کلی، عملکرد UI اپلیکیشن مجزاست و به سراغ اون‌ها نمیره.خب بریم سراغ اصل مطلبفراهم کردن مبانیقبل از شروع به کار مطمئن بشید که اطلاعات قسمت dev_dependencies پروژه‌ شما در فایل pubspec.yaml به همین صورت باشه:dev_dependencies: flutter_test: sdk: flutterساختار فایل‌ها و پوشه‌هااز موارد مهمی که حتما باید حواسمون بهش باشه مسئله‌ی ساختار پوشه‌ها و فایل‌ها در پروژه‌ای هستش که در اون از یونیت‌تست استفاده میشه. همونطور که از اسمش پیداست unit test کارکرد صحیح هر یک از کوچیک ترین واحدهای اجرایی کدهای مارو در همه‌ی حالت‌های ممکن و انواع ورودی میسنجه پس مسئله‌ی دسترسی صحیح و البته سریع به هر یک از تست‌های نوشته شده برای بررسی‌های آینده‌ توسط توسعه‌دهنده بسیار مهم و حیاتی. از فواید رعایت این نکته در یک اپلیکیشن فلاتری میشه به موارد زیر اشاره کرد:جلوگیری از شلوغ شدن و به‌هم ریختگی در پوشه‌ی اصلی کدهای اپلیکیشن که درمورد فلاتر پوشه‌ی lib هست. دسترسی سریع به همه‌ی کدهای مربوط به بخش تست در صورت نیازآرامش ذهنی J و البته جابه‌جایی راحت در بین هر بخشپس در نهایت ساده‌ترین مثال از توضیحات بالا با رعایت ساختار درست در فایل‌های پروژه به صورت خواهد بود:lib/ - xyz/ - xyz.dart test/ - xyz/ - xyz_test.dart شکل کلی باید در هر دو پوشه‌ی test و lib کاملا یکسان باشه. اگر هر قسمتی از کدهای Business Logic شما که در نظر دارید برای اون‌ها تستی نوشته بشه در فایلی به اسم xyz.dart در مسیر lib/path/to/xyz قرار گرفته باشن، کد‌های مربوط به یونیت‌تست‌های اون‌ها هم باید دقیقا به همون شکل در فایلی با نام xyz_test.dart در مسیر test/path/to/xyz جا بگیرن.برنامه حملهچارچوب درست تست نویسی یک سری قوانین رو در برمی‌گیره.از گیفی یک کلاس با نام Calculator رو به این صورت تصور کنید:class Calculator { int add(int a, int b) { return a + b; } int subtract(int a, int b) { return a - b; } int multiply(int a, int b) { return a * b; } int divide(int a, int b) { return b == 0 ? 0 : (a ~/ b); // integer division } }اگر چه که نوشتن تست‌های درست و البته به‌درد بخور به شکلی یک هنر کدنویسی محسوب میشه و باید با توجه به نیازهای پروژه بررسی بشه، اما توسعه‌دهنده‌های زیادی تا به حال سعی داشتن تا یک استاندارد و قاعده‌ی کلی رو برای درک و پیاده‌سازی بهتر تست‌نویسی جا بندازن که به شکل خلاصه از این باید‌ها پیروی میکنه:１. یک لیست از همه‌ی ویژگی‌های یک تکه کد یا Business Component مورد نظرتون برای نوشتن تست تهیه کنید.２. در این مرحله باید تلاش کنید که هر یک از ویژگی‌های لیستتون رو به مولفه‌های کوچیکتر (Subcomponent) بشکنید.３. مرحله‌ی دوم رو تا جایی ادامه بدید تا به نقطه‌ای برسید که کدتون از وضعیت فعلیش به بخش‌های کوچکتری قابل تقسیم نباشه. از این کار اصطلاحا با اسم atomization یا اتمی‌سازی میشه یاد کرد.４. بعد از این که از قدم قبلی سربلند بیرون اومدید حالا باید دونه به دونه subcomponentهارو انتخاب کنیم.５. حالا باید برای هر مولفه‌ی اتمی و کوچیکی که انتخاب کردیم فهرستی از انتظارت و یا خواسته‌هامون از اون subcomponent آماده کنیم.６. و در نهایت برای هر کدوم از اون خواسته‌ها و یا انتظارات کد تستمون رو بنویسیم.در ظاهر مقداری پیچیده به نظر میاد اما با پیاده‌سازی تست روی مثالی که در نظر گرفتیم این موضوع رو بهتر درک می‌کنیم.بریم برای نوشتن تستخب برگردیم به سراغ همون مثال کلاس ٰCalculator. ما چطور میتونیم اون رو تست کنیم؟با توجه به چهارچوبی که در بالا گفته شد در ابتدا باید لیستی از ویژگی‌های مد نظرمون از کامپوننت (در اینجا کلاس Calculator) تهیه کنیم.1. جمع کردن2. تفریق کردن3. ضرب کردن4. تقسیم کردناز همین اول میتونیم متوجه بشیم که هر کدوم از این ویژگی‌ها به‌خودی‌خود atomic هستن و نمیتونیم به بخش‌های کوچیک‌تری تقسیمشون کنیم پس در حال حاضر از مرحله‌ی ۲ و ۳ عبور کردیم.در ادامه ما باید فهرستی از انتظارات خودمون رو با به توجه به جزئیات لازم در رابطه با هر کدوم از این subcomponent ها آماده کنیم:1. جمع کردنجمع تو عدد صحیح مثبت به درستیجمع دو عدد صحیح منفی به درستیجمع یک عدد صحیح منفی و یک صحیح عدد مثبت به درستی2. تفریق کردنتفریق دو عدد صحیح مثبت به درستیتفریق دو عدد صحیح منفی به درستیتفریق یک عدد صحیح منفی و یک عدد صحیح مثبت به درستی3. ضرب کردنضرب دو عدد صحیح مثبت به درستیضرب دو عدد صحیح منفی به درستیضرب یک عدد صحیح منفی و یک عدد صحیح مثبت به درستی4. تقسیم کردنتقسیم دو عدد صحیح مثبت به درستیتقسیم دو عدد صحیح منفی به درستیتقسیم یک عدد صحیح منفی و یک عدد صحیح مثبت به درستیخب این شد لیست خواسته‌هایی که ما داریم از هر subcomponent که از شکستن ویژگی‌ها به دست اومد.و حالا به عنوان نمونه میریم به سراغ نوشتن یک یونیت‌تستِ مامان برای قابلیت جمع (add) در پروژه‌ی خودمون.محتویات فایل‌های تستاز گیفی خب تا اینجا از مقدمات یونیت‌تست و مفاهیم اولیه شنیدیم اما حالا چگونگی پیاده‌سازی اون رو به صورت عملی با هم بررسی می‌کنیم. با فرض این که شما فایلی به اسم calculator_test در پوشه‌ی مناسب ساختید،‌ ادامه‌ی این آموزش رو با هم پیش‌میریم.مطابق با رویه‌ای که تا به حال دربارش صحبت کردیم ما باید برای هر یک از انتظارات/خواسته‌هامون از متد مدنظر، کدِ تست جدایی رو پیاده کنیم. بنابراین طبق تحلیلی که ما از کدمون داریم باید ۴ تا تکه کدِ تستی برای ویژگی add از کلاس calculator خودمون بنویسیم. البته شاید سوال پیش‌بیاد که پس چرا ما ۳ تا مورد رو در بالا قید کردیم که درسته اما اباید بگم که در ینجا ما ترتیب اعداد منفی و مثبت در ورودی رو هم در نظر گرفتیم. به صورتی که شاید عدد منفی اول وارد بشه و عدد مثبت دوم و بالعکس که در نهایت میشه چهار حالت برای هر متد از کلاس calculator.خب برای نمونه کد تست ما به این شرح:test('should return a + b when a and b are two positives.', () { // arrange Calculator calculator = Calculator(); int a = 10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); // assert expect(actualResult, expectedResult); });در این جا از تابع test استفاده کردیم که به لطف استفاده از پکیج test اون رو در اختیار داریم.توضیح کد تستورودی اول تابع تست یک رشته هستش که توضیح مشخصی از آزمونی(تست) داره که ما در اینجا انجام دادیم. با دقت به تکه کد بالا میتونیم ببینیم که این String از یک قالب مشخص پیروی میکنه که به این شکل می‌باشد:should [expectation] when [condition].به این صورت که در جمله‌ی بالا به جای کلمه‌ی expectation انتظار خودمون در خروجی این تست و در قسمت condition وضعیت بخصوصی که ورودی‌های کد تحت تست ما در این حالت قرار دارن رو وارد می‌کنیم. در نهایت از این توضیح یک پیام واضح دریافت میشه که در این قالب حضور داره:The test component should do A when the condition is X.پارامتر دوم تابع test هم یک تابع callback هست که محتویات اصلی تست در اون قرار میگیره.بدنه‌ی تستباز هم در این قسمت یک الگوی مشخص برای پیروی وجود داره که با نام قرارداد AAA شناخته میشه. Arrange و Act و Assert.چینش و مرتب کردن (Arange)این بخش از بدنه‌ی تست جایی هستش که شما حقایق و در حقیقت مواد مورد نیاز تست خودتون رو کنار هم می‌چینید و یا به اصطلاح set up تست رو انجام میدید. میشه گفت که شما مقداردهی‌های اولیه‌ای که تست شما به اون‌ها نیاز داره رو در این قسمت نگهداری می‌کنید. خط آخر و مهم‌ترین کدی که در این قسمت حضور داره متغیر expectedResult هست که شما در اون مقداری رو ذخیره می‌کنید که از اجرای درست تست انتظار دارید. میشه گفت خروجی این تست از کد تحت آزمون شما در صورت سربلندی، باید مساوی با مقدار این متغیر باشه اینطوری شما متوجه می‌شید که بله! کد شما در این حالت ورودی، خروجی درست و مورد انتظار رو ارائه میده.انجام تست (Act)ما در اینجا به‌طور خلاصه عمل اصلی تست رو انجام میدیم و تابع خودمون رو صدا میزنیم. و مقدار بازگشتی اون رو در متغیری به نام actualResult ذخیره می‌کنیم. در واقع اینجاست که ما تست خودمون رو انجام میدیم تا ببینیم مقداری که از تابع (subcomponent) خودمون برگشته با این ورودی‌ها و شرایط بخصوص، ‌چه چیزی هست.اثبات ادعا (Assert)همون‌طور که از اسم این قسمت مشخصِ شما در اینجا با مقایسه‌ی دو مقدار ”مورد انتظار” و”فعلی” (expectedResult and actualResult) ادعای خودتون مبنی بر درست کارکردن تیکه کدتون ثابت می‌کنید. اگر که مقدار متغیر expectedResult (نتیجه‌ی مورد انتظار) برابر با مقدار متغیر actualResult (نتیجه فعلی) باشه نشون‌دهنده‌ی کارکرد درست subcomponent هست و همونطور که انتظار می‌رفت مقدار بازگشتی صحیح رو در اختیار شما قرار داده که در این صورت تست شما با موفقیت انجام شده.شما این مقایسه رو میتونید در تمامی حالات مساوی بودن، نامساوی بودن،‌ بزرگتر و کوچیکتر بودن و ... انجام بدید.استفاده از الگوی AAA کمک شایانی به وضوح تست شما داره و به شکلی شما رو مجبور به تفکر عینی می‌کنه. پس در نهایت همونطور که باید اصول کدنویسی تمیز و منظم رو در کدهای اصلی برنامه‌ی خودمون در نظر بگیریم، تست‌های نوشته شده هم برای فهم بهتر و کارایی درست باید مرتب و طبق اصول رسمی پیاده‌سازی شده باشن.گروه‌بندی تست‌هاخب پس فهمیدیم که ما برای هر وضعیت مورد انتظار از متد add باید یک تست جدا بنویسیم که در کنار هم ۴ تست مجزا رو خواهیم داشت. به نظرتون عالی نمیشد اگر که ما این قابلیت رو داشتیم تا به شکلی این تست‌ها رو گروه‌بندی کنیم؟ آفرین. صددرصد شما این ابزار مهم رو در اختیار دارید. با استفاده از تابع group که به همین منظور در پکیج test قرارداده شده شما می‌تونید تست‌های مرتبط به یک subcomponent رو در یک گروه مشخص قرار بدید و میتونید روش استفاده از اون رو در زیر مشاهده کنید:group('tests for add component', () { test('should return a + b when a and b are two positives.', () { Calculator calculator = Calculator(); // arrange int a = 10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); // assert expect(actualResult, expectedResult); }); });تابع group هم درست مثل قبل دو پارامتر، یکی به شکل رشته برای توضیحات و یکی هم شامل بدنه‌ی اصلی دریافت می‌کنه. قالب توضیحات هم بهتره که برای رعایت استاندارد به این شکل باشه:tests for [component name]که باید اون رو به این صورت بخونید:A group of tests for XYZ component.پس در نهایت یک مجموعه تست مناسب و کاربردی برای متد add ما به این صورت میشه:group('tests for add component', () { test('should return a + b when a and b are two positives.', () { // arrange Calculator calculator = Calculator(); int a = 10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); // assert expect(actualResult, expectedResult); }); test('should return a + b when a and b are two negatives.', () { // arrange Calculator calculator = Calculator(); int a = -10; int b = -20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); test('should return a + b when a is negative and b is positive', () { // arrange Calculator calculator = Calculator(); int a = -10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); test('should return a + b when a is positive and b is negative.', () { // arrange Calculator calculator = Calculator(); int a = -10; int b = -20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); });به علاوه، بدنه‌ یک گروه از تست‌ها میتونه میزبان ۲ تابع دیگه به نام‌های setUp و teardown باشه.تابع setUp به عنوان ورودی یک تابع callback رو با نوع بازگشتی void میپذیره که قبل از اجرای هر تست از اون گروه اجرا میشه.همین حالا احساس نیاز به استفاده از این تابع در کد خودمون مشهود و مشخص هستش. هرکدوم از تست‌های ما به صورت تکراری حاوی کدی هستن که یک نمونه (instance) از کلاس Calculator برای ما میسازه. این کد و امثال اون دقیقا همون‌هایی هستن که باید در تابع setUp قرار بگیرن تا از تکرار غیر ضروری برخی کدها جلوگیری بشه.setUp(() { calculator = Calculator(); });همچنین tearDown هم درست مثل قبلی یک تابع از نوع void رو دریافت می‌کنه با این تفاوت که اجرای این تابع بعد از اتمام هر تست از یک گروه از تست‌ها انجام میشه. میشه از این تابع برای اجرای کد‌هایی استفاده کرد که عمل بازگردانی بعضی موارد به حالت قبل رو برعهده دارن تا تست جدید در شرایط یکسان با تست قبلی انجام بشه.tearDown(() { calculator = Calculator(); });در آخرو اما در آخر کد نهایی ما برای تست کامل متد add به این صورت خواهد بود:import 'package:test/test.dart'; void main() { group('tests for add component', () { Calculator calculator; setUp(() { calculator = Calculator(); }); test('should return a + b when a and b are two positives.', () { // arrange int a = 10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); // assert expect(actualResult, expectedResult); }); test('should return a + b when a and b are two negatives.', () { // arrange int a = -10; int b = -20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); test('should return a + b when a is negative and b is positive', () { // arrange int a = -10; int b = 20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); test('should return a + b when a is positive and b is negative.', () { // arrange int a = -10; int b = -20; int expectedResult = a + b; // act int actualResult = calculator.add(a, b); //assert expect(actualResult, expectedResult); }); tearDown(() { // Nothing to do here. }); }); }تست‌نویسی در فلاتر بسیار راحته و اگر که مثل من از VS Code استفاده می‌کنید این اسکیرین شات می‌تونه براتون سرشار از حس خوب و لبخند رضایت باشه.تیک‌های سبز دوست داشتنی!!!در ضمن تمامی کد‌هایی که توضیح داده شد در اینجا قرار گرفتن تا بتونید راحت‌تر از اون‌ها استفاده کنید.ممنون از این که این مقاله رو تا انتها مطالعه کردید و امیدوارم که حتما به دردتون خورده باشه، چرا که ما هم در تیم زنیاک خوشحال میشیم تا کمکی هرچند کوچیک به جامعه‌ی بزرگ توسعه‌دهندگان ایرانی داشته باشیم. هر سوال و یا نظری در ذهنتون داشتید خوشحال میشیم که در قسمت کامنت‌های همین پست اون‌هارو با ما درمیون بزارید.منبع