یونیت تست در فلاتر: 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
  }
}

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

1. یک لیست از همه‌ی ویژگی‌های یک تکه کد یا Business Component مورد نظرتون برای نوشتن تست تهیه کنید.

2. در این مرحله باید تلاش کنید که هر یک از ویژگی‌های لیستتون رو به مولفه‌های کوچیکتر (Subcomponent) بشکنید.

3. مرحله‌ی دوم رو تا جایی ادامه بدید تا به نقطه‌ای برسید که کدتون از وضعیت فعلیش به بخش‌های کوچکتری قابل تقسیم نباشه. از این کار اصطلاحا با اسم atomization یا اتمی‌سازی میشه یاد کرد.

4. بعد از این که از قدم قبلی سربلند بیرون اومدید حالا باید دونه به دونه subcomponentهارو انتخاب کنیم.

5. حالا باید برای هر مولفه‌ی اتمی و کوچیکی که انتخاب کردیم فهرستی از انتظارت و یا خواسته‌هامون از اون subcomponent آماده کنیم.

6. و در نهایت برای هر کدوم از اون خواسته‌ها و یا انتظارات کد تستمون رو بنویسیم.

در ظاهر مقداری پیچیده به نظر میاد اما با پیاده‌سازی تست روی مثالی که در نظر گرفتیم این موضوع رو بهتر درک می‌کنیم.

بریم برای نوشتن تست

خب برگردیم به سراغ همون مثال کلاس ٰ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 استفاده می‌کنید این اسکیرین شات می‌تونه براتون سرشار از حس خوب و لبخند رضایت باشه.

تیک‌های سبز دوست داشتنی!!!
تیک‌های سبز دوست داشتنی!!!


در ضمن تمامی کد‌هایی که توضیح داده شد در اینجا قرار گرفتن تا بتونید راحت‌تر از اون‌ها استفاده کنید.

ممنون از این که این مقاله رو تا انتها مطالعه کردید و امیدوارم که حتما به دردتون خورده باشه، چرا که ما هم در تیم زنیاک خوشحال میشیم تا کمکی هرچند کوچیک به جامعه‌ی بزرگ توسعه‌دهندگان ایرانی داشته باشیم. هر سوال و یا نظری در ذهنتون داشتید خوشحال میشیم که در قسمت کامنت‌های همین پست اون‌هارو با ما درمیون بزارید.

منبع