تست واحد (Unit Test) با xUnit
یک Unit Test یک تکه کوچک از منطق را جدا از دیتابیس و شبکه بررسی میکند. تست خوب سریع، مستقل و قابل اعتماد است و رفتار را بررسی میکند، نه جزئیات پیادهسازی را. وابستگیهای کند یا بیرونی را با Fake، Stub یا Mock جایگزین میکنیم.
نویسنده: bezzad
مشکل: هر تغییر، ترس از خراب شدن
در فروشگاه اینترنتی ما یک قانون تخفیف داریم:
- مشتری ویژه (VIP) ده درصد تخفیف میگیرد.
- روزهای جمعه همه پنج درصد تخفیف اضافه میگیرند.
- مبلغ منفی یا صفر قبول نیست.
حالا تیم فروش یک قانون جدید میخواهد. یک برنامهنویس کد را تغییر میدهد. سؤال این است: از کجا بدانیم قانونهای قبلی خراب نشدهاند؟
راه دستی این است که برنامه را اجرا کنیم، وارد شویم، کالا بخریم و عدد را نگاه کنیم. این کار کند است. هر بار هم ممکن است یک حالت را فراموش کنیم.
راه بهتر این است که هر قانون را یک بار در کد بنویسیم و هر بار در چند میلیثانیه چک کنیم. به این کد Unit Test میگوییم.
یک Unit Test دقیقاً چیست؟
تست واحد یک تکه کوچک از منطق را جدا از بقیه سیستم اجرا میکند. دیتابیس، شبکه، فایل و ساعت سیستم داخل آن نیستند. برای همین:
- خیلی سریع است. هزاران تست در چند ثانیه اجرا میشوند.
- جای خطا را دقیق نشان میدهد. اگر تست شکست خورد، میدانیم مشکل در همان یک کلاس است.
- همیشه نتیجه یکسان دارد. چون به چیزی بیرون از کد وابسته نیست.
این تستها پایه هرم تست هستند. بیشترین تعداد تستها باید از این نوع باشند. چند تست Integration و تعداد کمی تست انتها به انتها (E2E) بالای آنها میآیند.
ساختار هر تست: آماده کردن، اجرا، بررسی
همه تستهای خوب یک شکل دارند. به این شکل Arrange-Act-Assert میگوییم:
- آماده کردن (Arrange). داده و اشیای لازم را میسازیم.
- اجرا (Act). فقط یک کار را صدا میزنیم. همان رفتاری که تست میکنیم.
- بررسی (Assert). نتیجه را با مقدار مورد انتظار مقایسه میکنیم.
کد: تست قانون تخفیف
اول خود کد را ببینیم. ساعت را از بیرون میگیریم. کلاس TimeProvider در .NET برای همین کار ساخته شده است. اگر کد مستقیم از DateTime.Now استفاده کند، نمیتوانیم «جمعه» را در تست بسازیم.
public sealed record Customer(bool IsVip);
public sealed class DiscountCalculator(TimeProvider clock)
{
public decimal Apply(decimal total, Customer customer)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total);
var percent = customer.IsVip ? 10 : 0;
if (clock.GetUtcNow().DayOfWeek == DayOfWeek.Friday)
percent += 5;
return total - total * percent / 100;
}
}
حالا تستها با xUnit. ویژگی Fact یعنی «یک تست». ویژگی Theory یعنی «یک تست با چند ورودی». کلاس FakeTimeProvider از بسته Microsoft.Extensions.TimeProvider.Testing میآید و یک ساعت ثابت به ما میدهد.
public class DiscountCalculatorTests
{
// 2026-10-05 is a Monday, 2026-10-09 is a Friday.
private static readonly DateTimeOffset Monday = new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset Friday = new(2026, 10, 9, 12, 0, 0, TimeSpan.Zero);
[Theory]
[InlineData(false, 1000)]
[InlineData(true, 900)]
public void Monday_discount_depends_only_on_vip(bool isVip, int expected)
{
// Arrange
var calc = new DiscountCalculator(new FakeTimeProvider(Monday));
// Act
var result = calc.Apply(1000m, new Customer(isVip));
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void Vip_on_friday_gets_both_discounts()
{
var calc = new DiscountCalculator(new FakeTimeProvider(Friday));
var result = calc.Apply(1000m, new Customer(IsVip: true));
Assert.Equal(850m, result);
}
[Fact]
public void Zero_total_is_rejected()
{
var calc = new DiscountCalculator(new FakeTimeProvider(Monday));
Assert.Throws<ArgumentOutOfRangeException>(
() => calc.Apply(0m, new Customer(IsVip: false)));
}
}
به اسم تستها دقت کن. اسم هر تست یک جمله درباره رفتار است. اگر تست شکست بخورد، از همان اسم میفهمیم کدام قانون خراب شده است.
تست خوب چه ویژگیهایی دارد؟
- سریع است. اگر تستها کند باشند، کسی آنها را اجرا نمیکند.
- مستقل است. ترتیب اجرا مهم نیست. هیچ تستی به داده تست دیگر وابسته نیست.
- تکرارپذیر است. امروز و فردا، روی لپتاپ و روی سرور، یک نتیجه دارد. پس ساعت واقعی، عدد تصادفی و شبکه داخل آن نیست.
- خودش نتیجه را اعلام میکند. یا سبز است یا قرمز. لازم نیست کسی خروجی را با چشم بخواند.
- رفتار را تست میکند، نه پیادهسازی را. اگر کد را بازنویسی کنیم و رفتار عوض نشود، تست نباید بشکند.
وابستگیها: Stub، Fake و Mock
کلاس OrderService در فروشگاه ما سه وابستگی دارد: قیمت کالا را از کاتالوگ میگیرد، سفارش را ذخیره میکند و ایمیل تأیید میفرستد. در تست واحد هیچ کدام از اینها را واقعی نمیخواهیم. به جای آنها یک جایگزین تست (Test Double) میگذاریم.
| نوع | چه کار میکند؟ | کی مناسب است؟ |
|---|---|---|
| Stub | فقط یک جواب ثابت برمیگرداند. | وقتی کد ما از وابستگی داده میخواند. |
| Fake | یک نسخه ساده ولی کارکن است. مثلاً یک انبار سفارش داخل حافظه. | وقتی وضعیت مهم است و میخواهیم بعداً آن را بخوانیم. |
| Mock | ثبت میکند چه متدی با چه ورودی صدا زده شد. بعداً آن را چک میکنیم. | وقتی خود صدا زدن نتیجه کار است. مثل فرستادن ایمیل. |
یک Fake دستساز معمولاً چند خط بیشتر نیست:
public sealed class InMemoryOrders : IOrderRepository
{
public List<Order> Saved { get; } = [];
public Task AddAsync(Order order, CancellationToken ct)
{
Saved.Add(order);
return Task.CompletedTask;
}
}
برای Mock معمولاً از یک کتابخانه مثل NSubstitute یا Moq استفاده میکنیم. این تست چک میکند که بعد از ثبت سفارش، دقیقاً یک ایمیل فرستاده شود:
[Fact]
public async Task Placing_an_order_saves_it_and_sends_one_email()
{
var ct = TestContext.Current.CancellationToken;
var catalog = Substitute.For<IPriceCatalog>();
catalog.GetPrice(7).Returns(500m); // stub
var orders = new InMemoryOrders(); // fake
var email = Substitute.For<IEmailSender>(); // mock
var service = new OrderService(catalog, orders, email);
await service.PlaceAsync(customerEmail: "ali@shop.test", productId: 7, ct);
Assert.Single(orders.Saved);
Assert.Equal(500m, orders.Saved[0].Total);
await email.Received(1).SendAsync("ali@shop.test", Arg.Any<string>(), Arg.Any<CancellationToken>());
}
قانونهای مهم
- فقط مرزهای سیستم را جایگزین کن. دیتابیس، شبکه، ایمیل، ساعت و عدد تصادفی. کلاسهای ساده داخلی خودت را واقعی استفاده کن.
- متد خصوصی را مستقیم تست نکن. آن را از راه متد عمومی تست کن. اگر یک متد خصوصی آنقدر مهم است که تست جدا میخواهد، احتمالاً باید یک کلاس جدا باشد.
- در هر تست یک رفتار. چند Assert اشکالی ندارد، به شرطی که همه درباره همان یک رفتار باشند.
- پوشش کد (Code Coverage) هدف نیست. پوشش صد درصد فقط میگوید هر خط یک بار اجرا شده است. نمیگوید نتیجه درست چک شده است. از آن برای پیدا کردن بخشهای بیتست استفاده کن، نه برای نمره دادن.
- حالتهای مرزی را فراموش نکن. صفر، منفی، لیست خالی، مقدار حداکثر و روز آخر ماه. بیشتر باگها همینجا هستند.
- تست هم کد است. آن را تمیز نگه دار. ولی در تست، خوانایی از حذف تکرار مهمتر است.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| استفاده مستقیم از DateTime.Now در کد | تست فقط بعضی روزها سبز است. | ساعت را با TimeProvider از بیرون بگیر. |
| Mock کردن همه چیز، حتی کلاسهای ساده | تست با هر بازنویسی میشکند. | فقط مرزها را جایگزین کن. |
| چک کردن ترتیب صدا زدن متدهای داخلی | تست پیادهسازی را قفل میکند. | نتیجه و وضعیت نهایی را چک کن. |
| داده مشترک بین تستها (فیلد استاتیک) | تستها به ترتیب اجرا وابسته میشوند. | هر تست داده خودش را بسازد. |
| تست بدون Assert | همیشه سبز است، حتی اگر کد غلط باشد. | هر تست باید بتواند قرمز شود. |
| اسمهایی مثل Test1 | وقتی شکست خورد، کسی نمیفهمد چه چیزی خراب است. | اسم تست یک جمله درباره رفتار باشد. |
چه چیزی را با Unit Test بسنجیم؟
مناسب
- قانونهای کسبوکار، مثل تخفیف، مالیات و هزینه ارسال.
- محاسبهها و تبدیلها.
- تصمیمگیریهایی که شرط زیاد دارند.
- منطق داخل Aggregate و Value Object.
نامناسب
- کوئریهای دیتابیس. برای آنها تست Integration بنویس.
- تنظیمات DI و Middleware در ASP.NET Core.
- کدی که فقط داده را از یک جا به جای دیگر میبرد و منطقی ندارد.
- خود کتابخانههای دیگران.
خلاصه در شش خط
- تست واحد یک رفتار کوچک را جدا از دیتابیس و شبکه بررسی میکند.
- بیشتر تستهای پروژه باید از این نوع باشند، چون سریع و دقیق هستند.
- هر تست سه بخش دارد: آماده کردن، اجرا و بررسی.
- تست خوب سریع، مستقل و تکرارپذیر است و رفتار را تست میکند، نه پیادهسازی را.
- برای خواندن داده Stub، برای وضعیت Fake و برای چک کردن یک پیام بیرونی Mock.
- ساعت و چیزهای تصادفی را از بیرون بگیر تا تست همیشه یک نتیجه داشته باشد.