مقدمه
در دنیای توسعه نرمافزار، بهویژه در چارچوب Microsoft .NET، تستنویسی (Unit Testing) یکی از ارکان اصلی تضمین کیفیت کد است. تستهای واحد به توسعهدهندگان کمک میکنند تا رفتار کد را در شرایط مشخص بررسی کرده و از صحت عملکرد آن اطمینان حاصل کنند. با این حال، استفاده نادرست از برخی الگوها میتواند به تستهایی شکننده، غیرقابل اعتماد و دشوار در نگهداری منجر شود. یکی از این الگوهای نادرست یا ضدالگوها (Anti-Patterns)، نحوه کار با زمان در تستنویسی است. وابستگی مستقیم به زمان فعلی سیستم، مانند استفاده از DateTime.Now، میتواند نتایجی غیرقابل پیشبینی به همراه داشته باشد، زیرا زمان در هر اجرا متغیر است. این مقاله به بررسی این ضدالگو، دلایل مشکلساز بودن آن و راهحلهای اصولی برای مدیریت زمان در تستنویسی میپردازد. هدف این است که توسعهدهندگان داتنت بتوانند تستهایی پایدار، قابل تکرار و منسجم بنویسند.
چرا کار با زمان یک ضدالگو محسوب میشود؟
بسیاری از برنامهها به زمان وابسته هستند، مثلاً برای محاسبه تاریخ انقضا، زمانبندی وظایف یا ثبت رویدادها. اما در تستنویسی، وابستگی مستقیم به زمان فعلی سیستم چالشهایی ایجاد میکند. فرض کنید کدی دارید که بررسی میکند آیا یک تاریخ خاص از زمان فعلی گذشته است یا خیر. اگر از DateTime.Now استفاده کنید، نتیجه تست به لحظه اجرای آن بستگی دارد. این یعنی تست شما ممکن است در یک روز موفق و در روز دیگر ناموفق باشد، بدون اینکه تغییری در منطق کد ایجاد شده باشد. این ناپایداری، تست را غیرقابل اعتماد میکند و اصل کلیدی تستنویسی، یعنی تکرارپذیری (Repeatability)، را نقض میکند.
به عنوان مثال، کدی را در نظر بگیرید که تخفیف یک محصول را بر اساس تاریخ انقضا محاسبه میکند:
public class DiscountCalculator
{
public decimal CalculateDiscount(DateTime expiryDate)
{
return expiryDate > DateTime.Now ? 0.1m : 0m;
}
}حالا تستی برای این کد بنویسید:
[Fact]
public void CalculateDiscount_WhenExpiryDateIsFuture_ReturnsDiscount()
{
var calculator = new DiscountCalculator();
var expiryDate = new DateTime(2025, 12, 31);
var discount = calculator.CalculateDiscount(expiryDate);
Assert.Equal(0.1m, discount);
}این تست امروز (مارس 2025) موفق است، زیرا DateTime.Now کمتر از تاریخ انقضا (دسامبر 2025) است. اما اگر همین تست در ژانویه 2026 اجرا شود، شکست میخورد، چون زمان فعلی از تاریخ انقضا گذشته است. این شکنندگی نشاندهنده یک ضدالگوست.
روشهای نادرست مدیریت زمان
یکی از رویکردهای رایج اما نادرست، استفاده از متدهای استاتیک مانند DateTime.Now یا ترفندهایی مثل متوقف کردن اجرای کد با Thread.Sleep برای شبیهسازی گذر زمان است. این روشها نهتنها تست را وابسته به وضعیت سیستم میکنند، بلکه گاهی زمان اجرای تست را بیدلیل طولانی میکنند. به عنوان مثال:
[Fact]
public void CalculateDiscount_AfterDelay_ReturnsNoDiscount()
{
var calculator = new DiscountCalculator();
var expiryDate = DateTime.Now.AddSeconds(1);
Thread.Sleep(2000); // صبر برای گذر زمان
var discount = calculator.CalculateDiscount(expiryDate);
Assert.Equal(0m, discount);
}این تست به زمانبندی سیستم وابسته است و اگر اجرا به هر دلیلی کند شود، ممکن است نتیجه متفاوتی بدهد. همچنین، استفاده از Thread.Sleep کارایی تست را کاهش میدهد و آن را غیرحرفهای جلوه میدهد.
راهحلهای اصولی
برای رفع این مشکل، باید زمان را بهعنوان یک وابستگی صریح (Explicit Dependency) در کد مدیریت کنیم. دو روش اصلی برای این کار وجود دارد:
- تزریق وابستگیها (Dependency Injection): زمان را از طریق یک رابط (Interface) به کد تزریق کنید. این کار کنترل زمان را به عهده تست میگذارد و وابستگی به سیستم را حذف میکند.
- استفاده از FakeTimeProvider در داتنت 8: این ابزار جدید، امکان شبیهسازی زمان را به شکلی ساده و کارآمد فراهم میکند.
روش اول: تزریق وابستگی
ابتدا یک رابط برای دسترسی به زمان تعریف کنید:
public interface ITimeProvider
{
DateTime GetCurrentTime();
}سپس پیادهسازی واقعی آن را ایجاد کنید:
public class SystemTimeProvider : ITimeProvider
{
public DateTime GetCurrentTime() => DateTime.Now;
}حالا کد اصلی را به این شکل بازنویسی کنید:
public class DiscountCalculator
{
private readonly ITimeProvider _timeProvider;
public DiscountCalculator(ITimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public decimal CalculateDiscount(DateTime expiryDate)
{
return expiryDate > _timeProvider.GetCurrentTime() ? 0.1m : 0m;
}
}در تست، میتوانید یک نسخه جعلی (Mock) از ITimeProvider ایجاد کنید:
[Fact]
public void CalculateDiscount_WhenExpiryDateIsFuture_ReturnsDiscount()
{
var timeProvider = new Mock<ITimeProvider>();
timeProvider.Setup(tp => tp.GetCurrentTime()).Returns(new DateTime(2025, 1, 1));
var calculator = new DiscountCalculator(timeProvider.Object);
var expiryDate = new DateTime(2025, 12, 31);
var discount = calculator.CalculateDiscount(expiryDate);
Assert.Equal(0.1m, discount);
}این تست همیشه نتیجه یکسانی دارد، زیرا زمان دیگر متغیر نیست.
روش دوم: استفاده از FakeTimeProvider
در داتنت 8، ابزار FakeTimeProvider به فضای نام System.Threading اضافه شده است. این ابزار برای شبیهسازی زمان در تستها طراحی شده است. مثال:
[Fact]
public void CalculateDiscount_WithFakeTimeProvider_ReturnsDiscount()
{
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(new DateTime(2025, 1, 1));
var calculator = new DiscountCalculator(fakeTime);
var expiryDate = new DateTime(2025, 12, 31);
var discount = calculator.CalculateDiscount(expiryDate);
Assert.Equal(0.1m, discount);
}این روش نیازی به تعریف رابط جداگانه ندارد و مستقیماً زمان را کنترل میکند.
مزایای رویکردهای پیشنهادی
هر دو روش تضمین میکنند که تستها:
- تکرارپذیر باشند: نتیجه تست به زمان واقعی وابسته نیست.
- سریع اجرا شوند: نیازی به تأخیرهای مصنوعی نیست.
- منعطف باشند: میتوانید سناریوهای مختلف زمانی را بهراحتی شبیهسازی کنید.
نتیجهگیری
کار با زمان در تستنویسی، اگر به شکل نادرست انجام شود، میتواند به یک ضدالگو تبدیل شود و کیفیت تستها را کاهش دهد. وابستگی به متدهای استاتیک مثل DateTime.Now یا استفاده از ترفندهای غیرحرفهای مانند Thread.Sleep، تستها را شکننده و غیرقابل اعتماد میکند. در مقابل، با استفاده از تکنیکهایی مانند تزریق وابستگی یا ابزارهای مدرن مثل FakeTimeProvider در داتنت 8، میتوان زمان را به شکلی کنترلشده مدیریت کرد. این رویکردها نهتنها پایداری تستها را افزایش میدهند، بلکه به توسعهدهندگان داتنت اجازه میدهند کدی تمیزتر و قابل نگهداریتر بنویسند. توصیه میشود در پروژههای خود، زمان را بهعنوان یک وابستگی صریح در نظر بگیرید و از ابزارهای مناسب برای تست آن بهره ببرید تا از مشکلات رایج این ضدالگو در امان بمانید.