پرش به مطلب اصلی

هرچه رابط کاربری کم‌ادعاتر باشد، آزمون‌پذیری بیشتر می‌شود

· ۱۱ دقیقه مطالعه
مهدی مالوردی
مهندس نرم‌افزار و نویسندهٔ این سایت

یک بخش ساده‌ی نمایش در کنار بخشی که داده‌های خام را مرتب، تصمیم‌ها را اعمال، و خروجی آماده‌ی نمایش تولید می‌کند.

فرض کنید صفحه‌ای داریم که وضعیت حساب کاربر را نمایش می‌دهد. این صفحه باید تصمیم بگیرد آیا حساب فعال است یا نه، متن مناسب را نشان دهد، رنگ وضعیت را انتخاب کند، مبلغ را قالب‌بندی کند، پیام هشدار بسازد، و اگر کاربر محدودیت برداشت دارد، دکمه‌ی برداشت را غیرفعال کند.

در آغاز، شاید طبیعی به نظر برسد که همه‌ی این تصمیم‌ها را همان‌جا در خود رابط کاربری بنویسیم. بالاخره خروجی قرار است در همین صفحه دیده شود:

function AccountSummary({account}: {account: Account}) {
const isBlocked = account.status === 'blocked'
const isLowBalance = account.balance < 100_000
const canWithdraw = account.status === 'active' && account.balance > 0

const statusText = isBlocked ? 'مسدود' : 'فعال'
const statusColor = isBlocked ? 'red' : 'green'
const balanceText = `${account.balance.toLocaleString('fa-IR')} تومان`
const warningText = isLowBalance
? 'موجودی حساب کم است'
: null

return (
<section>
<h2>{account.ownerName}</h2>
<span className={statusColor}>{statusText}</span>
<p>{balanceText}</p>
{warningText && <p>{warningText}</p>}
<button disabled={!canWithdraw}>برداشت</button>
</section>
)
}

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

مسئله این نیست که رابط کاربری بد است. مسئله این است که رابط کاربری جای خوبی برای پنهان‌کردن منطق تصمیم‌گیری نیست.

در فصل «Presenters and Humble Objects»، رابرت مارتین به همین مشکل اشاره می‌کند: بعضی بخش‌های سیستم ذاتاً سخت‌تر آزمون می‌شوند؛ پس بهتر است این بخش‌ها تا حد ممکن ساده و کم‌منطق بمانند، و تصمیم‌های اصلی به بخش‌هایی منتقل شوند که آزمون‌کردنشان آسان‌تر است.

یادداشت

الگوی شیء فروتن (Humble Object) می‌گوید بخشی که سخت آزمون می‌شود باید تا حد ممکن کم‌منطق و ساده باشد.

در این نگاه، منطق تصمیم‌گیری، قالب‌بندی، آماده‌سازی داده و انتخاب وضعیت‌ها به بخشی منتقل می‌شود که بتوان آن را با آزمون‌های ساده و سریع بررسی کرد. بخش سخت‌آزمون، مثل رابط کاربری، چارچوب وب، پایگاه داده یا دستگاه بیرونی، فقط کارهای ضروری و کم‌ادعا را انجام می‌دهد.

مشکل UI پرمنطق چیست؟

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

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

در مثال حساب کاربر، این تصمیم‌ها درون کامپوننت پنهان شده‌اند:

  • وضعیت حساب چه متنی داشته باشد؟
  • چه رنگی برای وضعیت مناسب است؟
  • دکمه‌ی برداشت چه زمانی فعال باشد؟
  • هشدار موجودی چه زمانی نمایش داده شود؟
  • مبلغ چطور قالب‌بندی شود؟

این‌ها فقط جزئیات نمایشی نیستند. بعضی از آن‌ها تصمیم‌های محصولی یا کسب‌وکاری‌اند. اگر اشتباه شوند، کاربر پیام نادرست می‌بیند یا عملیاتی را می‌بیند که نباید انجام دهد.

هشدار

رابط کاربری پرمنطق معمولاً به‌آرامی ساخته می‌شود. اول فقط یک شرط کوچک اضافه می‌کنیم. بعد یک قالب‌بندی ساده. بعد یک پیام خطا. بعد چند حالت ویژه.

پس از مدتی، صفحه‌ای داریم که هم نمایش می‌دهد، هم تصمیم می‌گیرد، هم داده را تفسیر می‌کند، هم وضعیت کسب‌وکار را تعیین می‌کند. چنین صفحه‌ای سخت‌تر آزمون می‌شود، سخت‌تر تغییر می‌کند، و خطاهایش دیرتر دیده می‌شوند.

Presenter چه کمکی می‌کند؟

