!Architecture Overview
Testing in .NET: xUnit, NUnit, Moq, and Integration Testing Best Practices
Introduction
Prerequisites
| Requirement | Details |
|---|---|
| .NET SDK installed | .NET SDK installed |
| VS Code or Visual Studio | VS Code or Visual Studio |
| Git installed | Git installed |
Figure: Code architecture demonstrating testing in .net—layered design, dependency injection patterns, interface abstractions, and testability considerations.
Figure: Performance benchmarking results for testing in .net—throughput metrics, latency percentiles, memory allocation profiles, and optimization opportunities.
Figure: Deployment pipeline for testing in .net—build automation, test execution, package publication, and production release workflow.
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
{
```csharp
[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(
```text
int productId,
bool expected)```
{
```text
// 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(
```text
Product product,
bool isValid,
string expectedError)```
{
```text
// 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()
{
```text
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
{
```text
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>
{
```csharp
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
{
```csharp
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()
{
```javascript
// 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()
{
```javascript
// 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()
{
```javascript
// 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@contoso.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>
```text
: WebApplicationFactory<TProgram> where TProgram : class```
{
```csharp
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>>
{
```csharp
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
{
```csharp
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()
{
```javascript
// 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
Figure: RAG pipeline – document ingestion, chunking, and retrieval flow.
Coverlet Configuration
.csproj:
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
```text
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>```
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.0">
```text
<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
Expected output:
Passed! - Failed: 0, Passed: 24, Skipped: 0, Total: 24, Duration: 1.8 s
Coverage Thresholds
Enforce minimum coverage:
<PropertyGroup>
<Threshold>80</Threshold>
<ThresholdType>line,branch</ThresholdType>
<ThresholdStat>total</ThresholdStat>
</PropertyGroup>
Test-Driven Development (TDD)
Figure: Test Explorer – categorized results with green/red pass/fail indicators.
Red-Green-Refactor Cycle
Example workflow:
// 1. RED: Write failing test
[Fact]
public void Calculate_TwoNumbers_ReturnsSum()
{
```text
var calculator = new Calculator();
var result = calculator.Add(2, 3);
Assert.Equal(5, result);```
}
// 2. GREEN: Implement minimal code to pass
public class Calculator
{
```text
public int Add(int a, int b)
{
return 5; // Hardcoded to pass
}```
}
// 3. REFACTOR: Improve implementation
public class Calculator
{
```text
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)
{
```text
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(
```text
processCompletedTask,
Task.Delay(TimeSpan.FromSeconds(5)));```
Assert.True(processCompleted);
Test Isolation:
// ✅ Reset state in fixture
public class DatabaseFixture : IDisposable
{
```text
public void ResetDatabase()
{
DbContext.Database.EnsureDeleted();
DbContext.Database.EnsureCreated();
}```
}
Architecture Decision and Tradeoffs
When designing application development solutions with .NET, consider these key architectural trade-offs:
| Approach | Best For | Tradeoff |
|---|---|---|
| Managed / platform service | Rapid delivery, reduced ops burden | Less customisation, potential vendor lock-in |
| Custom / self-hosted | Full control, advanced tuning | Higher operational overhead and cost |
Recommendation: Start with the managed approach for most workloads and move to custom only when specific requirements demand it.
Validation and Versioning
- Last validated: April 2026
- Validate examples against your tenant, region, and SKU constraints before production rollout.
- Keep module, CLI, and SDK versions pinned in automation pipelines and review quarterly.
Security and Governance Considerations
- Apply least-privilege access using RBAC roles and just-in-time elevation for admin tasks.
- Store secrets in managed secret stores and avoid embedding credentials in scripts or source files.
- Enable audit logging, data protection policies, and periodic access reviews for regulated workloads.
Cost and Performance Notes
- Define budgets and alerts, then monitor usage and cost trends continuously after go-live.
- Baseline performance with synthetic and real-user checks before and after major changes.
- Scale resources with measured thresholds and revisit sizing after usage pattern changes.
Official Microsoft References
- https://learn.microsoft.com/dotnet/
- https://learn.microsoft.com/aspnet/core/
- https://learn.microsoft.com/azure/developer/dotnet/
Public Examples from Official Sources
- These examples are sourced from official public Microsoft documentation and sample repositories.
- Documentation examples: https://learn.microsoft.com/dotnet/
- Sample repositories: https://github.com/dotnet/samples
- Prefer adapting these examples to your tenant, subscriptions, and governance requirements before production use.
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.
Discussion