۳۰ روز با TDD: روز یازدهم - درباره Mocking

روز یازدهم از مجموعه نوشته‌های ۳۰ روز با توسعه آزمون محور

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

Mocking چیست؟

اغلب نرم‌افزارهایی که شما توسعه می‌دهید، از کلاس‌ها و اجزا (component) مختلفی تشکیل می‌شوند. در حالت ایده‌آل، هر کلاس یا جزء برای اجرای وظایف خاصی طراحی می‌شوند که این همان Single Responsibility Principle است. این کلاس‌ها و اجزا در کنار هم یک برنامه (Application) را تشکیل می‌دهند. طبیعت خاص کلاس‌های و اجزای واحد، وابستگی را غیرقابل اجتناب می‌کند. رابط کاربر (User Interface) شما به کلاس‌های business domain وابسته است و خود کلاس‌های business domain به چیزهایی مثل انبارهای خارجی داده (دیتابیس، فایل سیستم)، وب سرویس یا منابع و سیستم‌های خارجی دیگر وابسته هستند.

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

کد بالا یک کد معمول در برنامه‌های LOB یا Line of Business محسوب می‌شود. احتمالاً ده‌ها و شاید صدها بار کدهایی مثل این نوشته‌اید. در مورد این مثال، متد مربوط به قرار دادن یک سفارش در یک برنامه تجارت‌الکترونیکی است. یک متد به نام PlaceOrder داریم که یک سفارش برای یک مشتری از پیش تعریف شده ایجاد می‌کند. مانند بسیاری از برنامه‌‌های مشابه من باید بعضی داده‌ها را ذخیره کنم که در اغلب موارد به معنی ذخیره در دیتابیس است. کلاس من متدی به نام Save دارد که یک فراخوانی از شی که IOrderDataService را پیاده‌سازی کرده را صدا می‌زند. این اینترفیس، یک abstraction (تجرد) از دیتابیس من است. من همچنین باید با Customer Service کار کنم تا سفارشات و مشتریان به هم مرتبط شوند. در این مثال همچنین نیازمند لاگ کردن همه سفارشات هستیم، پس فراخوانی logging service را هم نیاز داریم.

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

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

دلیل دیگری که نمی‌خواهم از کلاس‌ها واقعی برای این تست‌ها استفاده کنم این است که این کلاس‌های واقعی باعث می‌شوند تست غیرقابل پیش‌بینی شود. اگر آزمون واحد من مجبور باشد که هر دفعه از دیتابیس چیزی را بخواند، نتایج غیرقابل پیش‌بینی خواهند بود. چه اتفاقی می‌افتد اگر یک برنامه‌نویس دیگر مقدار دیگری را از دیتابیس انتظار داشته باشد؟ چه اتفاقی می‌افتد اگر یک برنامه‌نویس دیگر تست دیگری را اجرا کند که مقدار مورد نظر من را تغییر دهد؟ ناگهان تست من غیرقابل پیش‌بینی می‌شود، یک لحظه تست pass می‌شود و ناگهان fail در حالی که کد اصلی هیچ تغییری نکرده است. من نه تنها برای پیدا کردن راحت مشکلات تست را ایزوله می‌کنم، بلکه برای حفظ ثبات تست‌ها نیز باید آن‌ها را ایزوله کنم.

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

