Testing in .NET: xUnit, NUnit, Moq, and Integration Testing Best Practices

Testing in .NET: xUnit, NUnit, Moq, and Integration Testing Best Practices

Introduction

Automated testing is essential for maintainable applications. Unit tests verify individual components in isolation, integration tests validate component interactions, and end-to-end tests ensure complete workflows function correctly. This guide covers xUnit and NUnit testing frameworks, mocking with Moq, integration testing with WebApplicationFactory and TestContainers, code coverage measurement, and Test-Driven Development (TDD) practices.

Unit Testing with xUnit

Basic Test Structure

ProductServiceTests.cs:

public class ProductServiceTests
{
    [Fact]
    public async Task GetProductAsync_ExistingId_ReturnsProduct()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        var expectedProduct = new Product { Id = 1, Name = "Laptop" };
        mockRepo.Setup(r => r.GetByIdAsync(1))
            .ReturnsAsync(expectedProduct);
        
        var service = new ProductService(mockRepo.Object);
        
        // Act
        var result = await service.GetProductAsync(1);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal(expectedProduct.Id, result.Id);
        Assert.Equal(expectedProduct.Name, result.Name);
    }
    
    [Fact]
    public async Task GetProductAsync_NonExistingId_ThrowsNotFoundException()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        mockRepo.Setup(r => r.GetByIdAsync(999))
            .ReturnsAsync((Product?)null);
        
        var service = new ProductService(mockRepo.Object);
        
        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => service.GetProductAsync(999));
    }
}

Theory Tests with InlineData

Parameterized tests:

[Theory]
[InlineData(0, false)]
[InlineData(-5, false)]
[InlineData(1, true)]
[InlineData(100, true)]
public void IsValidProductId_VariousInputs_ReturnsExpectedResult(
    int productId,
    bool expected)
{
    // Arrange
    var validator = new ProductValidator();
    
    // Act
    var result = validator.IsValidProductId(productId);
    
    // Assert
    Assert.Equal(expected, result);
}

[Theory]
[MemberData(nameof(GetProductTestData))]
public void ValidateProduct_VariousProducts_ReturnsExpectedResult(
    Product product,
    bool isValid,
    string expectedError)
{
    // Arrange
    var validator = new ProductValidator();
    
    // Act
    var result = validator.Validate(product);
    
    // Assert
    Assert.Equal(isValid, result.IsValid);
    if (!isValid)
    {
        Assert.Contains(expectedError, result.Errors);
    }
}

public static IEnumerable<object[]> GetProductTestData()
{
    yield return new object[]
    {
        new Product { Name = "", Price = 10 },
        false,
        "Name is required"
    };
    
    yield return new object[]
    {
        new Product { Name = "Laptop", Price = -10 },
        false,
        "Price must be positive"
    };
    
    yield return new object[]
    {
        new Product { Name = "Laptop", Price = 999.99 },
        true,
        ""
    };
}

Test Fixtures

Sharing setup across tests:

public class DatabaseFixture : IDisposable
{
    public AppDbContext DbContext { get; }
    
    public DatabaseFixture()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;
        
        DbContext = new AppDbContext(options);
        
        // Seed test data
        DbContext.Products.AddRange(
            new Product { Id = 1, Name = "Laptop", Price = 999 },
            new Product { Id = 2, Name = "Mouse", Price = 25 }
        );
        DbContext.SaveChanges();
    }
    
    public void Dispose()
    {
        DbContext.Dispose();
    }
}

public class ProductRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;
    
    public ProductRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }
    
    [Fact]
    public async Task GetByIdAsync_ExistingId_ReturnsProduct()
    {
        // Arrange
        var repository = new ProductRepository(_fixture.DbContext);
        
        // Act
        var result = await repository.GetByIdAsync(1);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal("Laptop", result.Name);
    }
}

Unit Testing with NUnit

NUnit Syntax

Equivalent NUnit tests:

[TestFixture]
public class ProductServiceTests
{
    private Mock<IProductRepository> _mockRepo;
    private ProductService _service;
    
    [SetUp]
    public void Setup()
    {
        _mockRepo = new Mock<IProductRepository>();
        _service = new ProductService(_mockRepo.Object);
    }
    
    [Test]
    public async Task GetProductAsync_ExistingId_ReturnsProduct()
    {
        // Arrange
        var expectedProduct = new Product { Id = 1, Name = "Laptop" };
        _mockRepo.Setup(r => r.GetByIdAsync(1))
            .ReturnsAsync(expectedProduct);
        
        // Act
        var result = await _service.GetProductAsync(1);
        
        // Assert
        Assert.That(result, Is.Not.Null);
        Assert.That(result.Id, Is.EqualTo(expectedProduct.Id));
        Assert.That(result.Name, Is.EqualTo(expectedProduct.Name));
    }
    
