این فصل شامل این موضوعاته:
- شناخت چهار نوع کد
- درک الگوی Humble Object
- نوشتن تستهای ارزشمند
توی فصل ۱ ویژگیهای یه مجموعه تست واحد خوب رو تعریف کردم:
- باید توی چرخهی توسعه ادغام شده باشه.
- فقط بخشهای مهم کدبیس رو هدف بگیره.
- بیشترین ارزش رو با کمترین هزینهی نگهداری فراهم کنه.
برای رسیدن به این ویژگی آخر، لازمه بتونی:
- یه تست ارزشمند رو تشخیص بدی (و بهطور ضمنی تست کمارزش رو هم).
- یه تست ارزشمند بنویسی.
فصل ۴ دربارهی تشخیص تست ارزشمند بود، با استفاده از چهار ویژگی: محافظت در برابر پسرفت (regression)، مقاومت در برابر بازآرایی، بازخورد سریع، و نگهداریپذیری.
و فصل ۵ روی مهمترینِ این چهار ویژگی تمرکز کرد: مقاومت در برابر بازآرایی.
همونطور که قبلاً گفتم، فقط شناختن تستهای ارزشمند کافی نیست؛ باید بلد باشی همچین تستهایی رو هم بنویسی. این مهارت دوم به اولی وابستهست، ولی علاوه بر اون نیاز داری تکنیکهای طراحی کد رو هم بلد باشی. چون تستهای واحد و خود کدی که تست میشه خیلی به هم گره خوردن، و بدون اینکه روی کدبیس کار کنی نمیتونی تستهای ارزشمند بسازی.
توی فصل ۶ یه نمونه دیدی: ما سیستم ممیزی رو بازآرایی کردیم به سمت معماری تابعی و همین باعث شد بتونیم تستهای خروجیمحور رو پیاده کنیم. این فصل این رویکرد رو کلیتر میکنه و روی طیف وسیعتری از اپلیکیشنها اعمالش میکنه، حتی اونهایی که نمیتونن از معماری تابعی استفاده کنن. اینجا راهنماییهای عملی میبینی برای اینکه چطور توی تقریباً هر پروژهی نرمافزاری تستهای ارزشمند بنویسی.
۷.۱ شناسایی کدی که باید بازآرایی بشه
به ندرت میشه یه مجموعه تست رو بدون بازآرایی کد اصلی به شکل چشمگیری بهتر کرد. راهی جز این وجود نداره—کد تست و کد اصلی ذاتاً به هم وصلن. توی این بخش میبینی چطور میشه کدت رو به چهار دسته تقسیم کرد تا مسیر بازآرایی مشخص بشه. بخشهای بعدی هم یه مثال کامل رو نشون میدن.
۷.۱.۱ چهار نوع کد
توی این بخش چهار نوع کدی رو توضیح میدم که پایهی بقیهی فصل هستن.
کل کد تولیدی رو میشه بر اساس دو بُعد دستهبندی کرد:
- پیچیدگی یا اهمیت دامنه
- تعداد همکارها (collaborators)
پیچیدگی کد با تعداد نقاط تصمیمگیری (branching) توی کد مشخص میشه. هرچی این تعداد بیشتر باشه، کد پیچیدهتره.
چطور پیچیدگی سایکلوماتیک (cyclomatic) محاسبه میشه
توی علوم کامپیوتر یه اصطلاح مخصوص برای اندازهگیری پیچیدگی کد داریم: پیچیدگی سایکلوماتیک. این معیار نشون میده یه برنامه یا متد چند شاخه (branch) داره.
فرمولش اینه:
پیچیدگی سایکلوماتیک = 1 + تعداد نقاط شاخهزنی
- پس یه متدی که هیچ دستور کنترل جریان (مثل
if یا حلقههای شرطی) نداره، پیچیدگی سایکلوماتیکش میشه ۱ + ۰ = 1. - میتونی این معیار رو اینطور هم ببینی: تعداد مسیرهای مستقل از ورودی تا خروجی متد، یا تعداد تستهایی که لازمه تا پوشش ۱۰۰٪ شاخهها رو داشته باشی.
- تعداد نقاط شاخهزنی بر اساس سادهترین شرطها حساب میشه. مثلاً دستور
IF condition1 AND condition2 THEN ... معادل اینه که بگی:IF condition1 THEN IF condition2 THEN ...پس پیچیدگی این قطعه میشه ۱ + ۲ = 3.
اهمیت دامنه نشون میده یه قطعه کد چقدر برای مسئلهی اصلی پروژه مهمه. معمولاً کدی که توی لایهی دامنه قرار داره مستقیم به اهداف کاربر نهایی وصل میشه و بنابراین اهمیت بالایی داره. در مقابل، کدهای کمکی (utility) همچین ارتباطی ندارن.
کد پیچیده و کدی که اهمیت دامنهای داره بیشتر از همه از تست واحد سود میبرن، چون تستهاشون محافظت خیلی خوبی در برابر پسرفت فراهم میکنه. البته این دو ویژگی مستقل از هم هستن: کد دامنه لازم نیست پیچیده باشه، و کد پیچیده هم لازم نیست اهمیت دامنهای داشته باشه تا ارزش تست کردن داشته باشه.
مثلاً یه متدی که قیمت سفارش رو حساب میکنه ممکنه هیچ شرطی نداشته باشه و پیچیدگی سایکلوماتیکش فقط ۱ باشه. با این حال تست کردنش خیلی مهمه، چون یه بخش حیاتی از منطق تجاری رو نشون میده.
بُعد دوم تعداد همکارهایی هست که یه کلاس یا متد داره. همونطور که از فصل ۲ یادت هست، همکار یعنی وابستگیای که یا قابل تغییره یا خارج از پروسهست (یا هر دو). کدی که تعداد زیادی همکار داره تست کردنش گرون درمیاد. دلیلش هم اینه که معیار نگهداریپذیری به اندازهی تست بستگی داره؛ باید جا بذاری تا همکارها رو به وضعیت مورد انتظار برسونی و بعد حالت یا تعاملاتشون رو بررسی کنی. هرچی تعداد همکارها بیشتر باشه، تست بزرگتر و پیچیدهتر میشه.
نوع همکارها هم مهمه. همکارهای خارج از پروسه برای مدل دامنه مناسب نیستن. چون هزینهی نگهداری رو بالا میبرن؛ مجبور میشی ماکهای پیچیده درست کنی تا تستها کار کنن. علاوه بر این باید خیلی محتاط باشی و فقط تعاملهایی رو با ماک بررسی کنی که از مرز اپلیکیشن رد میشن و اثرشون بیرون قابل مشاهدهست، تا مقاومت در برابر بازآرایی حفظ بشه (جزئیاتش رو توی فصل ۵ دیدی). بهترین کار اینه که همهی ارتباطها با وابستگیهای خارج از پروسه رو بسپری به کلاسهایی بیرون از لایهی دامنه. اون وقت کلاسهای دامنه فقط با وابستگیهای درونپروسه کار میکنن.
دقت کن که هم همکارهای ضمنی و هم صریح توی این شمارش حساب میشن. فرقی نداره سیستم تحت تست (SUT) یه همکار رو بهعنوان آرگومان بگیره یا بهطور ضمنی از طریق یه متد استاتیک بهش ارجاع بده؛ در هر صورت باید اون همکار رو توی تستها راهاندازی کنی. در مقابل، وابستگیهای غیرقابل تغییر (مثل مقادیر یا آبجکتهای مقداری) جزو این شمارش نیستن. این نوع وابستگیها خیلی راحتتر راهاندازی میشن و بررسی کردنشون هم سادهتره.
ترکیب پیچیدگی کد، اهمیت دامنهای اون، و تعداد همکارها چهار نوع کدی رو به وجود میاره که توی شکل ۷.۱ نشون داده شده:
- مدل دامنه و الگوریتمها (بالا سمت چپ شکل ۷.۱) — کد پیچیده معمولاً بخشی از مدل دامنهست، ولی نه همیشه. ممکنه یه الگوریتم پیچیده داشته باشی که مستقیماً به مسئلهی دامنه ربطی نداره.
- کد ساده (پایین سمت چپ شکل ۷.۱) — نمونههاش توی C# سازندههای بدون پارامتر یا پراپرتیهای یکخطی هستن؛ این کدها همکار زیادی ندارن (یا اصلاً ندارن) و پیچیدگی یا اهمیت دامنهای کمی دارن.
- کنترلرها (پایین سمت راست شکل ۷.۱) — این کد خودش کار پیچیده یا حیاتی تجاری انجام نمیده، بلکه وظیفهش هماهنگ کردن کار بقیهی اجزاست، مثل کلاسهای دامنه یا اپلیکیشنهای خارجی.
- کد بیشازحد پیچیده (بالا سمت راست شکل ۷.۱) — این نوع کد توی هر دو معیار امتیاز بالایی میگیره: همکارهای زیادی داره و در عین حال پیچیده یا مهمه. نمونهش کنترلرهای چاق (fat controllers) هستن؛ کنترلرهایی که کار پیچیده رو به هیچجا واگذار نمیکنن و همهچیز رو خودشون انجام میدن.