همانطور که در ابتدای این نوشته اشاره کردم، Dependency Injection گام نخست برای راه حل این مشکل است. همانطور که در کد مثال بالا می‌توانید مشاهده کنید من یک سازنده (constructor) برای این کلاس دارم که که وابستگی‌ها را به عنوان پارامتر دریافت می‌کند. همچنین می‌توانید ببینید که این وابستگی‌ها بر اساس interface ایجاد شده‌اند. همانطور که از مطالب قبلی این سری نوشته‌ها به یاد دارید، این بدان معنی است که من فقط محدود به پاس دادن اشیا معمول در زمان اجرا نیستم، بلکه می‌توانیم هر چیزی که آن interface ها را پیاده‌سازی کند پاس بدهم. این نکته mocking است: من می‌توانم اشیا mock شده یا fake را پاس بدهم که به عنوان اشیا معمولی که OrderService از آن‌ها استفاده می‌کند قرار بگیرند. این mock ها می‌توانند پاسخ‌های مشخص برگردانند، تعداد دفعاتی که فراخوانی شده‌اند را بشمارند یا حتی بعضی اعتبارسنجی‌ها را انجام بدهند، همه کار به جز کار واقعی که object های اصلی انجام می‌دهند. این یعنی mock ها هیچ الگوریتم تجاری را پیاده‌سازی نمی‌کنند یا با منابع خارجی در تعامل نیستند. ما می‌توانیم هر چقدر بخواهیم تست‌هایمان را اجرا کنیم بدون اینکه نگران تغییر وضعیت دیتابیس باشیم.

Mocks و Stubs و Fakes

عبارت Mock به یک اصطلاح عمومی برای انواع مختلف mock هایی که در unit test ها به کار می‌بریم تبدیل شده است. چه زمانی mock یک mock نیست؟ چه زمانی stub یا fake است؟ تفاوت این‌ها چیست؟ تفسیر Martin Fowler:

Fakes: یک Fake شی‌ای است که یک مکانیزم داخلی دارد که نتایج قابل پیش‌بینی برمی‌گرداند، اما منطق کاری واقعی را پیاده‌سازی نکرده است.

Stubs: یک Stub شی‌ای است که یک نتیجه مشخص را بر اساس یک سری ورودی مشخص برمی‌گرداند. اگر من به stub بگویم که هر وقت شخصی با شناسه 42 را خواستم عبارت John Doe را برگردان، stub همین کار را خواهد کرد. با این حال اگر من از stub بخواهم که شخصی با شناسه 41 را برگرداند، نمی‌داند چه کار باید بکند. بر حسب اینکه از کدام mocking framework استفاده کنم، stub یا exception ایجاد می‌کند یا یک شی null برمی‌گرداند. stub می‌تواند بعضی اطلاعات مربوط به نحوه فراخوانی مثل تعداد فراخوانی یا اینکه با چه داده‌هایی فراخوانی شده است را به یاد داشته باشد.

Mocks: یک Mock یک نسخه پیچیده‌تر از stub است. همچنان مانند stub مقادیر را برمی‌گرداند، اما همچنین می‌تواند طوری برنامه‌ریزی شود که باید چند بار فراخوانی شود، به چه ترتیب یا به چه داده‌هایی.

Spy: یک Spy نوعی mock است که یک شی را می‌گیرد و به جای ایجاد یک شی mock متدهایی که tester می‌خواهد mock کند را جایگزین می‌کند. Spy ها برای کدهای غیر TDD عالی هستند، اما باید خیلی مراقب باشید چرا که فراموش کردن چیزی که می‌بایست mock شود ممکن است نتایج فاجعه‌باری داشته باشد.

Dummy: یک Dummy شی‌ای است که می‌تواند به عنوان جایگزین یک شی دیگر پاس داده شود اما استفاده نمی‌شود. Dummy ها در واقع placeholder محسوب می‌شوند.

Mocking Frameworks

بر اساس تعاریف بالا، بعضی انواع mock ها هستند که خودمان می‌توانیم آن‌ها را ایجاد کنیم مثل Fake ها و Dummy ها. همچنین می‌توان یک stub ساده نوشت، اما زمان انجام این کار بر روی بهره‌وری من تاثیر خواهد داشت. خوشبختانه راه بهتری وجود دارد: استفاده از فریمورک‌های mocking. تعداد زیادی Mocking Framework برای TDD روی دات نت موجود است. برای این سری از نوشته‌ها من از JustMock و JustMock Lite استفاده می‌کنم.

فریمورک‌های mock جز ابزارهای ضروری TDD هستند. این فریمورک‌ها معمولاً یک اینترفیس ساده دارند برای ایجاد stub های ساده که بر اساس ورودی، مقدار خاص یا exception برمی‌گردانند.

ادامه دارد…