پادشاهِ کُدنویسا شو!
کینگتو - آموزش برنامه نویسی تخصصصی - دات نت - سی شارپ - بانک اطلاعاتی و امنیت

تست واحد (Unit Testing) برای کلاس‌های انتزاعی (Abstract Classes) و اینترفیس‌ها (Interfaces) در .NET

6 بازدید 0 نظر ۱۴۰۵/۰۳/۲۷
یکی از نشانه‌های یک مهندس نرم‌افزار حرفه‌ای، پایبندی به اصول SOLID، به ویژه اصل وارونگی وابستگی (Dependency Inversion Principle) و اصل باز-بسته (Open-Closed Principle) است. برای تحقق این اصول، ما به وفور از تکتیک‌های شیءگرایی مانند اینترفیس‌ها (Interfaces) و کلاس‌های انتزاعی (Abstract Classes) استفاده می‌کنیم تا قراردادها را از پیاده‌سازی‌های ملموس (Concrete) جدا کنیم.

اما وقتی نوبت به نوشتن تست واحد (Unit Test) می‌رسد، توسعه‌دهندگان اغلب با یک پارادوکس مواجه می‌شوند: «چگونه چیزی را تست کنیم که مستقیماً قابل نمونه‌سازی (Instantiate) نیست؟» خطای معروفی که اکثر برنامه نویسان تازه‌کار با آن مواجه می‌شوند این است که تلاش می‌کنند با دستور new یک کلاس ابسترکت را بسازند و کامپایلر دات‌نت به آن‌ها اجازه این کار را نمی‌دهد. در این مقاله تخصصی، به عنوان یک معمار نرم‌افزار ارشد، استراتژی‌ها، الگوها و ترفندهای مهندسی برای تست واحد کلاس‌های انتزاعی و اینترفیس‌ها را با استفاده از فریم‌ورک‌های محبوب xUnit و Moq/NSubstitute در .NET 8/9 بررسی خواهیم کرد.

 

تفاوت استراتژیک در تست اینترفیس در مقابل کلاس انتزاعی

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

  • اینترفیس‌ها (Interfaces): هیچ لایه منطقی یا کدی ندارند (صرف نظر از Default Interface Methods در سی‌شارپ مدرن که استثناست). اینترفیس‌ها فقط یک قرارداد (Contract) هستند. بنابراین، ما خودِ اینترفیس را تست نمی‌کنیم؛ بلکه رفتار کلاسی که اینترفیس را پیاده‌سازی کرده، یا نحوۀ تعامل کلاس‌های دیگر با این اینترفیس را از طریق Mocking تست می‌کنیم.

  • کلاس‌های انتزاعی (Abstract Classes): این کلاس‌ها علاوه بر تعریف قرارداد، می‌توانند حاوی کدهای اشتراکی و منطق تجاری واقعی (Concrete Methods) باشند. چالش اصلی اینجاست: ما باید مطمئن شویم منطق پیاده‌سازی شده در متدهای غیرانتزاعیِ (Non-abstract) این کلاس، به درستی کار می‌کند.

 

استراتژی‌های تست واحد برای کلاس‌های انتزاعی (Abstract Classes)

برای تست کردن کدهای موجود در یک کلاس انتزاعی، سه رویکرد استاندارد در مهندسی نرم‌افزار وجود دارد. بیایید هر کدام را با نمونه کد واقعی تحلیل کنیم.

فرض کنید کلاس انتزاعی زیر را برای محاسبه پاداش کارکنان داریم:

public abstract class EmployeeBonusCalculator
{
    // متد ملموس که حاوی منطق اصلی است و باید تست شود
    public decimal CalculateTotalPayout(decimal baseSalary, decimal performanceScore)
    {
        if (baseSalary <= 0) throw new ArgumentException("Base salary must be positive.");
        
        decimal multiplier = GetPositionMultiplier(); // متد انتزاعی
        return baseSalary + (baseSalary * performanceScore * multiplier);
    }

    // متد انتزاعی که پیاده‌سازی آن به کلاس‌های فرزند واگذار شده است
    protected abstract decimal GetPositionMultiplier();
}

روش اول: استفاده از یک کلاس پیاده‌سازی جعلی مخصوص تست (Test-Specific Concrete Class)