تست واحد برای بخش بالا-چپ (مدل دامنه و الگوریتمها) بهترین بازده رو برات داره. این تستها خیلی ارزشمند و ارزون هستن. ارزشمند چون کد زیربنایی منطق پیچیده یا مهم رو اجرا میکنه و این باعث میشه تستها محافظت بیشتری در برابر پسرفت (regression) داشته باشن. ارزون چون این کد همکارهای کمی داره (در حالت ایدهآل هیچ)، و همین هزینهی نگهداری تستها رو پایین میاره.
کدهای ساده اصلاً نباید تست بشن؛ ارزش این تستها تقریباً صفره. کنترلرها رو هم باید خیلی مختصر تست کنی، اون هم بهعنوان بخشی از مجموعهی کوچیکتر تستهای یکپارچه (این موضوع رو توی بخش ۳ توضیح میدم).
مشکلسازترین نوع کد، بخش بیشازحد پیچیدهست. تست واحد براش سخت انجام میشه ولی ریسک اینکه بدون پوشش تست رها بشه خیلی بالاست. همین نوع کده که باعث میشه خیلیها با تست واحد مشکل داشته باشن. کل این فصل بیشتر روی این تمرکز داره که چطور میشه از این دوراهی عبور کرد. ایدهی کلی اینه که کد بیشازحد پیچیده رو به دو بخش تقسیم کنیم: الگوریتمها و کنترلرها (شکل ۷.۲)، هرچند پیادهسازی عملی گاهی سخت میشه.
نکته: هرچی کد مهمتر یا پیچیدهتر باشه، باید همکارهای کمتری داشته باشه.
حذف کدهای بیشازحد پیچیده و محدود کردن تست واحد فقط به مدل دامنه و الگوریتمها، مسیر رسیدن به یه مجموعه تست ارزشمند و راحت برای نگهداریه. با این رویکرد، پوشش تستهات ۱۰۰٪ نخواهد بود، اما نیازی هم نداری—هدف هیچوقت نباید ۱۰۰٪ پوشش باشه. هدفت باید مجموعهای از تستها باشه که هرکدوم ارزش قابلتوجهی به پروژه اضافه کنن. بقیه تستها رو یا بازآرایی کن یا حذفشون کن. نذار اندازهی مجموعه تستهات بیخودی بزرگ بشه.

یادداشت: یادت باشه بهتره اصلاً تستی ننویسی تا اینکه یه تست بد بنویسی.
البته حذف کدهای بیشازحد پیچیده گفتنش راحتتر از انجامشه. با این حال تکنیکهایی هستن که میتونن کمکت کنن این کار رو انجام بدی. اول نظریهی پشت این تکنیکها رو توضیح میدم و بعد با یه مثال نزدیک به دنیای واقعی نشونشون میدم.
۷.۱.۲ استفاده از الگوی Humble Object برای تقسیم کدهای بیشازحد پیچیده
برای تقسیم کدهای بیشازحد پیچیده باید از الگوی طراحی Humble Object استفاده کنی. این الگو توسط جرارد مزاروس توی کتاب xUnit Test Patterns: Refactoring Test Code (انتشارات Addison-Wesley، سال ۲۰۰۷) معرفی شد، بهعنوان یکی از راهها برای مقابله با وابستگی (coupling) کد، ولی کاربردش خیلی گستردهتره. بهزودی دلیلش رو میبینی.
خیلی وقتها کد سخت تست میشه چون به یه وابستگی فریمورکی گره خورده (شکل ۷.۳ رو ببین). نمونههاش شامل اجرای ناهمزمان یا چندنخی (multi-thread)، رابطهای کاربری، ارتباط با وابستگیهای خارج از پروسه و موارد مشابه هستن.

برای اینکه منطق این کد رو بیاری زیر تست، باید یه بخش قابل تست ازش جدا کنی. در نتیجه، کد تبدیل میشه به یه پوشش نازک و ساده (humble wrapper) دور اون بخش قابل تست: این پوشش وابستگی سختِ تست رو با مؤلفهی تازه استخراجشده به هم وصل میکنه، اما خودش منطق کمی (یا هیچ) نداره و بنابراین نیازی به تست شدن نداره (شکل ۷.۴).

اگه این رویکرد برات آشناست، دلیلش اینه که قبلاً توی همین کتاب دیدیش. در واقع، هم معماری ششضلعی و هم معماری تابعی دقیقاً همین الگو رو پیاده میکنن. همونطور که از فصلهای قبل یادت هست، معماری ششضلعی جداسازی منطق تجاری از ارتباطات با وابستگیهای خارج از پروسه رو توصیه میکنه. این دقیقاً مسئولیت لایههای دامنه و سرویسهای اپلیکیشن هست.
معماری تابعی حتی یه قدم جلوتر میره و منطق تجاری رو از ارتباطات با همهی همکارها جدا میکنه، نه فقط وابستگیهای خارج از پروسه. همین ویژگی باعث میشه معماری تابعی خیلی قابل تست باشه: هستهی تابعی هیچ همکاری نداره. همهی وابستگیها توی هستهی تابعی غیرقابل تغییرن، و این باعث میشه خیلی نزدیک به محور عمودی توی نمودار انواع کد (شکل ۷.۵) قرار بگیره.

راه دیگهای برای نگاه کردن به الگوی Humble Object اینه که اون رو وسیلهای برای پایبندی به اصل Single Responsibility بدونیم؛ اصلی که میگه هر کلاس باید فقط یه مسئولیت داشته باشه. یکی از این مسئولیتها همیشه منطق تجاریه؛ این الگو میتونه برای جداسازی اون منطق از تقریباً هر چیز دیگهای به کار بره.
در موقعیت خاص ما، تمرکز روی جداسازی منطق تجاری و هماهنگی (orchestration) هست. میتونی این دو مسئولیت رو بهصورت «عمق کد» در برابر «عرض کد» در نظر بگیری. کدت میتونه یا عمیق باشه (پیچیده یا مهم) یا پهن (با همکارهای زیاد کار کنه)، اما هیچوقت هر دو با هم نباید باشه (شکل ۷.۶).

