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
- AAA Pattern: Arrange, Act, Assert for clear test structure
- One Assertion: Test one behavior per test method
- Descriptive Names: Use
MethodName_Scenario_ExpectedResultconvention - Avoid Logic: No conditionals or loops in tests
- Fast Tests: Keep unit tests under 100ms
- Isolated Tests: No shared state between tests
- 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.