این روش، سنتی‌ترین و ایمن‌ترین راه است. ما یک لایه ملموسِ بسیار ساده (Concrete Fake) صرفاً درون پروژه تست می‌سازیم که از کلاس انتزاعی ارث‌بری می‌کند.

using Xunit;

namespace Domain.Tests
{
    // ۱. ساخت یک کلاس فرزند جعلی فقط برای محیط تست
    public class FakeBonusCalculator : EmployeeBonusCalculator
    {
        private readonly decimal _stubbedMultiplier;

        public FakeBonusCalculator(decimal stubbedMultiplier)
        {
            _stubbedMultiplier = stubbedMultiplier;
        }

        protected override decimal GetPositionMultiplier()
        {
            return _stubbedMultiplier;
        }
    }

    // ۲. نوشتن تست‌های واحد
    public class EmployeeBonusCalculatorTests
    {
        [Fact]
        public void CalculateTotalPayout_ValidInputs_ReturnsCorrectAmount()
        {
            // Arrange
            var calculator = new FakeBonusCalculator(0.1m); // ضریب ۱۰ درصد
            decimal baseSalary = 50000;
            decimal performanceScore = 2; // عملکرد عالی

            // Act
            decimal result = calculator.CalculateTotalPayout(baseSalary, performanceScore);

            // Assert
            // 50000 + (50000 * 2 * 0.1) = 50000 + 10000 = 60000
            Assert.Equal(60000, result);
        }

        [Fact]
        public void CalculateTotalPayout_NegativeSalary_ThrowsArgumentException()
        {
            // Arrange
            var calculator = new FakeBonusCalculator(0.1m);

            // Act & Assert
            Assert.Throws<ArgumentException>(() => calculator.CalculateTotalPayout(-1000, 1));
        }
    }
}

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

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

روش دوم: استفاده از فریم‌ورک‌های Mocking (مانند Moq) برای ابسترکشن

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

برای تست متدهای Protected با کتابخانه Moq، باید از ویژگی CallBase = true استفاده کنیم تا متدهای ملموس اصلی اجرا شوند.

using Moq;
using Moq.Protected;
using Xunit;

public class EmployeeBonusCalculatorMoqTests
{
    [Fact]
    public void CalculateTotalPayout_UsingMoq_ReturnsCorrectAmount()
    {
        // Arrange
        var mockCalculator = new Mock<EmployeeBonusCalculator>();

        // تنظیم رفتار متد protected و انتزاعی GetPositionMultiplier
        mockCalculator.Protected()
            .Setup<decimal>("GetPositionMultiplier")
            .Returns(0.2m); // ضریب ۲۰ درصد

        // فعال‌سازی CallBase بسیار حیاتی است تا کدهای متد اصلی CalculateTotalPayout اجرا شوند
        mockCalculator.CallBase = true;

        // Act
        decimal result = mockCalculator.Object.CalculateTotalPayout(10000, 1);

        // Assert
        // 10000 + (10000 * 1 * 0.2) = 12000
        Assert.Equal(12000, result);
    }
}

مزیت: عدم نیاز به ساخت کلاس‌های فیک فیزیکی؛ تست‌ها فشرده‌تر و داینامیک‌تر می‌شوند.

عیب: استفاده از رشته‌های متنی (Magic Strings) مانند "GetPositionMultiplier" برای دسترسی به متدهای Protected ضریب خطا در ریفکتورینگ کد را بالا می‌برد (البته در نسخه های جدید Moq با Expression Treeها قابل حل است).

 

استراتژی‌های تست واحد برای اینترفیس‌ها (Interfaces)

همانطور که گفته شد، خود اینترفیس کدی برای تست ندارد. با این حال، دو سناریو درباره اینترفیس‌ها وجود دارد:

سناریو اول: تست کلاسی که به یک اینترفیس وابسته است (مستندسازی تعاملات)

وقتی کلاسی تحت تست (System Under Test - SUT) وابستگی‌هایی به صورت اینترفیس دارد، ما آن اینترفیس‌ها را Mock می‌کنیم تا تمرکز تست صرفاً روی رفتار خود کلاس باشد. این رایج‌ترین مدل تست واحد است.

public interface INotificationService
{
    Task<bool> SendEmailAsync(string to, string body);
}

// کلاس اصلی که می‌خواهیم تست کنیم
public class OrderProcessor
{
    private readonly INotificationService _notificationService;

