Unit Testing with xUnit
Write effective unit tests with xUnit and assertion libraries.
By EMEPublished: February 20, 2025
xunitunit testingtddmockingassertions
A Simple Analogy
Unit tests are like quality checks on an assembly line. Each part is tested individually before assembly, catching defects early and cheaply.
Why Unit Testing?
- Early detection: Find bugs before integration
- Confidence: Refactor with assurance
- Documentation: Tests show expected behavior
- Regression prevention: Prevent breaking changes
- Design improvement: Tests guide architecture
xUnit Test Structure
public class OrderServiceTests
{
private readonly OrderService _orderService;
private readonly MockOrderRepository _mockRepository;
public OrderServiceTests()
{
_mockRepository = new MockOrderRepository();
_orderService = new OrderService(_mockRepository);
}
[Fact]
public async Task CreateOrder_WithValidRequest_ReturnsOrderId()
{
// Arrange
var request = new CreateOrderRequest
{
CustomerId = "cust-123",
Items = new List<OrderItem>
{
new OrderItem { ProductId = "p1", Quantity = 2, Price = 29.99m }
}
};
// Act
var result = await _orderService.CreateOrderAsync(request);
// Assert
result.Should().NotBeNull();
result.Id.Should().NotBeEmpty();
result.Total.Should().Be(59.98m);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task CreateOrder_WithEmptyCustomerId_ThrowsException(string customerId)
{
// Arrange
var request = new CreateOrderRequest { CustomerId = customerId };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => _orderService.CreateOrderAsync(request));
}
}
Mocking with Moq
public class PaymentServiceTests
{
private readonly Mock<IPaymentGateway> _mockGateway;
private readonly Mock<ILogger<PaymentService>> _mockLogger;
private readonly PaymentService _service;
public PaymentServiceTests()
{
_mockGateway = new Mock<IPaymentGateway>();
_mockLogger = new Mock<ILogger<PaymentService>>();
_service = new PaymentService(_mockGateway.Object, _mockLogger.Object);
}
[Fact]
public async Task ProcessPayment_WithValidAmount_CallsGateway()
{
// Arrange
var amount = 99.99m;
_mockGateway
.Setup(g => g.AuthorizeAsync(amount))
.ReturnsAsync(new AuthorizationResult { Success = true, TransactionId = "tx-123" });
// Act
var result = await _service.ProcessPaymentAsync(amount);
// Assert
result.Should().NotBeNull();
_mockGateway.Verify(g => g.AuthorizeAsync(amount), Times.Once);
}
[Fact]
public async Task ProcessPayment_WhenGatewayFails_ThrowsException()
{
// Arrange
_mockGateway
.Setup(g => g.AuthorizeAsync(It.IsAny<decimal>()))
.ThrowsAsync(new HttpRequestException("Gateway unavailable"));
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(
() => _service.ProcessPaymentAsync(99.99m));
}
[Fact]
public async Task ProcessPayment_CallsLogger()
{
// Arrange
_mockGateway
.Setup(g => g.AuthorizeAsync(It.IsAny<decimal>()))
.ReturnsAsync(new AuthorizationResult { Success = true });
// Act
await _service.ProcessPaymentAsync(99.99m);
// Assert
_mockLogger.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Payment processed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
}
Data-Driven Tests
public class DiscountCalculatorTests
{
[Theory]
[InlineData(100, 0.0)] // No discount for low amounts
[InlineData(500, 0.1)] // 10% for $500+
[InlineData(1000, 0.15)] // 15% for $1000+
[InlineData(5000, 0.20)] // 20% for $5000+
public void CalculateDiscount_WithAmount_ReturnsCorrectPercentage(decimal amount, double expected)
{
// Act
var discount = DiscountCalculator.Calculate(amount);
// Assert
discount.Should().Be(expected);
}
[Theory]
[MemberData(nameof(GetOrderData))]
public void ProcessOrder_WithValidOrders_Succeeds(Order order, decimal expectedTotal)
{
// Act & Assert
var total = OrderCalculator.CalculateTotal(order);
total.Should().Be(expectedTotal);
}
public static TheoryData<Order, decimal> GetOrderData =>
new()
{
{
new Order { Items = new List<OrderItem>
{
new OrderItem { Price = 50m, Quantity = 2 }
}},
100m
},
{
new Order { Items = new List<OrderItem>
{
new OrderItem { Price = 100m, Quantity = 1 }
}},
100m
}
};
}
Test Fixtures and Cleanup
public class DatabaseTests : IDisposable
{
private readonly IDbContext _dbContext;
public DatabaseTests()
{
// Setup
var options = new DbContextOptionsBuilder<TestDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_dbContext = new TestDbContext(options);
}
public void Dispose()
{
// Cleanup
_dbContext?.Dispose();
}
[Fact]
public async Task SaveUser_PersistsToDatabase()
{
// Arrange
var user = new User { Id = 1, Name = "Alice" };
// Act
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
// Assert
var retrieved = await _dbContext.Users.FindAsync(1);
retrieved.Name.Should().Be("Alice");
}
}
Best Practices
- One assertion per test: Clear failure messages
- Arrange-Act-Assert: Consistent structure
- Descriptive names: Explain what's tested
- Use mocks sparingly: Test integration too
- Keep tests fast: Unit tests should run instantly
Related Concepts
- NUnit (alternative)
- Test-driven development (TDD)
- Behavior-driven development (BDD)
- Integration testing frameworks
Summary
xUnit provides a modern testing framework for C#. Write isolated, data-driven tests that provide confidence in code quality and prevent regressions.