نمیتونم به اندازهی کافی تأکید کنم که این جداسازی چقدر مهمه. در واقع خیلی از اصول و الگوهای شناختهشده رو میشه بهعنوان شکلی از الگوی Humble Object توصیف کرد: اینها دقیقاً برای جدا کردن کد پیچیده از کدی که وظیفهی هماهنگی داره طراحی شدن.
قبلاً رابطهی این الگو با معماری ششضلعی و معماری تابعی رو دیدی. مثالهای دیگه شامل الگوهای Model-View-Presenter (MVP) و Model-View-Controller (MVC) هستن. این دو الگو بهت کمک میکنن منطق تجاری (بخش Model)، نگرانیهای رابط کاربری (بخش View)، و هماهنگی بین اونها (Presenter یا Controller) رو از هم جدا کنی. اجزای Presenter و Controller همون Humble Object هستن: اونها View و Model رو به هم وصل میکنن.
مثال دیگه، الگوی Aggregate از طراحی دامنهمحور (Domain-Driven Design) هست. یکی از اهدافش کاهش اتصال بین کلاسهاست، با گروهبندی اونها در خوشهها (aggregates). کلاسها داخل این خوشهها اتصال زیادی دارن، اما خود خوشهها با وابستگی کم (loosely couple) به هم وصل میشن. این ساختار تعداد کل ارتباطات در کدبیس رو کم میکنه. کاهش اتصال هم به نوبهی خودش قابلیت تستپذیری رو بهتر میکنه.
دقت کن که بهبود قابلیت تست تنها دلیل حفظ جداسازی بین منطق تجاری و هماهنگی نیست. چنین جداسازیای همچنین به مدیریت پیچیدگی کد کمک میکنه، که برای رشد پروژه هم حیاتیه، مخصوصاً در بلندمدت. من شخصاً همیشه برام جالبه که یه طراحی قابل تست نهتنها قابل تست باقی میمونه، بلکه نگهداریش هم آسونتر میشه.
۷.۲ بازآرایی به سمت تستهای واحد ارزشمند
در این بخش، یه مثال جامع از تقسیم کدهای بیشازحد پیچیده به الگوریتمها و کنترلرها نشون میدم. نمونهی مشابهی رو توی فصل قبل دیدی، وقتی دربارهی تست مبتنی بر خروجی و معماری تابعی صحبت کردیم. این بار، این رویکرد رو برای همهی اپلیکیشنهای سطح سازمانی با کمک الگوی Humble Object تعمیم میدم. از این پروژه نهتنها توی این فصل، بلکه در فصلهای بعدی بخش ۳ هم استفاده خواهم کرد.
۷.۲.۱ معرفی یک سیستم مدیریت مشتریان
پروژهی نمونه یک سیستم مدیریت مشتریان (CRM) هست که ثبتنام کاربران رو مدیریت میکنه. همهی کاربران در پایگاه داده ذخیره میشن. این سیستم در حال حاضر فقط یک مورد استفاده رو پشتیبانی میکنه: تغییر ایمیل کاربر. سه قانون تجاری در این عملیات دخیل هستن:
- اگر ایمیل کاربر متعلق به دامنهی شرکت باشه، اون کاربر بهعنوان کارمند علامتگذاری میشه. در غیر این صورت، بهعنوان مشتری در نظر گرفته میشه.
- سیستم باید تعداد کارمندان شرکت رو دنبال کنه. اگر نوع کاربر از کارمند به مشتری یا برعکس تغییر کنه، این تعداد هم باید تغییر کنه.
- وقتی ایمیل تغییر میکنه، سیستم باید با ارسال یک پیام به message bus سیستمهای خارجی رو مطلع کنه.
کد زیر پیادهسازی اولیهی سیستم CRM رو نشون میده.
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(int userId, string newEmail)
{
object[] data = Database.GetUserById(userId);
UserId = userId;
Email = (string)data[1];
Type = (UserType)data[2];
if (Email == newEmail)
return;
object[] companyData = Database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
Database.SaveCompany(newNumber);
}
Email = newEmail;
Type = newType;
Database.SaveUser(this);
MessageBus.SendEmailChangedMessage(UserId, newEmail);
}
}
public enum UserType
{
Customer = 1,
Employee = 2
}کد ۷.۱
کلاس User وظیفهی تغییر ایمیل کاربر رو بر عهده داره. برای اختصار، اعتبارسنجیهای ساده مثل بررسی صحت ایمیل یا وجود کاربر در پایگاه داده حذف شدن. حالا این پیادهسازی رو از دیدگاه نمودار انواع کد بررسی کنیم:
- پیچیدگی کد: خیلی بالا نیست. متد
ChangeEmailفقط دو نقطهی تصمیمگیری صریح داره:- تشخیص اینکه کاربر کارمند باشه یا مشتری.
- نحوهی بهروزرسانی تعداد کارمندان شرکت.
با وجود سادگی، این تصمیمها مهم هستن چون هستهی منطق تجاری اپلیکیشن رو تشکیل میدن. بنابراین، کلاس در بُعد پیچیدگی و اهمیت دامنه امتیاز بالایی میگیره.
- وابستگیها: کلاس User چهار وابستگی داره:
- دو وابستگی صریح:
userIdوnewEmail. اینها مقدار هستن و بهعنوان همکار حساب نمیشن. - دو وابستگی ضمنی:
DatabaseوMessageBus. اینها همکارهای خارج از پروسه هستن.
- دو وابستگی صریح:
همونطور که قبلاً گفته شد، همکارهای خارج از پروسه برای کدی با اهمیت دامنهی بالا مناسب نیستن. بنابراین، کلاس User در بُعد تعداد همکارها هم امتیاز بالایی میگیره و در دستهی کدهای بیشازحد پیچیده (overcomplicated) قرار میگیره (شکل ۷.۷).
این رویکرد—وقتی یه کلاس دامنه خودش دادهها رو از پایگاه داده میگیره و ذخیره میکنه—بهعنوان الگوی Active Record شناخته میشه. این الگو توی پروژههای ساده یا کوتاهمدت خوب جواب میده، اما معمولاً وقتی کدبیس بزرگ میشه دیگه مقیاسپذیر نیست. دلیلش دقیقاً همین نبود جداسازی بین دو مسئولیت اصلیه: منطق تجاری و ارتباط با وابستگیهای خارج از پروسه.
۷.۲.۲ گام اول: صریحسازی وابستگیهای ضمنی
رویکرد معمول برای بهبود قابلیت تست اینه که وابستگیهای ضمنی رو صریح کنیم: یعنی برای Database و MessageBus اینترفیس معرفی کنیم، اونها رو به کلاس User تزریق کنیم و بعد در تستها شبیهسازی (mock) کنیم. این رویکرد کمک میکنه، و دقیقاً همون کاریه که در فصل قبل برای سیستم ممیزی با استفاده از mock انجام دادیم. اما این کافی نیست.
از دیدگاه نمودار انواع کد، مهم نیست که مدل دامنه به وابستگیهای خارج از پروسه مستقیم اشاره کنه یا از طریق اینترفیس. این وابستگیها همچنان خارج از پروسه هستن؛ اونها در واقع پروکسیهایی برای دادههایی هستن که هنوز در حافظه نیستن. برای تست چنین کلاسهایی همچنان باید ماشین شبیهسازی پیچیدهای رو نگهداری کنی، که هزینهی نگهداری تستها رو بالا میبره. علاوه بر این، استفاده از mock برای وابستگی پایگاه داده باعث شکنندگی تستها میشه (که در فصل بعد دربارهش صحبت میکنیم).
در مجموع، خیلی تمیزتره که مدل دامنه اصلاً به همکارهای خارج از پروسه وابسته نباشه، چه مستقیم و چه غیرمستقیم (از طریق اینترفیس). این دقیقاً همون چیزیه که معماری ششضلعی هم توصیه میکنه—مدل دامنه نباید مسئول ارتباط با سیستمهای خارجی باشه.
۷.۲.۳ گام دوم: معرفی یک لایهی سرویس اپلیکیشن
برای غلبه بر مشکل ارتباط مستقیم مدل دامنه با سیستمهای خارجی، باید این مسئولیت رو به یک کلاس دیگه منتقل کنیم، یک کنترلر ساده (سرویس اپلیکیشن، در طبقهبندی معماری ششضلعی). بهطور کلی، کلاسهای دامنه باید فقط به وابستگیهای درونپروسه مثل سایر کلاسهای دامنه یا مقادیر ساده وابسته باشن. در اینجا اولین نسخه از اون سرویس اپلیکیشن رو میبینیم.
public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] data = _database.GetUserById(userId);
string email = (string)data[1];
UserType type = (UserType)data[2];
var user = new User(userId, email, type);
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);
_database.SaveCompany(newNumberOfEmployees);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}کد ۷.۲
این تلاش اول خوبی بود؛ سرویس اپلیکیشن کمک کرد تا کار با وابستگیهای خارج از پروسه از کلاس User جدا بشه. اما چند مشکل در این پیادهسازی وجود داره:
- وابستگیهای خارج از پروسه (Database و MessageBus) بهطور مستقیم نمونهسازی شدن، نه تزریق. این موضوع برای تستهای یکپارچهای که برای این کلاس خواهیم نوشت مشکلساز میشه.
- کنترلر یک نمونهی User رو از دادهی خامی که از پایگاه داده میگیره بازسازی میکنه. این منطق پیچیدهست و نباید به سرویس اپلیکیشن تعلق داشته باشه، چون نقش اون فقط هماهنگیه، نه منطق پیچیده یا با اهمیت دامنهای.
- همین موضوع برای دادههای شرکت هم صدق میکنه. مشکل دیگه اینه که User حالا تعداد کارمندان بهروزشده رو برمیگردونه، که درست به نظر نمیاد. تعداد کارمندان شرکت هیچ ربطی به یک کاربر خاص نداره. این مسئولیت باید جای دیگهای باشه.
- کنترلر دادههای تغییر یافته رو ذخیره میکنه و اعلانها رو بدون قید و شرط به MessageBus میفرسته، فارغ از اینکه ایمیل جدید با قبلی فرق داشته باشه یا نه.
کلاس User حالا خیلی راحتتر قابل تست شده چون دیگه لازم نیست با وابستگیهای خارج از پروسه ارتباط برقرار کنه. در واقع، هیچ همکار دیگهای نداره—چه خارج از پروسه و چه داخل. این نسخهی جدید متد ChangeEmail در کلاس User هست:
public int ChangeEmail(string newEmail,
string companyDomainName, int numberOfEmployees)
{
if (Email == newEmail)
return numberOfEmployees;
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
numberOfEmployees = newNumber;
}
Email = newEmail;
Type = newType;
return numberOfEmployees;
}شکل ۷.۸ نشون میده که کلاسهای User و UserController الان در نمودار کجا قرار دارن.
کلاس User به بخش مدل دامنه منتقل شده و نزدیک محور عمودی قرار گرفته، چون دیگه لازم نیست با همکارها (collaborators) سروکار داشته باشه.
اما UserController مشکلسازتره. هرچند اون رو در بخش کنترلرها قرار دادم، تقریباً مرز ورود به دستهی کدهای بیشازحد پیچیده رو رد میکنه، چون منطق نسبتاً پیچیدهای داخلش وجود داره.