    [TestCase(0, false)]
    [TestCase(-5, false)]
    [TestCase(1, true)]
    [TestCase(100, true)]
    public void IsValidProductId_VariousInputs_ReturnsExpectedResult(
        int productId,
        bool expected)
    {
        // Arrange
        var validator = new ProductValidator();
        
        // Act
        var result = validator.IsValidProductId(productId);
        
        // Assert
        Assert.That(result, Is.EqualTo(expected));
    }
    
    [TearDown]
    public void TearDown()
    {
        _mockRepo = null;
        _service = null;
    }
}

Mocking with Moq

Basic Mocking

Setup and verification:

[Fact]
public async Task CreateOrderAsync_ValidOrder_CallsRepositoryAndSendsEmail()
{
    // Arrange
    var mockRepo = new Mock<IOrderRepository>();
    var mockEmailService = new Mock<IEmailService>();
    
    var order = new Order { CustomerId = 1, Total = 100 };
    
    mockRepo.Setup(r => r.CreateAsync(It.IsAny<Order>()))
        .ReturnsAsync(new Order { Id = 123, CustomerId = 1, Total = 100 });
    
    mockEmailService.Setup(e => e.SendOrderConfirmationAsync(It.IsAny<Order>()))
        .Returns(Task.CompletedTask);
    
    var service = new OrderService(mockRepo.Object, mockEmailService.Object);
    
    // Act
    var result = await service.CreateOrderAsync(order);
    
    // Assert
    Assert.NotNull(result);
    Assert.Equal(123, result.Id);
    
    mockRepo.Verify(r => r.CreateAsync(It.Is<Order>(o => o.Total == 100)), Times.Once);
    mockEmailService.Verify(
        e => e.SendOrderConfirmationAsync(It.Is<Order>(o => o.Id == 123)),
        Times.Once);
}

Argument Matchers

Complex matching:

[Fact]
public async Task ProcessPayment_ValidCard_CallsPaymentGateway()
{
    // Arrange
    var mockGateway = new Mock<IPaymentGateway>();
    
    mockGateway.Setup(g => g.ProcessAsync(
        It.Is<PaymentRequest>(r => 
            r.Amount > 0 && 
            r.Currency == "USD" &&
            r.CardNumber.Length == 16)))
        .ReturnsAsync(new PaymentResponse { Success = true });
    
    var service = new PaymentService(mockGateway.Object);
    
    // Act
    var result = await service.ProcessPaymentAsync(new PaymentRequest
    {
        Amount = 100,
        Currency = "USD",
        CardNumber = "1234567890123456"
    });
    
    // Assert
    Assert.True(result.Success);
}

Callbacks

Capturing arguments:

[Fact]
public async Task CreateUser_ValidUser_HashesPassword()
{
    // Arrange
    var mockRepo = new Mock<IUserRepository>();
    string capturedPasswordHash = null;
    
    mockRepo.Setup(r => r.CreateAsync(It.IsAny<User>()))
        .Callback<User>(u => capturedPasswordHash = u.PasswordHash)
        .ReturnsAsync((User u) => u);
    
    var service = new UserService(mockRepo.Object);
    
    // Act
    await service.CreateUserAsync("john@example.com", "password123");
    
    // Assert
    Assert.NotNull(capturedPasswordHash);
    Assert.NotEqual("password123", capturedPasswordHash); // Hashed
    Assert.True(capturedPasswordHash.Length > 20); // BCrypt hash
}

Integration Testing

WebApplicationFactory

ASP.NET Core integration tests:

public class CustomWebApplicationFactory<TProgram>
    : WebApplicationFactory<TProgram> where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove real database
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            
            if (descriptor != null)
                services.Remove(descriptor);
            
            // Add in-memory database
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDb");
            });
            
            // Replace email service with fake
            services.Remove(services.SingleOrDefault(
                d => d.ServiceType == typeof(IEmailService)));
            services.AddScoped<IEmailService, FakeEmailService>();
            
            // Build service provider and seed database
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            
            db.Database.EnsureCreated();
            SeedTestData(db);
        });
    }
    
    private void SeedTestData(AppDbContext db)
    {
        db.Products.AddRange(
            new Product { Id = 1, Name = "Laptop", Price = 999 },
            new Product { Id = 2, Name = "Mouse", Price = 25 }
        );
        db.SaveChanges();
    }
}

public class ProductsApiTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    
    public ProductsApiTests(CustomWebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }
    
    [Fact]
    public async Task GetProduct_ExistingId_ReturnsProduct()
    {
        // Act
        var response = await _client.GetAsync("/api/products/1");
        
        // Assert
        response.EnsureSuccessStatusCode();
        var product = await response.Content.ReadFromJsonAsync<Product>();
        
        Assert.NotNull(product);
        Assert.Equal("Laptop", product.Name);
    }
    
    [Fact]
    public async Task CreateProduct_ValidProduct_Returns201()
    {
        // Arrange
        var newProduct = new { Name = "Keyboard", Price = 49.99 };
        
        // Act
        var response = await _client.PostAsJsonAsync("/api/products", newProduct);
        
        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        var created = await response.Content.ReadFromJsonAsync<Product>();
        Assert.Equal(newProduct.Name, created.Name);
    }
}

TestContainers