    public OrderProcessor(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public async Task<bool> CompleteOrderAsync(Guid orderId, string customerEmail)
    {
        // لایژیک پردازش سفارش...
        
        // ارسال نوتیفیکیشن
        return await _notificationService.SendEmailAsync(customerEmail, $"Order {orderId} processed.");
    }
}

تست واحد این کلاس با ابزار NSubstitute (یک جایگزین مدرن و خوش‌دست برای Moq) به شکل زیر خواهد بود:

using NSubstitute;
using Xunit;

public class OrderProcessorTests
{
    [Fact]
    public async Task CompleteOrderAsync_OnSuccess_SendsEmailNotification()
    {
        // Arrange
        var mockNotification = Substitute.For<INotificationService>();
        
        // تنظیم پیش‌فرض برای متد اینترفیس
        mockNotification.SendEmailAsync(Arg.Any<string>(), Arg.Any<string>())
                        .Returns(Task.FromResult(true));

        var processor = new OrderProcessor(mockNotification);
        var orderId = Guid.NewGuid();

        // Act
        var result = await processor.CompleteOrderAsync(orderId, "user@example.com");

        // Assert
        Assert.True(result);
        
        // بررسی اینکه آیا متد اینترفیس دقیقا با پارامترهای درست صدا زده شده است یا خیر (Behavior Verification)
        await mockNotification.Received(1)
            .SendEmailAsync("user@example.com", $"Order {orderId} processed.");
    }
}

سناریو دوم: تست الگوهای پیاده‌سازی متدهای پیش‌فرض اینترفیس (Default Interface Methods)

از سی‌شارپ ۸ به بعد، ما می‌توانیم برای متدهای درون اینترفیس، پیاده‌سازی پیش‌فرض بنویسیم. این قابلیت چالش جدیدی برای تست واحد ایجاد کرده است. برای تست این متدها، باید کلاینت تست را مجبور کنید که شیء را ابتدا به خودِ اینترفیس Cast کند:

public interface ILogger
{
    void Log(string message);
    
    // متد با پیاده‌سازی پیش‌فرض
    void LogError(string message) => Log($"[ERROR] {message}");
}

// برای تست واحد لایژیک LogError:
public class DefaultInterfaceMethodTests
{
    [Fact]
    public void LogError_CallsBaseLogWithCorrectFormat()
    {
        // Arrange
        var mock = new Mock<ILogger>();
        mock.Setup(x => x.Log(It.IsAny<string>()));
        
        // Cast کردن به اینترفیس برای دسترسی به متد پیش‌فرض
        ILogger logger = mock.Object;

        // Act
        logger.LogError("Connection failed");

        // Assert
        mock.Verify(x => x.Log("[ERROR] Connection failed"), Times.Once);
    }
}

 

الگوی معماری Contract Test (تست قرارداد) برای تضمین رفتار تمامی پیاده‌سازی‌ها

یکی از پیشرفته‌ترین و حرفه‌ای‌ترین الگوها در تست واحد، الگوی Contract Test است. فرض کنید یک اینترفیس به نام ICacheRepository دارید و چندین کلاس مختلف (RedisCache, MemoryCache, SqlCache) این اینترفیس را پیاده‌سازی کرده‌اند.

طبق اصل جانشینی لیسکوف (Liskov Substitution Principle)، تمام این کلاس‌ها باید رفتار رفتاری یکسانی از خود نشان دهند (مثلاً اگر کلیدی وجود نداشت، همگی باید تِرو کردن یک اکسپشن یا برگشت مقدار null را یکسان انجام دهند).

برای اینکه مجبور نباشید برای هر سه کلاس تست‌های تکراری بنویسید، یک کلاس تست انتزاعی (Abstract Test Class) طراحی می‌کنیم:

// کلاس پایه برای تست‌ها به صورت انتزاعی
public abstract class CacheRepositoryContractTests
{
    // فکتوری متد برای ساخت نمونه دیتابیس؛ هر فرزند پیاده‌سازی خود را معرفی می‌کند
    protected abstract ICacheRepository CreateRepository();

    [Fact]
    public async Task GetAsync_KeyDoesNotExist_ReturnsNull()
    {
        // Arrange
        ICacheRepository repository = CreateRepository();
        string nonExistingKey = Guid.NewGuid().ToString();

        // Act
        var result = await repository.GetAsync(nonExistingKey);

        // Assert
        Assert.Null(result);
    }