۷.۲.۴ گام سوم: حذف پیچیدگی از سرویس اپلیکیشن
برای اینکه کلاس UserController بهطور کامل در بخش کنترلرها قرار بگیره، باید منطق بازسازی (reconstruction logic) رو از اون خارج کنیم. اگر از یک کتابخانهی ORM برای نگاشت پایگاه داده به مدل دامنه استفاده کنی، اون بهترین جاییه که میشه منطق بازسازی رو بهش نسبت داد. هر کتابخانهی ORM یک بخش اختصاصی داره که میتونی مشخص کنی جداول پایگاه داده چطور باید به کلاسهای دامنه نگاشت بشن، مثل استفاده از attributeها روی کلاسهای دامنه، فایلهای XML یا فایلهای نگاشت Fluent.
اگر نمیخوای یا نمیتونی از ORM استفاده کنی، یک factory در مدل دامنه بساز که کلاسهای دامنه رو با استفاده از دادههای خام پایگاه داده نمونهسازی کنه. این factory میتونه یک کلاس جداگانه باشه یا در موارد سادهتر، یک متد استاتیک در کلاسهای موجود دامنه. منطق بازسازی در اپلیکیشن نمونهی ما خیلی پیچیده نیست، اما بهتره چنین چیزهایی جدا نگه داشته بشن. بنابراین، من اون رو در یک کلاس جداگانه به نام UserFactory قرار میدم، همونطور که در فهرست بعدی نشون داده شده.
public class UserFactory
{
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
int id = (int)data[0];
string email = (string)data[1];
UserType type = (UserType)data[2];
return new User(id, email, type);
}
}کد ۷.۳
این کد حالا کاملاً از همهی همکارها جدا شده و بنابراین بهراحتی قابل تست هست.
توجه کن که من یک محافظ (safeguard) در این متد قرار دادم: الزام به داشتن حداقل سه عنصر در آرایهی داده. کلاس Precondition یک کلاس سادهی سفارشی هست که اگر آرگومان بولی false باشه، خطا میده. دلیل استفاده از این کلاس، کوتاهتر شدن کد و معکوس کردن شرطه؛ جملات تأییدی خواناتر از جملات منفی هستن.
در مثال ما، شرط data.Length >= 3 خواناتر از اینه که بنویسیم:
if (data.Length < 3)
throw new Exception();توجه داشته باش که هرچند منطق بازسازی کمی پیچیدهست، اما اهمیت دامنهای نداره؛ یعنی مستقیماً به هدف مشتری برای تغییر ایمیل کاربر مربوط نیست. این یک نمونه از کدهای کمکی (utility code) هست که در فصلهای قبلی بهش اشاره کردم.
چطور منطق بازسازی پیچیده است؟
پیچیدگی منطق بازسازی در متد UserFactory.Create فقط به تعداد شاخههای صریح در کد محدود نمیشه. هرچند در ظاهر فقط یک نقطهی تصمیمگیری وجود داره، اما در واقعیت شاخههای پنهان زیادی در کتابخانههای زیرین (NET Framework) وجود دارن که میتونن باعث خطا بشن:
- دسترسی به عنصر آرایه با ایندکس (
data[0]): درون .NET تصمیم گرفته میشه که کدوم عنصر رو برگردونه، و اگر ایندکس خارج از محدوده باشه، خطا میده. این خودش یک شاخهی پنهانه. - تبدیل نوع از
objectبهintیاstring: این تبدیلها درونی تصمیمگیری دارن؛ مثلاً آیا باید خطای cast رخ بده یا تبدیل مجاز باشه. هر بار که چنین تبدیلی انجام میشه، یک شاخهی پنهان وجود داره. - شرطهای داخلی کتابخانهها: حتی اگر کد ما ساده باشه، کتابخانههای پایه در پشت صحنه مسیرهای مختلفی رو طی میکنن تا نتیجه رو برگردونن.
به همین دلیل، منطق بازسازی ارزش تست کردن داره، چون احتمال خطا در شاخههای پنهان زیاده— حتی اگر در ظاهر نقطهی تصمیمگیری کمی وجود داشته باشه.
۷.۲.۵ گام چهارم: معرفی یک کلاس جدید به نام Company
به این کد در کنترلر دوباره نگاه کن:
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);اینکه کلاس User تعداد کارمندان بهروزشده رو برگردونه، نشونهی یک مسئولیت اشتباهه؛ و این خودش نشونهی نبود یک انتزاع (abstraction) مناسبه. برای رفع این مشکل، باید یک کلاس دامنهی جدید به نام Company معرفی کنیم که منطق و دادههای مربوط به شرکت رو در کنار هم نگه داره، همونطور که در فهرست بعدی نشون داده شده.
public class Company
{
public string DomainName { get; private set; }
public int NumberOfEmployees { get; private set; }
public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}
public bool IsEmailCorporate(string email)
{
string emailDomain = email.Split('@')[1];
return emailDomain == DomainName;
}
}کد ۷.۴
در این کلاس دو متد وجود داره: ChangeNumberOfEmployees و IsEmailCorporate. این متدها به اصل tell-don’t-ask که در فصل ۵ اشاره کردم پایبند هستن. این اصل توصیه میکنه داده و عملیات مربوط به اون داده در کنار هم نگه داشته بشن. یک نمونهی User به شرکت میگه تعداد کارمندانش رو تغییر بده یا مشخص کنه که آیا یک ایمیل خاص سازمانی هست یا نه؛ خودش دادهی خام رو نمیگیره تا همهچیز رو بهتنهایی انجام بده.
همچنین یک کلاس جدید به نام CompanyFactory معرفی شده که مسئول بازسازی اشیای شرکت هست، مشابه UserFactory. کنترلر حالا به این شکل در میاد.
public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}کد ۷.۵
و اینم کلاس User
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(string newEmail, Company company)
{
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
}
}کد ۷.۸
حذف مسئولیتِ اشتباه باعث شد کلاس User خیلی تمیزتر بشه.
بهجای اینکه خودش روی دادههای شرکت کار کنه، یک نمونهی Company رو دریافت میکنه و دو کار مهم رو به اون واگذار میکنه: تشخیص اینکه ایمیل سازمانی هست یا نه، و تغییر تعداد کارمندان شرکت.
شکل ۷.۹ نشون میده هر کلاس در نمودار کجا قرار گرفته. کارخانهها (UserFactory و CompanyFactory) و هر دو کلاس دامنه در بخش مدل دامنه و الگوریتمها قرار دارن. کلاس User کمی به سمت راست حرکت کرده چون حالا یک همکار داره (Company)؛ در حالی که قبلاً هیچ همکار مستقیمی نداشت. این تغییر باعث شده تستپذیری User کمی کمتر بشه، اما نه به شکل جدی.

