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

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

Automated testing is essential for maintainable applications.

What you will learn

Practical execution with concise explanations, real implementation patterns, and production-ready recommendations.

!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

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

Terminal output for dotnet test

Coverage Thresholds

Enforce minimum coverage:

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

Test-Driven Development (TDD)

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

  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(
```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