    [Fact]
    public async Task SetAsync_ValidKeyValue_StoresDataCorrectly()
    {
        // Arrange
        ICacheRepository repository = CreateRepository();
        string key = "test_key";
        string value = "data";

        // Act
        await repository.SetAsync(key, value);
        var result = await repository.GetAsync(key);

        // Assert
        Assert.Equal(value, result);
    }
}

حالا برای تست هر کدام از پیاده‌سازی‌های واقعی، کافیست کلاسی بسازید که از این لایه تست ارث‌بری کند:

// تست اختصاصی برای حافظه موقت رم
public class MemoryCacheRepositoryTests : CacheRepositoryContractTests
{
    protected override ICacheRepository CreateRepository()
    {
        return new MemoryCacheRepository(); // شیء ملموس کاملا واقعی
    }
}

// تست اختصاصی برای ردیس کش
public class RedisCacheRepositoryTests : CacheRepositoryContractTests
{
    protected override ICacheRepository CreateRepository()
    {
        // در محیط تست واقعی معمولا از Testcontainers استفاده می‌شود
        return new RedisCacheRepository("localhost:6379"); 
    }
}

چرا این الگو شاهکار مهندسی نرم‌افزار است؟ چون اگر فردا روزی عضو جدیدی به تیم اضافه شود و بخواهد دیتابیس چهارمی (مثلاً MongoCacheRepository) بسازد، کافیست یک کلاس تست برای آن بسازد و از CacheRepositoryContractTests ارث‌بری کند. تمام تست‌های رفتاری به صورت خودکار برای کلاس جدید اجرا خواهند شد و تضمین می‌شود که رفتاری مغایر با استانداردهای سیستم پدید نخواهد آمد.

 

چک‌لیست و اشتباهات رایج (Anti-Patterns) در تست اینترفیس‌ها و کلاس‌های انتزاعی

به عنوان یک توسعه‌دهنده ارشد، در طول کد ریویوها (Code Reviews) باید مراقب باشید که تیم به دام اشتباهات زیر نیفتد:

  1. ماک کردن بیش از حد (Over-Mocking) کلاس انتزاعی تحت تست: اگر می‌خواهید متد $A$ را در یک کلاس انتزاعی تست کنید، نباید بقیه متدهای همان کلاس را به حدی ماک کنید که عملاً خود کد اصلی اجرا نشود. فقط متدهای انتزاعی (Abstract) یا خارجی را ماک کنید.

  2. فراموش کردن CallBase = true: در فریم‌ورک Moq، اگر مایل به تست کدهای واقعی یک کلاس انتزاعی هستید و فرآیند ماک را فعال کرده‌اید، بدون ست کردن این فلگ، متدهای ملموس شما هیچ خروجی نخواهند داشت یا مقدار دیفالت تایپ را برمی‌گردانند.

  3. تست جزئیات داخلی به جای رفتار عمومی (Testing Implementation Details): در کلاس‌های انتزاعی، تمرکز خود را روی خروجی متدهای public بگذارید. تست کردن تک تک متدهای private یا protected داخلی، تست‌های شما را شکننده (Brittle) می‌کند؛ به طوری که با کوچک‌ترین ریفکتور کد، تست‌ها قرمز می‌شوند بدون اینکه باگی رخ داده باشد.

 

نتیجه‌گیری

تست واحد برای ساختارهای ابستره (Abstract) و اینترفیس‌ها بر خلاف ظاهر مبهمی که در ابتدا دارد، مظهر زیبایی و قدرت مهندسی ساختاریافته است. با استفاده از تکنیک Concrete Fakes برای کلاس‌های انتزاعی ساده، پلتفرم‌های ماکینگ مانند Moq/NSubstitute برای سناریوهای داینامیک، و به کارگیری شاهکار معماری Contract Testing برای تضمین یکپارچگی رفتار اینترفیس‌ها، می‌توانید ریسک بروز خطاهای ران‌تایم را در سیستم‌های بزرگ دات‌نتی به صفر نزدیک کنید. یک کد مجهز به تست‌های انتزاعی درست، کدی است که با اطمینان کامل رشد می‌کند و هرگز از تغییر نمی‌هراسد.

 
لینک استاندارد شده: dE1

0 نظر

    هنوز نظری برای این مقاله ثبت نشده است.
جستجوی مقاله و آموزش
دوره‌ها با تخفیفات ویژه