کلاس UserController حالا محکم در بخش کنترلرها قرار گرفته چون تمام پیچیدگی آن به کارخانهها منتقل شده. تنها مسئولیت این کلاس چسباندن همهی بخشهای همکار به یکدیگر است.
به شباهتهای این پیادهسازی با معماری تابعی در فصل قبل توجه کن. نه هستهی تابعی در سیستم ممیزی و نه لایهی دامنه در این CRM (کلاسهای User و Company) با وابستگیهای خارج از پروسه ارتباطی ندارن. در هر دو پیادهسازی، لایهی سرویسهای اپلیکیشن مسئول چنین ارتباطیه: دادهی خام رو از فایلسیستم یا پایگاه داده میگیره، اون داده رو به الگوریتمهای بدون حالت یا مدل دامنه میسپاره، و سپس نتایج رو دوباره در ذخیرهسازی داده پایدار میکنه.
تفاوت بین این دو پیادهسازی در نحوهی برخورد با اثرات جانبی (side effects) هست.
در هستهی تابعی هیچ اثر جانبیای وجود نداره. اما مدل دامنهی CRM اثرات جانبی داره، با این تفاوت که همهی اونها داخل مدل دامنه باقی میمونن؛ به شکل تغییر ایمیل کاربر و تعداد کارمندان. اثرات جانبی فقط زمانی از مرز مدل دامنه عبور میکنن که کنترلر اشیای User و Company رو در پایگاه داده ذخیره کنه.
اینکه همهی اثرات جانبی تا آخرین لحظه در حافظه نگه داشته میشن، تستپذیری رو خیلی بهتر میکنه. تستها دیگه نیازی به بررسی وابستگیهای خارج از پروسه ندارن و لازم نیست به تستهای مبتنی بر ارتباط (communication-based testing) متوسل بشن. همهی اعتبارسنجیها میتونن با استفاده از تستهای مبتنی بر خروجی و وضعیت اشیای درون حافظه انجام بشن.
تحلیل پوشش بهینهی تست واحد
حالا که بازآرایی با کمک الگوی Humble Object کامل شده، بیایید بررسی کنیم کدام بخشهای پروژه در کدام دستهی کد قرار میگیرند و این بخشها باید چگونه تست شوند. جدول ۷.۱ تمام کدهای پروژهی نمونه را بر اساس موقعیتشان در نمودار انواع کد گروهبندی میکند.
| تعداد همکار کم | تعداد همکار زیاد | |
|---|---|---|
| پیچیدگی بالا یا اهمیت دامنهای | – ChangeEmail(newEmail, company) در User– ChangeNumberOfEmployees(delta) و IsEmailCorporate(email) در Company– Create(data) در UserFactory و CompanyFactory | – |
| پیچیدگی پایین و اهمیت دامنهای کم | – سازندهها (Constructors) در User و Company | – ChangeEmail(userId, newEmail) در UserController |
جدول ۷.۱ با جداسازی کامل منطق تجاری و بخش هماهنگی (orchestration)، تصمیمگیری دربارهی اینکه کدام بخشهای کد باید با تست واحد پوشش داده شوند بسیار ساده میشود.
۷.۳.۱ تست کردن لایهی دامنه و کدهای کمکی
تست کردن متدهایی که توی بخش بالا-چپ جدول ۷.۱ قرار دارن، از نظر هزینه و فایده بهترین نتیجه رو میده. چون کد هم پیچیدگی یا اهمیت دامنهای بالایی داره و همین باعث میشه جلوی خطاهای برگشتی (regressions) خیلی خوب گرفته بشه، و هم تعداد همکاراش کمه، پس هزینهی نگهداریش پایین میاد.
یه نمونه از اینکه چطور میشه کلاس User رو تست کرد اینه:
[Fact]
public void Changing_email_from_non_corporate_to_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@gmail.com", UserType.Customer);
sut.ChangeEmail("new@mycorp.com", company);
Assert.Equal(2, company.NumberOfEmployees);
Assert.Equal("new@mycorp.com", sut.Email);
Assert.Equal(UserType.Employee, sut.Type);
}برای رسیدن به پوشش کامل، به سه تست مشابه دیگر نیاز داری:
public void Changing_email_from_corporate_to_non_corporate()
public void Changing_email_without_changing_user_type()
public void Changing_email_to_the_same_one()تستهای مربوط به سه کلاس دیگر حتی کوتاهتر خواهند بود، و میتونی از تستهای پارامتری برای گروهبندی چند سناریو استفاده کنی:
[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[Theory]
public void Differentiates_a_corporate_email_from_non_corporate(
string domain, string email, bool expectedResult)
{
var sut = new Company(domain, 0);
bool isEmailCorporate = sut.IsEmailCorporate(email);
Assert.Equal(expectedResult, isEmailCorporate);
}۷.۳.۲ تست کردن کدهای سه بخش دیگه
کدی که پیچیدگی کمی داره و تعداد همکاراش هم کمه (بخش پایین-چپ جدول ۷.۱)، نمونهش همون سازندههای کلاسهای User و Company هست، مثل:
public User(int userId, string email, UserType type)
{
UserId = userId;
Email = email;
Type = type;
}این سازندهها خیلی سادهان و واقعاً ارزش وقت گذاشتن برای تست ندارن. تستی که ازشون درمیاد اونقدر محافظت در برابر خطاهای برگشتی (regressions) ایجاد نمیکنه که به زحمتش بیارزه.
بازآرایی باعث شده همهی کدهایی که هم پیچیدگی بالا دارن و هم تعداد زیادی همکار (بخش بالا-راست جدول ۷.۱) حذف بشن، پس اونجا هم چیزی برای تست باقی نمونده.
اما در مورد بخش کنترلرها (پایین-راست جدول ۷.۱)، تست کردنش رو میذاریم برای فصل بعد.
۷.۳.۳ آیا باید پیششرطها رو تست کرد؟
بیاییم یه نوع خاص از نقاط انشعاب رو بررسی کنیم—پیششرطها—و ببینیم اصلاً لازمه تستشون کنیم یا نه.
مثلاً دوباره به این متد از کلاس Company نگاه کن:
public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}اینجا یه پیششرط داریم که میگه تعداد کارمندهای شرکت هیچوقت نباید منفی بشه. این پیششرط در واقع یه محافظه که فقط توی شرایط استثنایی فعال میشه. این شرایط استثنایی معمولاً نتیجهی وجود باگه. تنها دلیل ممکن برای اینکه تعداد کارمندها منفی بشه اینه که یه خطا توی کد وجود داشته باشه.
این محافظ باعث میشه نرمافزار سریع شکست بخوره (fail fast) و جلوی پخش شدن خطا و ذخیره شدنش توی دیتابیس رو بگیره؛ جایی که رفع کردنش خیلی سختتر میشه.
حالا سؤال اینه: آیا باید چنین پیششرطهایی رو تست کنیم؟ به عبارت دیگه، آیا این تستها اونقدر ارزش دارن که توی مجموعهی تستها قرار بگیرن؟
هیچ قانون سخت و قطعیای اینجا وجود نداره، ولی راهنمای کلی اینه که پیششرطهایی رو تست کنی که اهمیت دامنهای دارن. مثلاً شرط غیرمنفی بودن تعداد کارمندها یکی از همین پیششرطهاست. این شرط بخشی از invariantهای کلاس Company محسوب میشه؛ یعنی شرایطی که باید همیشه برقرار باشن.
اما وقتت رو برای تست کردن پیششرطهایی که اهمیت دامنهای ندارن تلف نکن. مثلاً کلاس UserFactory توی متد Create یه محافظ ساده داره:
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
/* Extract id, email, and type out of data */
}این پیششرط هیچ معنای دامنهای نداره، پس تست کردنش هم ارزش زیادی نداره.
۷.۴ مدیریت منطق شرطی در کنترلرها
مدیریت منطق شرطی و در عین حال آزاد نگه داشتن لایهی دامنه از وابستگیهای خارج از پروسه معمولاً کار سختیه و پای معامله و انتخابهای مختلف وسط میاد. توی این بخش میبینیم این انتخابها چی هستن و چطور میشه تصمیم گرفت کدوم رو توی پروژهی خودت استفاده کنی.
جدا کردن منطق تجاری از بخش هماهنگی (orchestration) وقتی بهترین نتیجه رو میده که یک عملیات تجاری سه مرحلهی مشخص داشته باشه:
- گرفتن داده از منبع (مثلا پایگاه داده)
- اجرای منطق تجاری
- ذخیره کردن دوبارهی داده در سیستم ذخیرهسازی (شکل ۷.۱۰)

خیلی وقتها این سه مرحله اونقدر هم شفاف و جدا از هم نیستن. همونطور که توی فصل ۶ گفتیم، ممکنه لازم باشه بر اساس یه نتیجهی میانی از فرآیند تصمیمگیری، دادههای بیشتری رو از یه وابستگی خارج از پروسه بگیری (شکل ۷.۱۱). نوشتن داده توی اون وابستگی هم معمولاً به همون نتیجه بستگی پیدا میکنه.

همونطور که توی فصل قبل هم گفتیم، توی چنین شرایطی سه راه پیش رو داری:
- همهی خوندنها و نوشتنهای خارجی رو به لبههای عملیات منتقل کنی. این روش ساختار «خواندن–تصمیمگیری–اقدام» رو حفظ میکنه، ولی از نظر کارایی ضعف داره؛ چون کنترلر حتی وقتی لازم نیست هم وابستگیهای خارج از پروسه رو صدا میزنه.
- وابستگیهای خارج از پروسه رو به مدل دامنه تزریق کنی و اجازه بدی منطق تجاری خودش تصمیم بگیره چه زمانی اونها رو فراخوانی کنه.
- فرآیند تصمیمگیری رو به مراحل ریزتر تقسیم کنی و کنترلر رو وادار کنی هر مرحله رو جداگانه اجرا کنه.
چالش اصلی اینجاست که باید بین سه ویژگی زیر تعادل برقرار کنی:
- قابلیت تستپذیری مدل دامنه: که وابسته به تعداد و نوع همکارهایی هست که توی کلاسهای دامنه وجود دارن.
- سادگی کنترلر: که بستگی به وجود نقاط تصمیمگیری (branching) داخل کنترلر داره.
- کارایی (Performance): که با تعداد فراخوانیها به وابستگیهای خارج از پروسه تعریف میشه.
هر کدوم از این گزینهها فقط دو تا از سه ویژگی رو پوشش میده (شکل ۷.۱۲):
- انتقال همهی خوندنها و نوشتنهای خارجی به لبههای عملیات تجاری: سادگی کنترلر رو حفظ میکنه و مدل دامنه رو از وابستگیهای خارج از پروسه جدا نگه میداره (که باعث میشه تستپذیر بمونه)، ولی کارایی رو قربانی میکنه.
- تزریق وابستگیهای خارج از پروسه به مدل دامنه: کارایی و سادگی کنترلر رو حفظ میکنه، اما تستپذیری مدل دامنه رو خراب میکنه.
- تقسیم فرآیند تصمیمگیری به مراحل ریزتر: هم به کارایی کمک میکنه و هم تستپذیری مدل دامنه رو بالا میبره، ولی سادگی کنترلر رو از دست میده. برای مدیریت این مراحل ریزتر، باید نقاط تصمیمگیری رو داخل کنترلر معرفی کنی.

