عنوان:

‫بهبود کیفیت تست‌نویسی با اجتناب از شبیه‌سازی (Mocking) کلاس‌های مشخص (Concrete Classes)


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۱/۰۹ ۲۲:۱۵
آدرس: www.dntips.ir
مقدمه
تست‌نویسی واحد (Unit Testing) یکی از ارکان اصلی توسعه نرم‌افزارهای باکیفیت در چارچوب NET. مایکروسافت است. این فرآیند به توسعه‌دهندگان کمک می‌کند تا از صحت عملکرد کدهای خود اطمینان حاصل کنند و از بروز خطاها جلوگیری کنند. با این حال، استفاده نادرست از ابزارها و تکنیک‌های تست‌نویسی، مانند شبیه‌سازی (Mocking) کلاس‌های مشخص (Concrete Classes)، می‌تواند به پیچیدگی غیرضروری و کاهش کیفیت تست‌ها منجر شود. یکی از الگوهای نادرست رایج، شبیه‌سازی کلاس‌های مشخص به جای استفاده از واسط‌ها (Interfaces) است که می‌تواند اصول طراحی مانند اصل مسئولیت واحد (Single Responsibility Principle) را نقض کند و تست‌ها را شکننده‌تر کند. در این مقاله، به بررسی این الگوی نادرست، دلایل مشکل‌ساز بودن آن و راهکارهای جایگزین می‌پردازیم تا توسعه‌دهندگان NET. بتوانند تست‌هایی کارآمدتر و قابل‌نگهداری‌تر بنویسند.

مشکل شبیه‌سازی کلاس‌های مشخص
شبیه‌سازی در تست‌نویسی به معنای جایگزینی وابستگی‌های واقعی یک کلاس با نسخه‌های جعلی است که رفتار مشخصی را شبیه‌سازی می‌کنند. ابزارهایی مانند Moq یا NSubstitute معمولاً برای این منظور استفاده می‌شوند. با این حال، وقتی این شبیه‌سازی روی کلاس‌های مشخص اعمال می‌شود، چالش‌هایی به وجود می‌آید. به عنوان مثال، فرض کنید یک کنترلر به نام CustomerOrdersController داریم که به یک سرویس حمل‌ونقل وابسته است:
public class ShipmentService
{
    public List<ShipmentRecord> FetchShipments(int customerId)
    {
        // فراخوانی سرویس خارجی برای دریافت داده‌های حمل‌ونقل
    }
}

public class CustomerOrdersController
{
    private readonly ShipmentService _shipmentService;

    public CustomerOrdersController(ShipmentService shipmentService)
    {
        _shipmentService = shipmentService;
    }

    public string GetSummary(int customerId)
    {
        var shipments = _shipmentService.FetchShipments(customerId);
        return $"تعداد محموله‌ها: {shipments.Count}";
    }
}
اگر بخواهیم ShipmentService را مستقیماً شبیه‌سازی کنیم، با مشکلاتی مواجه می‌شویم. اکثر ابزارهای شبیه‌سازی، مانند Moq، تنها می‌توانند متدهای مجازی (Virtual) یا واسط‌ها را شبیه‌سازی کنند. این یعنی یا باید متدهای کلاس را مجازی کنیم یا به سراغ ابزارهای پیشرفته‌تر و پولی مانند JustMock برویم که محدودیت‌های خاص خود را دارند. این کار نه‌تنها کد را پیچیده‌تر می‌کند، بلکه وابستگی تست به پیاده‌سازی داخلی را افزایش می‌دهد و شکنندگی تست‌ها را بیشتر می‌کند.
علاوه بر این، شبیه‌سازی کلاس‌های مشخص اغلب به نقض اصل مسئولیت واحد منجر می‌شود. در مثال بالا، ShipmentService هم منطق کسب‌وکار را مدیریت می‌کند و هم به سرویس خارجی متصل می‌شود. این ترکیب مسئولیت‌ها، تست‌نویسی را دشوارتر می‌کند، زیرا باید رفتارهای خارجی را نیز شبیه‌سازی کنیم که ممکن است ناپایدار باشند.

راهکار: استفاده از واسط‌ها و جداسازی مسئولیت‌ها
به جای شبیه‌سازی کلاس‌های مشخص، بهتر است از واسط‌ها استفاده کنیم و مسئولیت‌ها را جدا کنیم. این رویکرد طراحی بهتری را تضمین می‌کند و تست‌نویسی را ساده‌تر می‌سازد. بیایید مثال بالا را بازطراحی کنیم:
public interface IShipmentProvider
{
    List<ShipmentRecord> FetchShipments(int customerId);
}

