مقالات › C#
آموزش تست واحد در .NET با xUnit و Moq
تستهایی بنویسید که باگ واقعی پیدا میکنند: Fact و Theory در xUnit، الگوی Arrange-Act-Assert، Mock با Moq، تست کد async، تست یکپارچه با WebApplicationFactory و طراحی تست خوب.
تستها تور ایمنیای هستند که اجازه میدهند بدون ترس کد را تغییر دهید. یک مجموعهی تست خوب باگهای برگشتی را قبل از کاربران پیدا میکند، رفتار مورد انتظار کد را مستند میکند و Refactor را سریع میکند. این راهنما نشان میدهد چطور در داتنت با xUnit و Moq تست مؤثر بنویسید؛ از اولین تست تا تست یکپارچهی کل یک API در ASP.NET Core.
انواع تست
| نوع | محدوده | سرعت | مثال |
|---|---|---|---|
| واحد (Unit) | یک کلاس/متد با وابستگیهای جایگزینشده | میلیثانیه | محاسبهی تخفیف |
| یکپارچه (Integration) | چند جزء واقعی با هم | ثانیه | Endpoint + دیتابیس |
| سرتاسری (E2E) | کل سیستم از طریق رابط کاربری | کند | تست مرورگر برای فرایند خرید |
توصیهی رایج یک «هرم» است: تعداد زیادی تست واحد سریع، تعداد کمتری تست یکپارچه و چند تست سرتاسری.
راهاندازی
dotnet new xunit -n Shop.Tests
dotnet add Shop.Tests reference src/Shop.Application
dotnet add Shop.Tests package Moq
dotnet test
اولین تست: Arrange، Act، Assert
public class PriceCalculator
{
public decimal Total(decimal price, int quantity, decimal discountPercent)
{
ArgumentOutOfRangeException.ThrowIfNegative(price);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
var gross = price * quantity;
return Math.Round(gross - gross * discountPercent / 100, 2);
}
}
public class PriceCalculatorTests
{
[Fact]
public void Total_applies_discount_to_gross_amount()
{
// Arrange
var calculator = new PriceCalculator();
// Act
var total = calculator.Total(price: 200, quantity: 3, discountPercent: 10);
// Assert
Assert.Equal(540m, total);
}
}
ساختار AAA تستها را خوانا نگه میدارد: آمادهسازی، انجام یک عمل و بررسی نتیجه.
Theory: یک تست، ورودیهای متعدد
[Theory]
[InlineData(100, 1, 0, 100)]
[InlineData(100, 2, 50, 100)]
[InlineData(19.99, 3, 0, 59.97)]
public void Total_calculates_correctly(decimal price, int qty, decimal discount, decimal expected)
{
var total = new PriceCalculator().Total(price, qty, discount);
Assert.Equal(expected, total);
}
[Fact]
public void Total_rejects_zero_quantity()
{
var calculator = new PriceCalculator();
Assert.Throws<ArgumentOutOfRangeException>(() => calculator.Total(10, 0, 0));
}
برای ورودیهای پیچیده از [MemberData] یا [ClassData] استفاده کنید.
نامگذاری و ساختار
نام تست خوب میگوید چه چیزی تست میشود، در چه شرایطی و چه انتظاری داریم:
PlaceOrder_with_empty_cart_returns_failureGetBySlug_when_article_is_unpublished_returns_null
یک Assertion منطقی در هر تست، تشخیص علت شکست را آسان میکند.
Mock کردن وابستگیها با Moq
تست واحد وابستگیها (دیتابیس، ایمیل، درگاه پرداخت) را با Test Double جایگزین میکند تا تست سریع و قطعی باشد:
public class OrderService(IOrderRepository orders, IPaymentGateway payments, IEmailSender email)
{
public async Task<Result> PlaceAsync(Order order, CancellationToken ct)
{
var payment = await payments.ChargeAsync(order.Total, ct);
if (!payment.Succeeded) return Result.Failure("Payment declined.");
orders.Add(order);
await orders.SaveAsync(ct);
await email.SendAsync(order.CustomerEmail, "Order confirmed", ct);
return Result.Success();
}
}
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _orders = new();
private readonly Mock<IPaymentGateway> _payments = new();
private readonly Mock<IEmailSender> _email = new();
private OrderService CreateSut() => new(_orders.Object, _payments.Object, _email.Object);
[Fact]
public async Task PlaceAsync_declined_payment_does_not_save_or_email()
{
_payments.Setup(p => p.ChargeAsync(It.IsAny<decimal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(PaymentResult.Declined());
var result = await CreateSut().PlaceAsync(new Order { Total = 100 }, default);
Assert.False(result.IsSuccess);
_orders.Verify(o => o.Add(It.IsAny<Order>()), Times.Never);
_email.Verify(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task PlaceAsync_successful_payment_saves_and_sends_confirmation()
{
_payments.Setup(p => p.ChargeAsync(250, It.IsAny<CancellationToken>()))
.ReturnsAsync(PaymentResult.Success("tx-1"));
var order = new Order { Total = 250, CustomerEmail = "sara@example.com" };
var result = await CreateSut().PlaceAsync(order, default);
Assert.True(result.IsSuccess);
_orders.Verify(o => o.SaveAsync(It.IsAny<CancellationToken>()), Times.Once);
_email.Verify(e => e.SendAsync("sara@example.com", "Order confirmed", It.IsAny<CancellationToken>()), Times.Once);
}
}
قابلیتهای کلیدی Moq:
Setup(...).Returns/ReturnsAsync/Throws— تعریف رفتار.It.IsAny<T>()وIt.Is<T>(predicate)— تطبیق آرگومانها.Verify(..., Times.Once)— بررسی اینکه یک تعامل انجام شده است.Callback(...)— گرفتن آرگومانها برای بررسی دقیقتر.
نکته: چیزهایی را که مالکشان نیستید از طریق اینترفیسهای خودتان Mock کنید (درگاه پرداخت، ساعت، ایمیل). همهچیز را Mock نکنید: Value Objectهای ساده و منطق خالص را واقعی استفاده کنید.
Test Double بدون کتابخانه
برای وابستگیهای ساده، یک Fake دستنویس اغلب از Mock شفافتر است:
public class FakeClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
TimeProvider در .NET 8 (و FakeTimeProvider در پکیج Microsoft.Extensions.TimeProvider.Testing) کدهای وابسته به زمان را بدون فراخوانی استاتیک DateTime.Now قابل تست میکند.
تست کد async
xUnit بهصورت بومی از تستهای async Task پشتیبانی میکند:
[Fact]
public async Task GetAsync_unknown_id_throws()
{
var sut = new ArticleService(new InMemoryArticleStore());
await Assert.ThrowsAsync<NotFoundException>(() => sut.GetAsync("missing", default));
}
هرگز در تست از async void استفاده نکنید؛ شکستها گم میشوند.
Setup، Teardown و Fixtureهای مشترک
- سازنده قبل از هر تست اجرا میشود و
IDisposable.Disposeبعد از آن؛ xUnit برای هر تست یک نمونهی جدید میسازد، پس تستها ایزولهاند. IClassFixture<T>یک شیء پرهزینه (مثلاً کانتینر دیتابیس) را بین تستهای یک کلاس به اشتراک میگذارد.ICollectionFixture<T>آن را بین چند کلاس به اشتراک میگذارد.
تست یکپارچه برای ASP.NET Core
WebApplicationFactory برنامهی واقعی شما را داخل حافظه اجرا میکند و یک HttpClient در اختیارتان میگذارد:
dotnet add package Microsoft.AspNetCore.Mvc.Testing
public class ArticlesApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Get_unknown_article_returns_404()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/articles/does-not-exist");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
سرویسها را برای تست با WithWebHostBuilder(b => b.ConfigureServices(...)) جایگزین کنید؛ مثلاً DbContext را به یک دیتابیس تست متصل کنید. برای رفتار واقعی دیتابیس، بهجای Provider درونحافظهای EF Core از یک SQL Server واقعی در Docker به کمک Testcontainers استفاده کنید.
(اگر پروژهی تست کلاس Program را نمیبیند، public partial class Program { } را انتهای Program.cs اضافه کنید.)
یک تست خوب چه ویژگیهایی دارد
- سریع — کل تستهای واحد در چند ثانیه اجرا میشوند تا توسعهدهندهها مدام اجرایشان کنند.
- قطعی — بدون وابستگی به زمان، عدد تصادفی، شبکه یا ترتیب تستها.
- مستقل — هر تست دادهی خودش را آماده میکند.
- خوانا — تست شکستخورده باید بدون دیباگ مشکل را توضیح دهد.
- رفتار را تست کند، نه پیادهسازی را — نتیجه را بررسی کنید، نه جزئیات خصوصی را، تا Refactor تستها را نشکند.
اشتباهات رایج
- تست کردن Getter/Setterهای بدیهی بهجای قوانین بیزینس.
- Mock بیش از حد — تستهایی که خطبهخط پیادهسازی را تکرار میکنند و با هر Refactor میشکنند.
- State مشترک و تغییرپذیر بین تستها.
- نادیده گرفتن تستهای ناپایدار (Flaky) بهجای درست کردنشان.
- دنبال کردن پوشش ۱۰۰ درصد بهجای پوشش رفتارهای مهم و حالتهای مرزی.
جمعبندی
از Fact و Theory در xUnit با الگوی Arrange-Act-Assert استفاده کنید، وابستگیهای بیرونی را با Moq یا Fakeهای ساده جایگزین کنید، کد async را با async Task تست کنید و برای مسیرهای حیاتی API تست یکپارچه با WebApplicationFactory اضافه کنید. تستها را سریع، قطعی و متمرکز بر رفتار نگه دارید تا هر روز با اطمینان کد را تغییر دهید.
پرسشهای متداول
xUnit، NUnit یا MSTest؟
هر سه بالغاند. xUnit در داتنت مدرن رایجترین است و خود تیم داتنت از آن استفاده میکند؛ یکی را انتخاب و یکدست بمانید.
چه درصدی از پوشش کد هدف باشد؟
پوشش یک نشانه است، نه هدف. قوانین بیزینس و مسیرهای پرخطر را کامل پوشش دهید؛ ۷۰ تا ۸۰ درصد در کل یک بازهی سالم رایج است.
آیا برای تست از Provider درونحافظهای EF Core استفاده کنم؟
برای حالتهای خیلی ساده اشکالی ندارد، اما رفتارش مثل دیتابیس رابطهای نیست (بدون Constraint و با ترجمهی متفاوت کوئری). SQLite درونحافظهای یا یک کانتینر دیتابیس واقعی را ترجیح دهید.