توی بیشتر پروژههای نرمافزاری، کارایی خیلی مهمه، پس روش اول (انتقال همهی خوندنها و نوشتنهای خارجی به لبههای عملیات تجاری) عملاً کنار گذاشته میشه.
روش دوم (تزریق وابستگیهای خارج از پروسه به مدل دامنه) بیشتر کد رو میبره به سمت «بیشازحد پیچیده» توی نمودار انواع کد. این دقیقاً همون چیزیه که ما توی بازآرایی پیادهسازی اولیهی CRM ازش دور شدیم. توصیه اینه که از این روش پرهیز کنی؛ چون دیگه جداسازی بین منطق تجاری و ارتباط با وابستگیهای خارجی حفظ نمیشه و تست و نگهداری کد خیلی سختتر میشه.
پس میمونه گزینهی سوم: شکستن فرآیند تصمیمگیری به مراحل کوچیکتر. با این روش، کنترلرها پیچیدهتر میشن و به بخش «بیشازحد پیچیده» نزدیکتر میشن. ولی راههایی هست برای کنترل این مشکل. هرچند به ندرت میتونی مثل پروژهی نمونه قبلی همهی پیچیدگی رو از کنترلرها بیرون بکشی، اما میتونی کاری کنی که این پیچیدگی قابل مدیریت بمونه.
۷.۴.۱ استفاده از الگوی CanExecute/Execute
اولین راه برای کنترل رشد پیچیدگی کنترلرها اینه که از الگوی CanExecute/Execute استفاده کنیم. این الگو کمک میکنه منطق تجاری از مدل دامنه به کنترلرها نشت نکنه. بهترین روش برای توضیح این الگو، استفاده از یه مثال عملی هست، پس بیاییم پروژهی نمونهمون رو گسترش بدیم.
فرض کن کاربر فقط تا زمانی میتونه ایمیلش رو تغییر بده که هنوز اون رو تأیید نکرده. اگر کاربر بعد از تأیید بخواد ایمیل رو تغییر بده، باید یه پیام خطا بهش نشون داده بشه. برای اینکه این نیاز جدید رو پوشش بدیم، یه ویژگی (property) جدید به کلاس User اضافه میکنیم.
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public bool IsEmailConfirmed { get; private set; } //ویژگی جدید
/* ChangeEmail(newEmail, company) method */
// New property
}کد ۷.۷
دو تا راه داری برای اینکه این بررسی رو انجام بدی.
اولین راه اینه که شرط رو مستقیم بذاری داخل متد ChangeEmail کلاس User:
public string ChangeEmail(string newEmail, Company company)
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
/* the rest of the method */
}بعدش کنترلر میتونه بسته به خروجی این متد، یا خطا برگردونه یا همهی کارهای جانبی لازم رو انجام بده.
public string ChangeEmail(int userId, string newEmail)
{
// Prepares the data
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
// Makes a decision
string error = user.ChangeEmail(newEmail, company);
if (error != null)
return error;
// Acts on the decision
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
return "OK";
}کد ۷.۸
این پیادهسازی کنترلر رو از تصمیمگیری آزاد نگه میداره، اما به قیمت یه ضعف در کارایی. شیء Company بدون هیچ شرطی از دیتابیس گرفته میشه، حتی وقتی ایمیل قبلاً تأیید شده و دیگه قابل تغییر نیست. این نمونهایه از انتقال همهی عملیات خواندن و نوشتن خارجی به لبههای عملیات تجاری.
نکته: من شرط جدیدی که رشتهی خطا رو بررسی میکنه افزایش پیچیدگی نمیدونم، چون این بخش متعلق به فاز «اقدام» هست؛ نه بخشی از فرآیند تصمیمگیری. همهی تصمیمها توسط کلاس User گرفته میشن و کنترلر فقط روی اون تصمیمها عمل میکنه.
گزینهی دوم اینه که بررسی ویژگی IsEmailConfirmed رو از کلاس User به کنترلر منتقل کنیم.
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
// Decision-making moved here from User.
if (user.IsEmailConfirmed)
return "Can't change a confirmed email";
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
return "OK";
}کد ۷.۹
با این پیادهسازی، کارایی حفظ میشه: شیء Company فقط وقتی از دیتابیس گرفته میشه که مطمئن باشیم ایمیل قابل تغییر هست. اما حالا فرآیند تصمیمگیری به دو بخش تقسیم شده:
- اینکه آیا باید تغییر ایمیل انجام بشه یا نه (توسط کنترلر)
- اینکه در طول تغییر ایمیل چه کارهایی باید انجام بشه (توسط کلاس User)
این وضعیت باعث میشه امکان تغییر ایمیل بدون بررسی ویژگی IsEmailConfirmed وجود داشته باشه، که در نتیجه کپسولهسازی مدل دامنه ضعیفتر میشه. چنین تکهتکه شدن (fragmentation) جداسازی بین منطق تجاری و هماهنگی رو مختل میکنه و کنترلر رو به سمت ناحیهی «بیشازحد پیچیده» میبره.
برای جلوگیری از این مشکل، میتونی یه متد جدید به کلاس User اضافه کنی به نام CanChangeEmail() و موفقیت اجرای اون رو پیششرط تغییر ایمیل قرار بدی. نسخهی اصلاحشده در لیست بعدی از الگوی CanExecute/Execute پیروی میکنه.
public string CanChangeEmail()
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
return null;
}
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
/* the rest of the method */
}کد ۷.۱۰
این رویکرد دو مزیت مهم داره:
- کنترلر دیگه لازم نیست چیزی از فرآیند تغییر ایمیل بدونه. فقط کافیه متد
CanChangeEmailرو صدا بزنه تا ببینه عملیات قابل انجام هست یا نه. توجه کن که این متد میتونه شامل چندین اعتبارسنجی باشه که همگی از کنترلر جدا و کپسوله شدن. - پیششرط اضافهشده در متد
ChangeEmailتضمین میکنه که ایمیل هیچوقت بدون بررسی تأیید تغییر داده نشه.
این الگو کمک میکنه همهی تصمیمها در لایهی دامنه متمرکز بشن. کنترلر دیگه گزینهای برای نادیده گرفتن بررسی تأیید ایمیل نداره، و همین عملاً نقطهی تصمیمگیری جدید رو از کنترلر حذف میکنه. بنابراین، حتی با وجود اینکه کنترلر هنوز شامل شرطی برای فراخوانی CanChangeEmail هست، نیازی به تست اون شرط نداری. تست واحد پیششرط داخل کلاس User خودش کافی خواهد بود.
نکته: برای سادهسازی، اینجا از رشته (string) برای نمایش خطا استفاده شده. توی پروژههای واقعی بهتره یه کلاس سفارشی مثل Result معرفی بشه تا موفقیت یا شکست عملیات رو مشخص کنه.
۷.۴.۲ استفاده از رویدادهای دامنه برای ردیابی تغییرات در مدل دامنه
گاهی سخت میشه فهمید چه مراحلی باعث شدن مدل دامنه به وضعیت فعلی برسه. با این حال، دونستن این مراحل میتونه مهم باشه، چون باید سیستمهای خارجی رو در جریان بذاری که دقیقاً چه اتفاقی توی برنامه افتاده. اگر این مسئولیت رو به کنترلرها بسپری، اونها پیچیدهتر میشن. برای جلوگیری از این مشکل، میتونی تغییرات مهم رو در مدل دامنه ردیابی کنی و بعد از کامل شدن عملیات تجاری، اون تغییرات رو به فراخوانی وابستگیهای خارج از پروسه تبدیل کنی. رویدادهای دامنه بهت کمک میکنن چنین ردیابیای رو پیادهسازی کنی.
تعریف: رویداد دامنه (Domain Event) رویدادی در برنامهست که برای متخصصان دامنه معنا و اهمیت داره. همین معنا داشتن برای متخصصان دامنه، رویدادهای دامنه رو از رویدادهای معمولی (مثل کلیک روی دکمه) متمایز میکنه. رویدادهای دامنه معمولاً برای اطلاعرسانی به برنامههای خارجی درباره تغییرات مهمی که در سیستم رخ دادن استفاده میشن.
سیستم CRM ما هم یه نیاز ردیابی داره: باید سیستمهای خارجی رو درباره تغییر ایمیل کاربران با ارسال پیام به message bus مطلع کنه. اما پیادهسازی فعلی یه ایراد داره: پیامها حتی وقتی ایمیل تغییر نکرده هم ارسال میشن، همونطور که توی لیست بعدی نشون داده شده.
// User
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return; // User email may not change.
/* the rest of the method */
}
// Controller
public string ChangeEmail(int userId, string newEmail)
{
/* preparations */
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
// The controller sends a message anyway.
_messageBus.SendEmailChangedMessage(userId, newEmail);
return "OK";
}شکل ۷.۱۱
این باگ رو میشه با انتقال بررسی «یکسان بودن ایمیل» به کنترلر حل کرد، اما دوباره مشکل تکهتکه شدن منطق تجاری پیش میاد. از طرفی نمیشه این بررسی رو داخل متد CanChangeEmail گذاشت، چون برنامه نباید خطا برگردونه وقتی ایمیل جدید همون ایمیل قبلی باشه.
البته این بررسی خاص احتمالاً منطق تجاری رو خیلی متلاشی نمیکنه، بنابراین کنترلر رو بیشازحد پیچیده نمیکنه اگر اون شرط رو داشته باشه. ولی ممکنه در شرایط سختتری قرار بگیری که جلوگیری از فراخوانیهای غیرضروری به وابستگیهای خارج از پروسه دشوار بشه، مگر اینکه اون وابستگیها رو وارد مدل دامنه کنی—که این کار مدل دامنه رو بیشازحد پیچیده میکنه.
تنها راه جلوگیری از چنین پیچیدگیای استفاده از رویدادهای دامنه (Domain Events) هست. این رویدادها کمک میکنن تغییرات مهم در مدل دامنه رو ردیابی کنی و بعد از کامل شدن عملیات تجاری، اونها رو به فراخوانی وابستگیهای خارجی تبدیل کنی، بدون اینکه منطق تجاری و کنترلرها بیشازحد سنگین بشن.
از دیدگاه پیادهسازی، یک رویداد دامنه (domain event) کلاسیه که دادههای لازم برای اطلاعرسانی به سیستمهای خارجی رو در خودش نگه میداره. در مثال ما، این دادهها شناسهی کاربر و ایمیلشه:
public class EmailChangedEvent
{
public int UserId { get; }
public string NewEmail { get; }
}نکته: رویدادهای دامنه همیشه باید به زمان گذشته نامگذاری بشن، چون چیزهایی رو نمایش میدن که قبلاً اتفاق افتاده. رویدادهای دامنه مقادیر هستن—اونها تغییرناپذیر (immutable) و قابل جایگزینی (interchangeable) هستن.
کلاس User مجموعهای از این رویدادها خواهد داشت که وقتی ایمیل تغییر میکنه، یک عضو جدید به اون اضافه میشه. این هم شکل متد ChangeEmail بعد از بازآرایی:
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
// A new event indicates the change of email.
EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
}کد ۷.۱۲
حالا کنترلر رویدادهای دامنه را به پیامهایی روی باس (Message Bus) تبدیل میکند
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
// Domain event processing
foreach (var ev in user.EmailChangedEvents)
{
_messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
}
return "OK";
}کد ۷.۱۳
توجه داشته باش که نمونههای Company و User همچنان بدون شرط در دیتابیس ذخیره میشن؛ منطق ذخیرهسازی وابسته به رویدادهای دامنه نیست. این موضوع به تفاوت بین تغییرات در دیتابیس و پیامها در باس برمیگرده.
اگر فرض کنیم هیچ برنامهای جز CRM به دیتابیس دسترسی نداره، ارتباط با دیتابیس بخشی از رفتار قابل مشاهدهی CRM محسوب نمیشه—اینها جزئیات پیادهسازی هستن. تا زمانی که وضعیت نهایی دیتابیس درست باشه، مهم نیست برنامه چند بار به دیتابیس فراخوانی انجام بده.
از طرف دیگه، ارتباط با message bus بخشی از رفتار قابل مشاهدهی برنامهست. برای حفظ قرارداد با سیستمهای خارجی، CRM باید فقط زمانی پیام روی باس قرار بده که ایمیل واقعاً تغییر کرده باشه.
از نظر کارایی، ذخیرهسازی بیشرط داده در دیتابیس پیامدهایی داره، اما این پیامدها نسبتاً ناچیزن. احتمال اینکه بعد از همهی اعتبارسنجیها ایمیل جدید همون ایمیل قبلی باشه خیلی کمه. استفاده از ORM هم میتونه کمک کنه، چون بیشتر ORMها وقتی وضعیت شیء تغییری نکرده باشه، رفتوبرگشت به دیتابیس انجام نمیدن.
میتونی راهحل رو با استفاده از رویدادهای دامنه کلیسازی کنی: یک کلاس پایه به نام DomainEvent استخراج کن و یک کلاس پایه برای همهی کلاسهای دامنه معرفی کن که شامل مجموعهای از این رویدادها باشه، مثل List<DomainEvent> events. همچنین میتونی یک event dispatcher جداگانه بنویسی تا رویدادهای دامنه رو بهجای کنترلرها بهصورت دستی ارسال کنه. در پروژههای بزرگتر، ممکنه به مکانیزمی برای ادغام رویدادهای دامنه قبل از ارسال نیاز داشته باشی. این موضوع خارج از محدودهی این کتابه، اما میتونی مقالهی من با عنوان “Merging domain events before dispatching” رو در http://mng.bz/YeVe بخونی.
رویدادهای دامنه مسئولیت تصمیمگیری رو از کنترلر حذف میکنن و اون رو به مدل دامنه منتقل میکنن، و در نتیجه تست واحد ارتباطات با سیستمهای خارجی سادهتر میشه. بهجای اینکه خود کنترلر رو تست کنی و از mockها برای جایگزینی وابستگیهای خارج از پروسه استفاده کنی، میتونی مستقیماً ایجاد رویداد دامنه رو در تستهای واحد بررسی کنی، همونطور که در ادامه نشون داده شده.
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@mycorp.com", UserType.Employee, false);
sut.ChangeEmail("new@gmail.com", company);
company.NumberOfEmployees.Should().Be(0);
sut.Email.Should().Be("new@gmail.com");
sut.Type.Should().Be(UserType.Customer);
// Simultaneously asserts the collection size and the element in the collection
sut.EmailChangedEvents.Should().Equal(new EmailChangedEvent(1, "new@gmail.com"));
}کد ۷.۱۴
البته هنوز لازم داری کنترلر رو تست کنی تا مطمئن بشی هماهنگی (orchestration) رو درست انجام میده، اما این کار به مجموعهی خیلی کوچکتری از تستها نیاز داره. این موضوع، بحث فصل بعد خواهد بود.
۷.۵ نتیجهگیری
کل این فصل یه خط فکری مشترک داشت: دور نگه داشتن اثرات جانبی از سیستمهای خارجی.
راهش اینه که این اثرات رو تا آخر عملیات تجاری فقط توی حافظه نگه داریم، بعد در نهایت اعمالشون کنیم. اینطوری میتونی با تستهای واحد ساده بررسیشون کنی، بدون اینکه پای وابستگیهای بیرونی وسط بیاد.
- رویدادهای دامنه در واقع یه لایهی انتزاعی روی پیامهایی هستن که قراره روی باس فرستاده بشن.
- تغییرات توی کلاسهای دامنه هم یه لایهی انتزاعی روی تغییراتی هستن که قراره توی دیتابیس ذخیره بشن.
نکته: تست کردن خودِ انتزاعها خیلی راحتتر از تست کردن چیزیه که اونها دارن انتزاع میکنن.
هرچند تونستیم با کمک رویدادهای دامنه و الگوی CanExecute/Execute همهی تصمیمگیریها رو داخل مدل دامنه نگه داریم، اما همیشه نمیشه این کار رو کرد. بعضی وقتها تکهتکه شدن منطق تجاری اجتنابناپذیره.
مثلاً بررسی یکتایی ایمیل رو نمیشه خارج از کنترلر انجام داد، مگر اینکه وابستگیهای خارج از پروسه رو وارد مدل دامنه کنیم. یا وقتی یکی از این وابستگیهای بیرونی خطا میده و باید مسیر عملیات تجاری تغییر کنه؛ تصمیمگیری در این مورد نمیتونه داخل لایهی دامنه باشه، چون دامنه خودش اون وابستگیها رو صدا نمیزنه. پس مجبور میشی این منطق رو داخل کنترلر بذاری و با تستهای یکپارچه پوشش بدی. با این حال، حتی با وجود این تکهتکه شدن، جدا کردن منطق تجاری از هماهنگی (orchestration) ارزش زیادی داره، چون فرآیند تست واحد رو خیلی سادهتر میکنه.
همونطور که نمیشه کاملاً منطق تجاری رو از کنترلرها حذف کرد، بهندرت میتونی همهی همکارها (collaborators) رو از کلاسهای دامنه کنار بذاری. و این اشکالی نداره. یکی، دو تا یا حتی سه همکار باعث پیچیدگی بیشازحد نمیشن، به شرطی که این همکارها وابستگی بیرونی نداشته باشن.
نکتهی مهم: برای بررسی تعاملات با این همکارها از mock استفاده نکن. این تعاملات ربطی به رفتار قابل مشاهدهی مدل دامنه ندارن. تنها اولین فراخوانی که از کنترلر به کلاس دامنه انجام میشه، ارتباط مستقیم با هدف کنترلر داره. همهی فراخوانیهای بعدی که کلاس دامنه به کلاسهای دامنهی دیگه در همان عملیات انجام میده، صرفاً جزئیات پیادهسازی هستن.
شکل ۷.۱۳ این مفهوم رو نشون میده. این شکل ارتباط بین اجزای CRM و رابطهشون با رفتار قابل مشاهده رو به تصویر میکشه. همونطور که از فصل ۵ یادت هست، اینکه یه متد جزو رفتار قابل مشاهدهی کلاس حساب بشه یا نه، بستگی داره به اینکه کلاینت کیه و هدف اون کلاینت چیه.
برای اینکه یه متد جزو رفتار قابل مشاهده باشه، باید یکی از این دو شرط رو داشته باشه:
- ارتباط مستقیم و فوری با یکی از اهداف کلاینت داشته باشه.
- باعث ایجاد یه اثر جانبی در یکی از وابستگیهای خارج از پروسه بشه که برای برنامههای خارجی قابل مشاهده باشه.