public class ShipmentService : IShipmentProvider
{
    public List<ShipmentRecord> FetchShipments(int customerId)
    {
        // فراخوانی سرویس خارجی برای دریافت داده‌های حمل‌ونقل
    }
}

public class DeliveryStatistics
{
    public (double totalWeight, double totalCost) ComputeStatistics(List<ShipmentRecord> shipments)
    {
        double totalWeight = shipments.Sum(x => x.Weight);
        double totalCost = shipments.Sum(x => x.Cost);
        return (totalWeight, totalCost);
    }
}

public class CustomerOrdersController
{
    private readonly DeliveryStatistics _statistics;
    private readonly IShipmentProvider _shipmentProvider;

    public CustomerOrdersController(DeliveryStatistics statistics, IShipmentProvider shipmentProvider)
    {
        _statistics = statistics;
        _shipmentProvider = shipmentProvider;
    }

    public string GetSummary(int customerId)
    {
        var shipments = _shipmentProvider.FetchShipments(customerId);
        var (totalWeight, totalCost) = _statistics.ComputeStatistics(shipments);
        return $"وزن کل: {totalWeight}, هزینه کل: {totalCost}";
    }
}
در این نسخه، با تعریف واسط IShipmentProvider, مسئولیت دریافت داده‌ها از سرویس خارجی جدا شده و منطق محاسباتی به کلاس DeliveryStatistics منتقل شده است. حالا در تست‌ها، می‌توانیم به‌راحتی واسط را شبیه‌سازی کنیم:
[Fact]
public void GetSummary_ReturnsCorrectSummary()
{
    // Arrange
    var mockShipmentProvider = new Mock<IShipmentProvider>();
    mockShipmentProvider.Setup(sp => sp.FetchShipments(1))
        .Returns(new List<ShipmentRecord> { new ShipmentRecord { Weight = 10, Cost = 100 } });
    var stats = new DeliveryStatistics();
    var controller = new CustomerOrdersController(stats, mockShipmentProvider.Object);

    // Act
    var result = controller.GetSummary(1);

    // Assert
    Assert.Equal("وزن کل: 10, هزینه کل: 100", result);
}
این تست ساده، قابل‌فهم و مستقل از پیاده‌سازی داخلی است. استفاده از واسط‌ها همچنین امکان تزریق وابستگی (Dependency Injection) را تسهیل می‌کند که یک روش استاندارد در NET. است.

چرا شبیه‌سازی کلاس‌های مشخص یک الگوی نادرست است؟
شبیه‌سازی کلاس‌های مشخص چندین مشکل را ایجاد می‌کند:
  1. پیچیدگی غیرضروری: نیاز به متدهای مجازی یا ابزارهای خاص، کد را پیچیده‌تر می‌کند.
  2. شکنندگی تست‌ها: تغییرات در پیاده‌سازی کلاس، تست‌ها را خراب می‌کند، حتی اگر رفتار خارجی تغییر نکند.
  3. نقض اصول طراحی: ترکیب مسئولیت‌ها در یک کلاس مشخص، انعطاف‌پذیری و قابلیت نگهداری کد را کاهش می‌دهد.

در مقابل، استفاده از واسط‌ها و جداسازی مسئولیت‌ها، تست‌ها را قابل‌اعتمادتر و کد را ماژولارتر می‌کند. این رویکرد با اصول SOLID، به‌ویژه اصل وابستگی معکوس (Dependency Inversion Principle)، هم‌راستاست.

نکات عملی برای توسعه‌دهندگان NET.
  • از واسط‌ها استفاده کنید: حتی اگر در ابتدا به نظر غیرضروری بیاید، واسط‌ها انعطاف‌پذیری و تست‌پذیری را افزایش می‌دهند.
  • مسئولیت‌ها را جدا کنید: هر کلاس باید یک وظیفه مشخص داشته باشد. این کار شبیه‌سازی و تست‌نویسی را ساده‌تر می‌کند.
  • ابزارهای مناسب را انتخاب کنید: ابزارهایی مانند Moq برای شبیه‌سازی واسط‌ها عالی هستند و نیازی به پیچیدگی‌های اضافی ندارند.
  • بازطراحی را در نظر بگیرید: اگر با کلاس‌های مشخص پیچیده مواجه هستید، آن‌ها را به واسط‌ها و کلاس‌های مجزا تبدیل کنید.

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


مشاهده مطلب اصلی