Isaac.

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

  1. One assertion per test: Clear failure messages
  2. Arrange-Act-Assert: Consistent structure
  3. Descriptive names: Explain what's tested
  4. Use mocks sparingly: Test integration too
  5. 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.