متد ChangeEmail در کنترلر جزو رفتار قابل مشاهده محسوب میشه، همینطور فراخوانیای که به message bus انجام میده. متد اول نقطهی ورود کلاینت خارجی هست و بنابراین شرط اول رو برآورده میکنه. فراخوانی باس هم پیامها رو به برنامههای خارجی میفرسته و شرط دوم رو برآورده میکنه. باید هر دوی این فراخوانیها رو بررسی کنی (که موضوع فصل بعده).
اما فراخوانی بعدی کنترلر به کلاس User ارتباط مستقیمی با اهداف کلاینت خارجی نداره. برای اون کلاینت مهم نیست کنترلر چطور تغییر ایمیل رو پیادهسازی میکنه؛ فقط مهمه که وضعیت نهایی سیستم درست باشه و پیام روی باس ارسال بشه. بنابراین وقتی رفتار کنترلر رو تست میکنی، نباید فراخوانیهایی که کنترلر به کلاس User انجام میده رو بررسی کنی.
وقتی یک سطح پایینتر در پشتهی فراخوانی (Call Stack) میری، وضعیت مشابهی پیش میاد. حالا کنترلر نقش کلاینت رو داره و متد ChangeEmail در کلاس User ارتباط مستقیم با هدف کلاینت یعنی تغییر ایمیل کاربر داره، پس باید تست بشه. اما فراخوانیهای بعدی که از User به Company انجام میشن، از دید کنترلر جزئیات پیادهسازی محسوب میشن. بنابراین تستی که متد ChangeEmail در User رو پوشش میده، نباید بررسی کنه که User چه متدهایی رو روی Company صدا میزنه. همین منطق وقتی یک سطح دیگه پایینتر میری و متدهای Company رو از دید User تست میکنی هم صدق میکنه.
رفتار قابل مشاهده و جزئیات پیادهسازی رو مثل لایههای یک پیاز در نظر بگیر. هر لایه رو از دید لایهی بیرونی تست کن و کاری نداشته باش که اون لایه چطور با لایههای زیرین ارتباط برقرار میکنه. وقتی این لایهها رو یکییکی کنار میزنی، زاویهی دیدت تغییر میکنه: چیزی که قبلاً جزئیات پیادهسازی بود، حالا تبدیل به رفتار قابل مشاهده میشه و باید با مجموعهی جدیدی از تستها پوشش داده بشه.
خلاصه
- پیچیدگی کد با تعداد نقاط تصمیمگیری در کد مشخص میشه؛ چه تصمیمهایی که خود کد میگیره و چه تصمیمهایی که کتابخانهها میگیرن.
- اهمیت دامنه نشون میده کد چقدر برای مسئلهی اصلی پروژه مهمه. معمولاً کد پیچیده اهمیت دامنهی بالایی داره و برعکس، ولی همیشه اینطور نیست.
- کدهای پیچیده یا با اهمیت دامنهی بالا بیشترین سود رو از تست واحد میبرن، چون تستهاشون محافظت بیشتری در برابر خطاهای برگشتی (regressions) دارن.
- تست واحد برای کدی که تعداد زیادی همکار (collaborators) داره هزینهی نگهداری بالایی داره، چون باید شرایط همهی همکارها رو آماده کنی و بعد وضعیت یا تعاملاتشون رو بررسی کنی.
- دستهبندی کدها بر اساس پیچیدگی، اهمیت دامنه و تعداد همکارها:
- مدل دامنه و الگوریتمها (پیچیدگی یا اهمیت بالا، همکار کم): بهترین بازدهی تست واحد.
- کد ساده (پیچیدگی و اهمیت پایین، همکار کم): ارزش تست نداره.
- کنترلرها (پیچیدگی و اهمیت پایین، همکار زیاد): فقط با تستهای یکپارچه بررسی بشن.
- کد بیشازحد پیچیده (پیچیدگی یا اهمیت بالا، همکار زیاد): باید به کنترلر و کد پیچیدهتر تقسیم بشه.
- هرچه کد مهمتر یا پیچیدهتر باشه، باید همکارهای کمتری داشته باشه.
- الگوی Humble Object کمک میکنه کدهای پیچیده تستپذیر بشن؛ منطق تجاری جدا میشه و کنترلر فقط یه لایهی ساده و نازک دور اون منطق میشه.
- معماریهای Hexagonal و Functional همین الگو رو پیاده میکنن:
- معماری Hexagonal منطق تجاری رو از وابستگیهای بیرونی جدا میکنه.
- معماری Functional منطق تجاری رو از همهی همکارها جدا میکنه، نه فقط وابستگیهای بیرونی.
- منطق تجاری و هماهنگی رو میتونی مثل عمق و عرض کد ببینی: کد یا باید عمیق باشه (پیچیده یا مهم) یا پهن (با همکارهای زیاد)، ولی هر دو با هم نه.
- پیششرطها رو فقط وقتی تست کن که اهمیت دامنه داشته باشن.
- سه ویژگی مهم در جداسازی منطق تجاری از هماهنگی:
- تستپذیری مدل دامنه → وابسته به تعداد و نوع همکارها.
- سادگی کنترلر → وابسته به تعداد نقاط تصمیمگیری.
- کارایی → وابسته به تعداد فراخوانیهای وابستگیهای بیرونی.
- در هر لحظه فقط میتونی دو تا از این سه ویژگی رو داشته باشی:
- انتقال همهی خواندن/نوشتنهای بیرونی به لبههای عملیات → سادگی کنترلر + تستپذیری دامنه، ولی افت کارایی.
- تزریق وابستگیهای بیرونی به مدل دامنه → کارایی + سادگی کنترلر، ولی آسیب به تستپذیری دامنه.
- خرد کردن تصمیمگیری به مراحل جزئیتر → کارایی + تستپذیری دامنه، ولی پیچیدگی کنترلر.
- الگوهای کاهش پیچیدگی کنترلر:
- الگوی CanExecute/Execute → برای هر متد Do یک متد CanDo تعریف میکنه و اجرای موفق CanDo پیششرط Do میشه. این الگو عملاً تصمیمگیری رو از کنترلر حذف میکنه.
- رویدادهای دامنه → تغییرات مهم در مدل دامنه رو ثبت میکنن و بعد اون تغییرات رو به فراخوانیهای وابستگیهای بیرونی تبدیل میکنن. این الگو مسئولیت ردیابی رو از کنترلر میگیره.
- تست کردن انتزاعها همیشه راحتتر از تست کردن چیزیه که انتزاع شدن. رویدادهای دامنه انتزاعی روی فراخوانیهای بیرونی هستن، و تغییرات در کلاسهای دامنه انتزاعی روی تغییرات دیتابیس.