ایده‌ی ارائه‌دهنده (Presenter) این است که داده‌ی خام را بگیرد و آن را به شکلی آماده کند که رابط کاربری فقط آن را نمایش دهد. یعنی به‌جای اینکه کامپوننت خودش تصمیم بگیرد چه چیزی نشان دهد، یک بخش جدا این تصمیم‌ها را می‌گیرد و خروجی آماده‌ی نمایش می‌سازد.

تصویر زیر همین مرز را نشان می‌دهد: رابط کاربری در سمت نمایش می‌ماند و کارهای پرتصمیم، مثل قالب‌بندی داده، انتخاب وضعیت، آماده‌سازی متن‌ها و ساخت مدل نمایش، در Presenter انجام می‌شود. نتیجه این است که UI کم‌منطق‌تر و پایدارتر می‌ماند، و Presenter را می‌توان با آزمون‌های ساده‌تر بررسی کرد.

جریان آماده‌سازی داده در Presenter و ساده ماندن رابط کاربری در الگوی Humble Object.

برای نمونه، می‌توانیم منطق مثال قبلی را به یک تابع یا کلاس ساده منتقل کنیم:

interface AccountSummaryViewModel {
ownerName: string
statusText: string
statusTone: 'success' | 'danger'
balanceText: string
warningText: string | null
canWithdraw: boolean
}

function presentAccountSummary(account: Account): AccountSummaryViewModel {
const isBlocked = account.status === 'blocked'
const isLowBalance = account.balance < 100_000

return {
ownerName: account.ownerName,
statusText: isBlocked ? 'مسدود' : 'فعال',
statusTone: isBlocked ? 'danger' : 'success',
balanceText: `${account.balance.toLocaleString('fa-IR')} تومان`,
warningText: isLowBalance ? 'موجودی حساب کم است' : null,
canWithdraw: account.status === 'active' && account.balance > 0,
}
}

حالا کامپوننت ساده‌تر می‌شود:

function AccountSummary({account}: {account: Account}) {
const viewModel = presentAccountSummary(account)

return (
<section>
<h2>{viewModel.ownerName}</h2>
<span data-tone={viewModel.statusTone}>{viewModel.statusText}</span>
<p>{viewModel.balanceText}</p>
{viewModel.warningText && <p>{viewModel.warningText}</p>}
<button disabled={!viewModel.canWithdraw}>برداشت</button>
</section>
)
}

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

آزمون ساده‌تر، طراحی بهتر را نشان می‌دهد

وقتی منطق در Presenter قرار می‌گیرد، آزمون آن بسیار ساده‌تر می‌شود:

test('presents blocked account summary', () => {
const viewModel = presentAccountSummary({
ownerName: 'علی محمدی',
status: 'blocked',
balance: 50_000,
})

expect(viewModel).toEqual({
ownerName: 'علی محمدی',
statusText: 'مسدود',
statusTone: 'danger',
balanceText: '۵۰٬۰۰۰ تومان',
warningText: 'موجودی حساب کم است',
canWithdraw: false,
})
})

این آزمون نه به رندر نیاز دارد، نه به مرورگر، نه به کتابخانه‌ی رابط کاربری. یک ورودی می‌دهیم و خروجی را بررسی می‌کنیم. اگر قانون نمایش وضعیت یا پیام هشدار تغییر کند، آزمون دقیقاً همان بخش را نشان می‌دهد.

این همان سود اصلی الگوی Humble Object است: بخش سخت‌آزمون را کم‌منطق می‌کنیم و منطق را به جایی می‌بریم که آزمونش ساده، سریع و دقیق باشد.

Presenter فقط برای رابط کاربری نیست

معمولاً وقتی از Presenter حرف می‌زنیم، ذهنمان به صفحه و کامپوننت می‌رود. اما همین ایده در پاسخ‌های رابط برنامه‌نویسی کاربردی (API)، خروجی گزارش، پیام‌های صف، فایل‌های خروجی و حتی قالب ایمیل هم کاربرد دارد.

فرض کنید یک کنترلر وب چنین کاری می‌کند:

async function getAccountResponse(request: Request, response: Response) {
const account = await accountService.getById(request.params.id)

if (account.status === 'blocked') {
response.status(403).json({
title: 'حساب مسدود است',
canWithdraw: false,
})
return
}

response.json({
title: account.ownerName,
balance: `${account.balance.toLocaleString('fa-IR')} تومان`,
canWithdraw: account.balance > 0,
})
}

در اینجا هم منطق تصمیم‌گیری داخل بخشی رفته که بهتر است فروتن بماند. کنترلر وب باید درخواست را بگیرد، پاسخ را برگرداند و خطاهای چارچوب را مدیریت کند؛ اما لازم نیست همه‌ی منطق ارائه‌ی پاسخ را خودش نگه دارد.

