وبسایت شخصی حسن هاشمی

برنامه نویس. ایران. قم :))

نکاتی در مورد تست برنامه های Domain محور

یکی ارایج ترین و ساده ترین معماری ها، معماری n-tier هست که شاید همین سادگیش خیلی گول زننده هست و اغلب طوری  در ایران استفاده میشه که تست کردنش خیلی سخت میشه.

بذارید از ساده ترین، ساده ترین طراحی n-tier شروع کنیم

 اگه بخوایم یه معماری سنتی Domain-Centric رو فرض کنیم، می تونیم سه بخش اصلی رو براش در نظر بگیریم:

1- Domain Objects: اشیاء اصلی که برنامه حول اونها کار می کنه.

2- DataAccess Layer: لایه ای که امکانات دسترسی به Presistent storage ما رو فراهم می کنه.

3- EndPoints: از کلمه UI استفاده نکردم برای این مورد، چون نقطه های انتشار دیتای ما میتونن چیزایی غیر از ui باشن. فرضا web api، web socket و ... .

پرس�� اصلی اینه که بخش های مختلف این معماری رو چطور می تونیم تست کنیم، به نحوی که نه به باگ های خیلی ساده برخورد کنیم و نه سرعت توسعه رو کم کنیم؟

اگه بخوایم کل برنامه رو روی همین سه لایه بچینیم که تست کردنش باعث انهدام ما میشه:

مهم ترین دلیلیش این هست که برای Logic اصلی برنامه جایی مجزا قائل نشدید. توی این طراحی مجبورید logic برنامه رو یا توی لایه دوم پیاده کنید و یا سوم که در هر دو حالت تست کردن هر لایه به صورت مجزا رو غیر ممکن می کنه.

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


خوشبختانه حل این دو مورد به راحتی آب خوردن هست، می تونیم طرح اول رو به صورت زیر ویرایش کنیم:

1- Domain

2- DataAccess: بخشی که به صورت مستقیم با دیتا ارتباط داره . توجه کنید که حتی 1 خط کد نامربوط به دیتا نباید این قسمت اضافه بشه.

3- Business logic Services : این بخش رو اضافه می کنیم تا لاجیک اصلی دیتا رو توی اون پیاده سازی کنیم، فرض کنیم یه domain object داریم به نام profile، برای این شی یک کلاس می سازیم در این بخش، مثلا ProfileService. حالا توی این کلاس یه سری functionality خاص مثلاً UpdateLocation و یا AddInformation و یا ... رو اضافه می کنیم.

4- Business Logic Abstraction: این بخش تشکیل میشه از یه سری انتزاع که به ما اجازه Mock کردن سرویس های بخش قبلی رو میده، فرض کنید برای ProfileService یه interface در سی شارپ و یا یک protocol در swift اضافه می کنیم.

5- DTO: یه سری آبجکت های اصطلاحا plain که برای انتقال دیتا از بخش 3 به بخش 6 استفاده کنیم. فرض کنید ما برای سرویس Profile یه عملی داریم به نام UpdateLocation که نیازمند یه سری فیلد مثل طول و عرض جغرافیایی و ... هست، در این صورت یه DTO تعریف می کنیم که در بخش EndPoint، ساخته میشه و به این UpdateLocation اسال میشه. اونجا اونوقت بخش 3 اون رو به فیلد هایی که مربوط به دیتا هست تجزیه می کنه.

6- EndPoints: یه سری اشیایی مثل controller ها در asp.net mvc و PHP، و یا صفحات aspx در asp.net web forms و ... 


حالا با این طراحی تا حدودی اصلاح شده می تونیم به راحتی هر بخش رو به صورت جدا در طول توسعه به روش Agile، یونیت تست کنیم و خبر خیلی بهتر اینه که بالاخره می تونیم کار رو راحت تر بین تیم ها تقسیم کنیم. چون فرضا تیمی که مسئول بخش endPoint هست هرگز با concrete object ی از بخش business logic کاری نداره و تنها اینترفیس ها رو از تیم مسئول business service تحویل می گیره که لزوماً شاید در اون نقطه زمانی اصلا پیاده سازی هم نشدن باشن و فقط mock باشن.

در نتیجه مثلا تیم UI فقط mock ها رو تحویل می گیره و روی اونها یونیت تست های مربوط به بخش خودش رو مینویسه، و تیمی که مسئول سرویس های لاجیک برنامه هست باید احتیاط کنه تست هایی که تیم های دیگه روی خروجی های اونها نوشته رو نشکنه.


بعنوان مثال توی asp.net mvc فرض کنید که یه controller داریم به نام profileController که می خوایم خود controller رو به صورت مجزا و مستقل از بخش های مربوط به business logic  و دیتا تست کنیم:

public class ProfileController
{
private readonly IProfileService _profileService
public ProfileController(IProfileService service)
{
// store this service some where in the class
this._profileService = service;
}

public ActionResult UpdateLocation(UpdateLocationModel model)
{
if(!ModelState.IsValid)
return BadRequest(ModelState);
this._profileService.UpdateLocation(model.ProfileId, model.Lat, model.Long);
return Ok();
}
}

و حالا برای این که تست کنیم تنها کاری که باید بکنیم اینه که:

class ProfileControllerTest
{
[TestMethod]
public void Profile_UpdateLocation()
{
IProfileService service = new Mock<IProfileService>(); // یه شی جعلی از سرویس می سازیم
ProfileController controller = new ProfileController(service);
UpdateLocationModel wrongModel = new UpdateLocationMode() { ProfileId = null } // یه مدل غیر معتبر می سازیم
Assert.IsTrue(controller.UpdateLocation(wrongModel).Result is BadRequestResult); // یه مدل اشتباه به کنترلر ارسال می کنیم و راحت می تونیم رفتار کنترلر رو چک کنیم
}

}


به همین طریق بخش های دیگه رو می تونیم تست کنیم مثلاً خود ProfileService و قسمت های مربوط به دیتا که میتونه یه سری Query به دیتابیس باشه.


اما حالا یه سری بخش هایی هستن که جاشون، خودش سوال هست. مثلا Caching. خب شما ممکنه بگین بهترین جا برای این بخش توی لول مربوط به EndPoint هست که ما دیتای خروجی برنامه رو کش می کنیم اما بعضی از مواقع خروجی هایی که از EndPoint میدیم اصلا به درد کش کردن نمی خوره.

سناریویی مثل برنامه Tap30 و یا اسنپ رو فرض کنید که ما شاید غالباً بخوایم موقعیت کاربرهارو نسبت به راننده query بزنیم و برعکس. کش کردن موقعیت کاربر یا راننده توی این حالت کار بیهوده ای هست چون هر خیلی زود invalidate می شه. اینکه اطلاعات مکانی کجای معماری ما باید نگه داری بشن هم خودش جای سوال هست. 

1- باید یه بخشی بین لایه هایی ui و business logic یا همون entity service هامون براش در نظر بگیریم؟

2- بگذاریمش در قلب لایه EndPoint؟


حقیقت اینه که یه همچین functionality هم مربوط به یکی از اشیاء domain ما هست و هم خیلی transient هست. یعنی اولا ما هم میخوایم موقعیت کاربر رو در هر حرکتی که می کنه نگه داریم (یعنی از نقطه 1 به 2 به 4 و ...) و نه فقط آخرین نقطه و دوماً این که اگه بخوایم با این سرعت مختصات کاربر رو به لایه 3 انتقال بدیم فشار زیادی به سیستم اوردیم (چون غالباً چنین عملیاتی برای نرم افزای مثل sql server, oracle و ... زمان بر هست و باعث تولید latency توی عملکرد کلی سیستم میشه)


انتخاب شخصی خودم برای این سناریوها، ساخت یک زیرمجموعه برای بخش 3 هست. در ساده ترین حالت می تونید اینطور فرض کنید که ما برای کلاس ProfileService یه sub class می سازیم و توی در نتیجه مدیریت اطلاعات مختصات کاربر رو می سپاریم به اون، حالا این کلاس هر بار که ازش موقعیتی درخواست میشه ممکنه اون رو از توی دیتابیس کشی که تدارک دیدیم مثل (redis, MemcacheD) به ما اطلاعات رو بده و یا اینکه از دیتابیس اصلی کوئری بزنه. 

اینجوری راحتی در این هست که اگر اتفاقی توی دیتابیس های بیفته بخاطراینکه این کلاس به لایه دیتا دسترسی داره می تونه Cache رو Invalidate کنه.

یه همیچین flow ی رو در نظر بگیرید، یه کاربر در حال حرکت توی شهر هست ما هر ثانیه موقعیتش رو به وسیله فراخوانی این کلاس ذخیره می کنیم، این کلاس هم تا زمانی که فرضا کاربر 10 بار مکانش رو تغییر نداده اطلاعات روی توی redis ذخیره می کنه و در 10مین حرکت دیتا رو از redis به صورت bulck به Sql server انتقال میده.

اگر هم بخوایم حرکات کاربر رو کوئری بزنیم به راحتی می تونیم با سرعت یه دسترسی به حافظه اونها رو داشته باشیم. (وگرنه هر کاربر حداقل 10000 مختصات جغرافیایی داره که توی اونها بوده و بازیابی این حجم از دیتابیس هایی مثل sql server , ... خیلی پردازش سنگینی هست)


و البته هیچ کدام از کارهای بالا بدون پیاده سازی یه انجین Dependency Injection مجزا امکان پذیر نیست. (منظورم از مجزا یعنی نمی تونید از اونایی استفاده کنید که فریمورک هایی مثل asp.net mvc برای کارهای داخلی خودشون ازش استفاده می کنن)

Loading