Real database integration tests:

public class DatabaseIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder()
        .WithImage("postgres:15")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();
    
    private AppDbContext _dbContext;
    
    public async Task InitializeAsync()
    {
        await _postgresContainer.StartAsync();
        
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgresContainer.GetConnectionString())
            .Options;
        
        _dbContext = new AppDbContext(options);
        await _dbContext.Database.MigrateAsync();
    }
    
    [Fact]
    public async Task CreateProduct_RealDatabase_PersistsCorrectly()
    {
        // Arrange
        var repository = new ProductRepository(_dbContext);
        var product = new Product { Name = "Laptop", Price = 999 };
        
        // Act
        var created = await repository.CreateAsync(product);
        
        // Assert
        Assert.True(created.Id > 0);
        
        var retrieved = await repository.GetByIdAsync(created.Id);
        Assert.NotNull(retrieved);
        Assert.Equal(product.Name, retrieved.Name);
    }
    
    public async Task DisposeAsync()
    {
        await _dbContext.DisposeAsync();
        await _postgresContainer.DisposeAsync();
    }
}

FluentAssertions

Readable assertions:

[Fact]
public async Task GetProducts_ReturnsMultipleProducts()
{
    // Arrange
    var repository = CreateRepository();
    
    // Act
    var products = await repository.GetAllAsync();
    
    // Assert
    products.Should().NotBeNull()
        .And.HaveCount(2)
        .And.OnlyContain(p => p.Price > 0);
    
    products.Should().Contain(p => p.Name == "Laptop")
        .Which.Price.Should().BeGreaterThan(500);
    
    var laptop = products.First(p => p.Name == "Laptop");
    laptop.Should().BeEquivalentTo(new Product
    {
        Name = "Laptop",
        Price = 999
    }, options => options.Excluding(p => p.Id));
}

Code Coverage

Coverlet Configuration

.csproj:

<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.0.0">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
  <PackageReference Include="coverlet.msbuild" Version="6.0.0">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
</ItemGroup>

Collect coverage:

# Run tests with coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover

# Generate HTML report
dotnet tool install -g dotnet-reportgenerator-globaltool

reportgenerator `
  -reports:**/coverage.opencover.xml `
  -targetdir:coveragereport `
  -reporttypes:Html

# Open report
Start-Process coveragereport/index.html

Coverage Thresholds

Enforce minimum coverage:

<PropertyGroup>
  <Threshold>80</Threshold>
  <ThresholdType>line,branch</ThresholdType>
  <ThresholdStat>total</ThresholdStat>
</PropertyGroup>

Test-Driven Development (TDD)

Red-Green-Refactor Cycle

Example workflow:

// 1. RED: Write failing test
[Fact]
public void Calculate_TwoNumbers_ReturnsSum()
{
    var calculator = new Calculator();
    var result = calculator.Add(2, 3);
    Assert.Equal(5, result);
}

// 2. GREEN: Implement minimal code to pass
public class Calculator
{
    public int Add(int a, int b)
    {
        return 5; // Hardcoded to pass
    }
}

// 3. REFACTOR: Improve implementation
public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b; // Proper implementation
    }
}

// 4. Add more tests
[Theory]
[InlineData(0, 0, 0)]
[InlineData(1, 1, 2)]
[InlineData(-1, 1, 0)]
[InlineData(100, 200, 300)]
public void Calculate_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    var result = calculator.Add(a, b);
    Assert.Equal(expected, result);
}

Best Practices

  1. AAA Pattern: Arrange, Act, Assert for clear test structure
  2. One Assertion: Test one behavior per test method
  3. Descriptive Names: Use MethodName_Scenario_ExpectedResult convention
  4. Avoid Logic: No conditionals or loops in tests
  5. Fast Tests: Keep unit tests under 100ms
  6. Isolated Tests: No shared state between tests
  7. Test Edge Cases: Validate boundary conditions and error paths

Troubleshooting

Flaky Tests:

// ❌ Flaky due to timing
await Task.Delay(1000);
Assert.True(processCompleted);

// ✅ Wait with timeout
await Task.WhenAny(
    processCompletedTask,
    Task.Delay(TimeSpan.FromSeconds(5)));
Assert.True(processCompleted);

Test Isolation:

// ✅ Reset state in fixture
public class DatabaseFixture : IDisposable
{
    public void ResetDatabase()
    {
        DbContext.Database.EnsureDeleted();
        DbContext.Database.EnsureCreated();
    }
}

Key Takeaways

  • xUnit and NUnit provide robust testing frameworks with different syntax styles
  • Moq enables testing in isolation by mocking dependencies
  • WebApplicationFactory simplifies ASP.NET Core integration testing
  • TestContainers provide real database instances for integration tests
  • Code coverage ensures comprehensive test suites
  • TDD drives better design through test-first development

Next Steps

  • Implement mutation testing with Stryker.NET
  • Add snapshot testing for API responses
  • Explore property-based testing with FsCheck
  • Use SpecFlow for BDD scenarios

Additional Resources


Test early, test often.