می‌توانیم خروجی پاسخ را جدا کنیم:

function presentAccountResponse(account: Account): AccountResponse {
if (account.status === 'blocked') {
return {
statusCode: 403,
body: {
title: 'حساب مسدود است',
canWithdraw: false,
},
}
}

return {
statusCode: 200,
body: {
title: account.ownerName,
balance: `${account.balance.toLocaleString('fa-IR')} تومان`,
canWithdraw: account.balance > 0,
},
}
}

و کنترلر را ساده‌تر نگه داریم:

async function getAccountResponse(request: Request, response: Response) {
const account = await accountService.getById(request.params.id)
const result = presentAccountResponse(account)

response.status(result.statusCode).json(result.body)
}

در این طراحی، کنترلر همان Humble Object است: کم‌منطق، وابسته به چارچوب، و مسئول کارهای اجرایی. Presenter بخش قابل‌آزمون‌تر است: داده را می‌گیرد، تصمیم می‌گیرد و خروجی آماده‌ی پاسخ می‌سازد.

نکته

برای طراحی Presenter از این پرسش شروع کنید: «اگر رابط کاربری فقط قرار باشد نمایش دهد، چه داده‌ای باید آماده و بی‌ابهام به آن برسد؟»

Presenter خوب معمولاً خروجی‌ای می‌سازد که رابط کاربری برای نمایش آن به تصمیم‌های پیچیده نیاز نداشته باشد. یعنی متن‌ها، وضعیت‌ها، قالب‌بندی‌ها، امکان انجام عملیات، و پیام‌های مهم تا حد ممکن پیش از رسیدن به UI آماده شده باشند.

مرز دقیق کجاست؟

نباید از این الگو برداشت افراطی داشته باشیم. قرار نیست هیچ شرطی در رابط کاربری باقی نماند. مثلاً شرطی مثل «اگر warningText وجود داشت، آن را نمایش بده» معمولاً مسئله‌ای ندارد. این یک شرط نمایشی ساده است.

اما شرط‌هایی که معنای کسب‌وکاری یا محصولی دارند بهتر است از رابط کاربری بیرون بیایند. برای نمونه:

  • آیا کاربر اجازه‌ی برداشت دارد؟
  • آیا این وضعیت باید هشدار قرمز بسازد؟
  • آیا پاسخ باید با کد ۴۰۳ برگردد یا ۲۰۰؟
  • آیا این مبلغ باید پنهان شود؟
  • آیا کاربر باید پیام محدودیت ببیند؟

این‌ها تصمیم‌های مهم‌تری هستند و آزمون‌کردنشان باید آسان باشد.

یک قاعده‌ی ساده این است: اگر برای اطمینان از یک تصمیم مجبورید کامپوننت را رندر کنید، ولی خود تصمیم ربطی به رندر ندارد، احتمالاً آن تصمیم جای بهتری بیرون از UI دارد.

Humble Object یعنی بی‌ارزش‌کردن UI نیست

گاهی وقتی می‌گوییم رابط کاربری باید کم‌منطق باشد، ممکن است این سوءبرداشت پیش بیاید که UI بخش کم‌اهمیت سیستم است. این برداشت درست نیست. رابط کاربری بسیار مهم است، چون جایی است که کاربر با سیستم روبه‌رو می‌شود.

اما مهم‌بودن UI به این معنا نیست که باید همه‌ی تصمیم‌ها در آن باشد. اتفاقاً چون رابط کاربری مهم است و خطاهایش مستقیم دیده می‌شوند، بهتر است تا حد ممکن ساده، قابل‌فهم و کم‌ریسک بماند.

در الگوی Humble Object، بخش فروتن تحقیر نمی‌شود؛ بلکه از بار اضافه محافظت می‌شود. رابط کاربری کار خودش را خوب انجام می‌دهد: نمایش، دریافت رخداد، فراخوانی عملیات، و اتصال به محیط اجرا. تصمیم‌های پیچیده‌تر در جایی قرار می‌گیرند که با هزینه‌ی کمتر آزمون می‌شوند.

چه زمانی Presenter لازم نیست؟

هر صفحه یا پاسخ ساده‌ای نیاز به Presenter جدا ندارد. اگر یک صفحه فقط چند فیلد را مستقیم نمایش می‌دهد و تصمیم خاصی در آن نیست، افزودن Presenter ممکن است فقط کد را شلوغ کند.

برای نمونه، چنین چیزی شاید نیازی به Presenter جدا نداشته باشد:

function UserName({user}: {user: User}) {
return <span>{user.name}</span>
}

اما اگر همین بخش کم‌کم شروع کند به تصمیم‌گیری درباره‌ی وضعیت، مجوز، قالب‌بندی، پیام، رنگ، هشدار و رفتار دکمه‌ها، آن‌وقت بهتر است مرزها را دوباره بررسی کنیم.

پس معیار اصلی، وجود منطق تصمیم‌گیری و هزینه‌ی آزمون است، نه صرفاً تعداد خط‌های کد.

یک ساختار ساده برای جداکردن Presenter

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

account-summary/
account-summary.tsx
account-summary-presenter.ts
account-summary-presenter.test.ts

یا اگر با پاسخ رابط برنامه‌نویسی کاربردی کار می‌کنید:

account/
account-controller.ts
account-response-presenter.ts
account-response-presenter.test.ts

در این ساختار، فایل UI یا کنترلر کوچک و اجرایی می‌ماند، و منطق آماده‌سازی خروجی در فایل Presenter قرار می‌گیرد. آزمون هم کنار همان منطق نوشته می‌شود.

این ساختار قرار نیست قانون همیشگی باشد، اما کمک می‌کند مرز بین «نمایش» و «آماده‌سازی برای نمایش» روشن بماند.

اطلاع

در این بحث، مدل نمایش یا View Model خروجی‌ای است که Presenter برای مصرف رابط کاربری یا کنترلر آماده می‌کند.

این مدل معمولاً داده‌ی خام دامنه نیست. داده‌ای است که برای نمایش آماده شده: متن‌ها، برچسب‌ها، وضعیت‌ها، امکان انجام عملیات، پیام‌ها و قالب‌بندی‌های لازم.

پیشنهاد عملی

برای استفاده از این ایده، لازم نیست همه‌ی صفحه‌ها و کنترلرها را بازنویسی کنید. از بخش‌هایی شروع کنید که منطق تصمیم‌گیری در آن‌ها پنهان شده و آزمون‌کردنشان سخت است.

چیزهایی که باید بررسی شوند:

  • آیا صفحه یا کنترلر چند شرط محصولی یا کسب‌وکاری دارد؟
  • آیا برای آزمون یک تصمیم ساده مجبورید UI را رندر کنید؟
  • آیا قالب‌بندی متن، عدد، وضعیت و پیام‌ها در چند جای مختلف تکرار شده است؟
  • آیا خطاهای نمایشی به‌خاطر تصمیم‌های پنهان در UI رخ می‌دهند؟
  • آیا می‌توان خروجی موردنیاز صفحه را به‌صورت یک View Model روشن تعریف کرد؟

چیزهایی که نباید بی‌دلیل تغییر کنند:

  • کامپوننت‌های ساده‌ای که فقط داده را نمایش می‌دهند.
  • شرط‌های نمایشی کوچک، مثل وجود یا نبود یک متن آماده.
  • ساختارهایی که هنوز درد واقعی در آزمون یا تغییر ایجاد نکرده‌اند.
  • منطق‌هایی که جای درستشان واقعاً در خود تعامل کاربر است، نه در Presenter.

برای اعتبارسنجی تغییر، دست‌کم این گام‌ها را اجرا کنید:

npm test
npm run lint
npm run typecheck

اگر پروژه‌ی شما این فرمان‌ها را ندارد، معادل آن‌ها را اجرا کنید. بعد از جداسازی، بهتر است آزمون Presenter را جداگانه بنویسید و یک آزمون سبک هم برای خود UI نگه دارید تا مطمئن شوید داده‌ی آماده‌شده درست نمایش داده می‌شود.

جمع‌بندی

الگوی Presenter و Humble Object می‌خواهد ما منطق مهم را از بخش‌هایی جدا کنیم که آزمون‌کردنشان سخت‌تر است. رابط کاربری، کنترلر وب، فریم‌ورک و ابزارهای بیرونی لازم‌اند، اما بهتر است کم‌منطق بمانند.

هرچه بخش سخت‌آزمون کم‌ادعاتر باشد، آزمون‌پذیری سیستم بیشتر می‌شود. تصمیم‌ها، قالب‌بندی‌ها و آماده‌سازی خروجی اگر در Presenter قرار بگیرند، ساده‌تر آزمون می‌شوند و تغییرشان ریسک کمتری دارد.

طراحی خوب همیشه به معنی ساختن لایه‌های زیاد نیست. گاهی یعنی یک تصمیم ساده: چیزی که سخت آزمون می‌شود، فروتن بماند؛ چیزی که مهم و پرمنطق است، به جایی برود که بتوان آن را روشن، سریع و مطمئن آزمود.