Mastering Unit Testing and BDD with xUnit and SpecFlow in .NET 9: A Comprehensive Guide
Mastering Unit Testing and BDD with xUnit and SpecFlow in .NET 9: A Comprehensive Guide
Table of Contents
- Introduction: Why Testing Saves Your Project
- Unit Testing with xUnit in .NET 9
- Behavior-Driven Development with SpecFlow
- Code Coverage: Measuring and Improving Test Quality
- Surviving Critical Scenarios: Real-World Testing Patterns
- Comprehensive Example: Building a Tested E-Commerce System
- Best Practices and Advanced Patterns
- Conclusion and Next Steps
Introduction: Why Testing Saves Your Project
Imagine deploying a critical update to your e-commerce platform on Black Friday, only to discover that the payment processing module fails under load. Or consider a healthcare application where a simple calculation error could lead to incorrect medication dosages. These scenarios aren’t just theoretical – they represent real-world disasters that proper testing could have prevented.
Testing isn’t just about finding bugs; it’s about building confidence in your code, enabling fearless refactoring, and creating a safety net that catches problems before they reach production. In this comprehensive guide, we’ll explore how to leverage .NET 9’s powerful testing capabilities to build robust, reliable applications.
Unit Testing with xUnit in .NET 9
Why xUnit?
After extensive research and real-world experience, xUnit emerges as the preferred testing framework for .NET projects. Created by the original author of NUnit v2, xUnit offers several compelling advantages:
- Performance: xUnit runs tests faster than alternatives due to its lightweight architecture
- Modern Design: Built from the ground up for .NET, utilizing modern C# features
- Parallel Execution: Tests run in parallel by default, significantly reducing test suite execution time
- Better Isolation: Each test runs in its own instance, preventing state pollution
- Excellent Tooling: First-class support in Visual Studio, VS Code, Rider, and CI/CD pipelines
Setting Up xUnit in .NET 9
Let’s start by creating a new solution with a class library and test project:
# Create solution directory
mkdir ECommerceApp
cd ECommerceApp
# Create solution file
dotnet new sln -n ECommerceApp
# Create main project
dotnet new classlib -n ECommerce.Core -f net9.0
dotnet sln add ECommerce.Core/ECommerce.Core.csproj
# Create test project
dotnet new xunit -n ECommerce.Core.Tests -f net9.0
dotnet sln add ECommerce.Core.Tests/ECommerce.Core.Tests.csproj
# Add reference from test project to main project
cd ECommerce.Core.Tests
dotnet add reference ../ECommerce.Core/ECommerce.Core.csproj
Writing Your First Unit Tests
Let’s create a simple shopping cart service to demonstrate unit testing principles:
// ECommerce.Core/Models/Product.cs
namespace ECommerce.Core.Models;
public class Product
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
}
// ECommerce.Core/Models/CartItem.cs
namespace ECommerce.Core.Models;
public class CartItem
{
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal TotalPrice => Product.Price * Quantity;
}
// ECommerce.Core/Services/ShoppingCartService.cs
namespace ECommerce.Core.Services;
public class ShoppingCartService
{
private readonly List<CartItem> _items = new();
private readonly IInventoryService _inventoryService;
private readonly IPricingService _pricingService;
public ShoppingCartService(IInventoryService inventoryService, IPricingService pricingService)
{
_inventoryService = inventoryService;
_pricingService = pricingService;
}
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public decimal TotalAmount => _items.Sum(item => item.TotalPrice);
public void AddItem(Product product, int quantity)
{
// Validate input parameters
if (product == null) throw new ArgumentNullException(nameof(product));
if (quantity <= 0) throw new ArgumentException("Quantity must be positive", nameof(quantity));
// Check stock availability
if (!_inventoryService.IsInStock(product.Id, quantity))
throw new InvalidOperationException($"Insufficient stock for product {product.Name}");
// Check if product already exists in cart
var existingItem = _items.FirstOrDefault(i => i.Product.Id == product.Id);
if (existingItem != null)
{
existingItem.Quantity += quantity;
}
else
{
_items.Add(new CartItem { Product = product, Quantity = quantity });
}
}
public void RemoveItem(string productId)
{
_items.RemoveAll(i => i.Product.Id == productId);
}
public decimal CalculateTotalWithDiscounts()
{
return _pricingService.CalculateDiscountedTotal(_items);
}
}
Now let’s write comprehensive unit tests:
// ECommerce.Core.Tests/Services/ShoppingCartServiceTests.cs
using Xunit;
using Moq;
using ECommerce.Core.Services;
using ECommerce.Core.Models;
using FluentAssertions;
namespace ECommerce.Core.Tests.Services;
public class ShoppingCartServiceTests
{
private readonly Mock<IInventoryService> _inventoryServiceMock;
private readonly Mock<IPricingService> _pricingServiceMock;
private readonly ShoppingCartService _sut; // System Under Test
public ShoppingCartServiceTests()
{
// Arrange - Common setup for all tests
_inventoryServiceMock = new Mock<IInventoryService>();
_pricingServiceMock = new Mock<IPricingService>();
_sut = new ShoppingCartService(_inventoryServiceMock.Object, _pricingServiceMock.Object);
}
[Fact]
public void AddItem_ValidProduct_AddsToCart()
{
// Arrange
var product = new Product { Id = "1", Name = "Laptop", Price = 999.99m, StockQuantity = 10 };
_inventoryServiceMock.Setup(x => x.IsInStock("1", 1)).Returns(true);
// Act
_sut.AddItem(product, 1);
// Assert
_sut.Items.Should().HaveCount(1);
_sut.Items[0].Product.Should().Be(product);
_sut.Items[0].Quantity.Should().Be(1);
_sut.TotalAmount.Should().Be(999.99m);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void AddItem_InvalidQuantity_ThrowsArgumentException(int invalidQuantity)
{
// Arrange
var product = new Product { Id = "1", Name = "Mouse", Price = 29.99m };
// Act & Assert
var action = () => _sut.AddItem(product, invalidQuantity);
action.Should().Throw<ArgumentException>()
.WithMessage("*Quantity must be positive*")
.WithParameterName("quantity");
}
[Fact]
public void AddItem_NullProduct_ThrowsArgumentNullException()
{
// Act & Assert
var action = () => _sut.AddItem(null!, 1);
action.Should().Throw<ArgumentNullException>()
.WithParameterName("product");
}
[Fact]
public void AddItem_InsufficientStock_ThrowsInvalidOperationException()
{
// Arrange
var product = new Product { Id = "1", Name = "GPU", Price = 1499.99m };
_inventoryServiceMock.Setup(x => x.IsInStock("1", 5)).Returns(false);
// Act & Assert
var action = () => _sut.AddItem(product, 5);
action.Should().Throw<InvalidOperationException>()
.WithMessage("*Insufficient stock*");
}
[Fact]
public void AddItem_ExistingProduct_IncreasesQuantity()
{
// Arrange
var product = new Product { Id = "1", Name = "Keyboard", Price = 79.99m };
_inventoryServiceMock.Setup(x => x.IsInStock("1", It.IsAny<int>())).Returns(true);
// Act
_sut.AddItem(product, 2);
_sut.AddItem(product, 3);
// Assert
_sut.Items.Should().HaveCount(1);
_sut.Items[0].Quantity.Should().Be(5);
_sut.TotalAmount.Should().Be(399.95m);
}
[Fact]
public void CalculateTotalWithDiscounts_CallsPricingService()
{
// Arrange
var product = new Product { Id = "1", Name = "Monitor", Price = 299.99m };
_inventoryServiceMock.Setup(x => x.IsInStock("1", 1)).Returns(true);
_pricingServiceMock.Setup(x => x.CalculateDiscountedTotal(It.IsAny<IEnumerable<CartItem>>()))
.Returns(269.99m); // 10% discount
// Act
_sut.AddItem(product, 1);
var discountedTotal = _sut.CalculateTotalWithDiscounts();
// Assert
discountedTotal.Should().Be(269.99m);
_pricingServiceMock.Verify(x => x.CalculateDiscountedTotal(It.IsAny<IEnumerable<CartItem>>()), Times.Once);
}
}
Advanced xUnit Features
1. Test Data with MemberData and ClassData
public class ProductTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { new Product { Id = "1", Price = 10m }, 5, 50m };
yield return new object[] { new Product { Id = "2", Price = 25.50m }, 2, 51m };
yield return new object[] { new Product { Id = "3", Price = 100m }, 1, 100m };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class AdvancedShoppingCartTests
{
[Theory]
[ClassData(typeof(ProductTestData))]
public void CalculateTotal_VariousProducts_ReturnsCorrectAmount(Product product, int quantity, decimal expectedTotal)
{
// Test implementation
}
}
2. Test Collections and Fixtures
// Shared database context for integration tests
public class DatabaseFixture : IDisposable
{
public DatabaseFixture()
{
// Initialize test database
ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=TestDb;Trusted_Connection=true";
// Run migrations, seed data, etc.
}
public string ConnectionString { get; private set; }
public void Dispose()
{
// Cleanup test database
}
}
[CollectionDefinition("Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
// This class has no code, and is never created.
}
[Collection("Database collection")]
public class DatabaseIntegrationTests
{
private readonly DatabaseFixture _fixture;
public DatabaseIntegrationTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task SaveOrder_ValidOrder_PersistsToDatabase()
{
// Use _fixture.ConnectionString for database operations
}
}
Behavior-Driven Development with SpecFlow
Understanding BDD
Behavior-Driven Development bridges the communication gap between technical and non-technical stakeholders by expressing requirements as concrete examples in plain language. BDD follows the Given-When-Then pattern:
- Given: The initial context or state
- When: The action or event that occurs
- Then: The expected outcome or behavior
Setting Up SpecFlow
First, install the SpecFlow extension for your IDE (Visual Studio 2022 or Rider). Then create a new SpecFlow project:
# Create SpecFlow project
dotnet new specflow -n ECommerce.Specs -f net9.0
cd ECommerce.Specs
# Add necessary packages
dotnet add package SpecFlow.xUnit
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package FluentAssertions
# Reference your main project
dotnet add reference ../ECommerce.Core/ECommerce.Core.csproj
Writing Feature Files
Let’s create a feature file for our shopping cart:
# ECommerce.Specs/Features/ShoppingCart.feature
Feature: Shopping Cart Management
As an online shopper
I want to manage items in my shopping cart
So that I can purchase products efficiently
Background:
Given the following products exist:
| ProductId | Name | Price | Stock |
| LAPTOP01 | Gaming Laptop | 1299.99| 5 |
| MOUSE01 | Wireless Mouse | 49.99 | 20 |
| KEYB01 | Mechanical Keyboard | 129.99 | 10 |
Scenario: Add single item to empty cart
Given I have an empty shopping cart
When I add 1 "Gaming Laptop" to the cart
Then the cart should contain 1 item
And the cart total should be $1299.99
Scenario: Add multiple items to cart
Given I have an empty shopping cart
When I add the following items to the cart:
| Product | Quantity |
| Gaming Laptop | 1 |
| Wireless Mouse | 2 |
| Mechanical Keyboard| 1 |
Then the cart should contain 3 different products
And the cart should have 4 total items
And the cart total should be $1529.96
Scenario Outline: Apply discounts based on total amount
Given I have an empty shopping cart
And the store has a <DiscountPercent>% discount for orders over $<MinAmount>
When I add <Quantity> "<Product>" to the cart
Then the original total should be $<OriginalTotal>
And the discounted total should be $<DiscountedTotal>
Examples:
| Product | Quantity | MinAmount | DiscountPercent | OriginalTotal | DiscountedTotal |
| Gaming Laptop | 1 | 1000 | 10 | 1299.99 | 1169.99 |
| Gaming Laptop | 2 | 2000 | 15 | 2599.98 | 2209.98 |
| Wireless Mouse| 5 | 200 | 5 | 249.95 | 237.45 |
Scenario: Prevent adding out-of-stock items
Given I have an empty shopping cart
And "Gaming Laptop" has only 2 items in stock
When I try to add 3 "Gaming Laptop" to the cart
Then I should see an error "Insufficient stock available"
And the cart should remain empty
Scenario: Remove item from cart
Given I have the following items in my cart:
| Product | Quantity |
| Gaming Laptop | 1 |
| Wireless Mouse | 2 |
When I remove "Wireless Mouse" from the cart
Then the cart should contain 1 item
And the cart should not contain "Wireless Mouse"
And the cart total should be $1299.99
Implementing Step Definitions
// ECommerce.Specs/StepDefinitions/ShoppingCartSteps.cs
using TechTalk.SpecFlow;
using FluentAssertions;
using ECommerce.Core.Services;
using ECommerce.Core.Models;
using Moq;
namespace ECommerce.Specs.StepDefinitions;
[Binding]
public class ShoppingCartSteps
{
private readonly ScenarioContext _scenarioContext;
private ShoppingCartService _shoppingCart = null!;
private Mock<IInventoryService> _inventoryServiceMock = null!;
private Mock<IPricingService> _pricingServiceMock = null!;
private Dictionary<string, Product> _products = new();
private Exception? _lastException;
public ShoppingCartSteps(ScenarioContext scenarioContext)
{
_scenarioContext = scenarioContext;
}
[BeforeScenario]
public void Setup()
{
_inventoryServiceMock = new Mock<IInventoryService>();
_pricingServiceMock = new Mock<IPricingService>();
_shoppingCart = new ShoppingCartService(_inventoryServiceMock.Object, _pricingServiceMock.Object);
_products = new Dictionary<string, Product>();
}
[Given(@"the following products exist:")]
public void GivenTheFollowingProductsExist(Table table)
{
foreach (var row in table.Rows)
{
var product = new Product
{
Id = row["ProductId"],
Name = row["Name"],
Price = decimal.Parse(row["Price"]),
StockQuantity = int.Parse(row["Stock"])
};
_products[product.Name] = product;
// Setup inventory mock for this product
_inventoryServiceMock
.Setup(x => x.IsInStock(product.Id, It.IsAny<int>()))
.Returns<string, int>((id, qty) => qty <= product.StockQuantity);
}
}
[Given(@"I have an empty shopping cart")]
public void GivenIHaveAnEmptyShoppingCart()
{
_shoppingCart.Items.Should().BeEmpty();
}
[Given(@"the store has a (.*)% discount for orders over \$(.*)")]
public void GivenTheStoreHasADiscountForOrdersOver(int discountPercent, decimal minAmount)
{
_pricingServiceMock
.Setup(x => x.CalculateDiscountedTotal(It.IsAny<IEnumerable<CartItem>>()))
.Returns<IEnumerable<CartItem>>(items =>
{
var total = items.Sum(i => i.TotalPrice);
if (total > minAmount)
{
return total * (1 - discountPercent / 100m);
}
return total;
});
}
[Given(@"""(.*)"" has only (.*) items in stock")]
public void GivenProductHasOnlyItemsInStock(string productName, int stockQuantity)
{
var product = _products[productName];
product.StockQuantity = stockQuantity;
_inventoryServiceMock
.Setup(x => x.IsInStock(product.Id, It.IsAny<int>()))
.Returns<string, int>((id, qty) => qty <= stockQuantity);
}
[When(@"I add (.*) ""(.*)"" to the cart")]
public void WhenIAddProductToTheCart(int quantity, string productName)
{
try
{
var product = _products[productName];
_shoppingCart.AddItem(product, quantity);
}
catch (Exception ex)
{
_lastException = ex;
}
}
[When(@"I try to add (.*) ""(.*)"" to the cart")]
public void WhenITryToAddProductToTheCart(int quantity, string productName)
{
WhenIAddProductToTheCart(quantity, productName);
}
[When(@"I add the following items to the cart:")]
public void WhenIAddTheFollowingItemsToTheCart(Table table)
{
foreach (var row in table.Rows)
{
var productName = row["Product"];
var quantity = int.Parse(row["Quantity"]);
WhenIAddProductToTheCart(quantity, productName);
}
}
[When(@"I remove ""(.*)"" from the cart")]
public void WhenIRemoveProductFromTheCart(string productName)
{
var product = _products[productName];
_shoppingCart.RemoveItem(product.Id);
}
[Then(@"the cart should contain (.*) item(?:s)?")]
public void ThenTheCartShouldContainItems(int expectedCount)
{
_shoppingCart.Items.Count.Should().Be(expectedCount);
}
[Then(@"the cart should contain (.*) different products")]
public void ThenTheCartShouldContainDifferentProducts(int expectedCount)
{
_shoppingCart.Items.Select(i => i.Product.Id).Distinct().Count().Should().Be(expectedCount);
}
[Then(@"the cart should have (.*) total items")]
public void ThenTheCartShouldHaveTotalItems(int expectedTotal)
{
_shoppingCart.Items.Sum(i => i.Quantity).Should().Be(expectedTotal);
}
[Then(@"the cart total should be \$(.*)")]
public void ThenTheCartTotalShouldBe(decimal expectedTotal)
{
_shoppingCart.TotalAmount.Should().Be(expectedTotal);
}
[Then(@"I should see an error ""(.*)""")]
public void ThenIShouldSeeAnError(string expectedError)
{
_lastException.Should().NotBeNull();
_lastException!.Message.Should().Contain(expectedError);
}
[Then(@"the cart should remain empty")]
public void ThenTheCartShouldRemainEmpty()
{
_shoppingCart.Items.Should().BeEmpty();
}
}
Code Coverage: Measuring and Improving Test Quality
Understanding Code Coverage Metrics
Code coverage isn’t just a number – it’s a diagnostic tool that helps you understand which parts of your code are tested and which are vulnerable. Let’s explore the key metrics:
- Statement Coverage: Percentage of executed statements
- Branch Coverage: Percentage of decision branches taken
- Function Coverage: Percentage of functions called
- Line Coverage: Percentage of lines executed
Setting Up Code Coverage in .NET 9
Using Built-in .NET Coverage
# Run tests with code coverage
dotnet test --collect:"Code Coverage"
# For cross-platform coverage with Coverlet
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
Installing and Configuring Coverlet
<!-- In your test project .csproj file -->
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<CoverletOutput>./TestResults/</CoverletOutput>
<ExcludeByAttribute>GeneratedCode,CompilerGenerated</ExcludeByAttribute>
</PropertyGroup>
Generating Coverage Reports
# Install ReportGenerator globally
dotnet tool install -g dotnet-reportgenerator-globaltool
# Generate HTML report
reportgenerator -reports:"./TestResults/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
Strategies for Increasing Code Coverage
1. Start with Critical Paths
Focus on the most important business logic first. In our e-commerce example, prioritize:
- Payment processing
- Inventory management
- Order fulfillment
- User authentication
2. Use Coverage to Find Edge Cases
Low coverage often reveals untested edge cases. Let’s improve our ShoppingCartService:
public class ShoppingCartService
{
// ... existing code ...
public void ApplyCoupon(string couponCode)
{
if (string.IsNullOrWhiteSpace(couponCode))
throw new ArgumentException("Coupon code cannot be empty", nameof(couponCode));
// Edge case: Cart is empty
if (!_items.Any())
throw new InvalidOperationException("Cannot apply coupon to empty cart");
// Edge case: Coupon already applied
if (_appliedCoupons.Contains(couponCode))
throw new InvalidOperationException($"Coupon {couponCode} already applied");
var discount = _pricingService.GetCouponDiscount(couponCode);
// Edge case: Invalid or expired coupon
if (discount == null)
throw new InvalidOperationException($"Invalid or expired coupon: {couponCode}");
_appliedCoupons.Add(couponCode);
}
public void ClearCart()
{
_items.Clear();
_appliedCoupons.Clear();
OnCartCleared?.Invoke(this, EventArgs.Empty);
}
public event EventHandler? OnCartCleared;
}
And comprehensive tests for edge cases:
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void ApplyCoupon_InvalidCouponCode_ThrowsArgumentException(string invalidCode)
{
// Act & Assert
var action = () => _sut.ApplyCoupon(invalidCode);
action.Should().Throw<ArgumentException>()
.WithParameterName("couponCode");
}
[Fact]
public void ApplyCoupon_EmptyCart_ThrowsInvalidOperationException()
{
// Act & Assert
var action = () => _sut.ApplyCoupon("SAVE10");
action.Should().Throw<InvalidOperationException>()
.WithMessage("*empty cart*");
}
[Fact]
public void ClearCart_RaisesOnCartClearedEvent()
{
// Arrange
var eventRaised = false;
_sut.OnCartCleared += (sender, args) => eventRaised = true;
var product = new Product { Id = "1", Name = "Test", Price = 10m };
_inventoryServiceMock.Setup(x => x.IsInStock("1", 1)).Returns(true);
_sut.AddItem(product, 1);
// Act
_sut.ClearCart();
// Assert
eventRaised.Should().BeTrue();
_sut.Items.Should().BeEmpty();
}
Code Coverage Best Practices
- Aim for 80% Coverage: This is a practical target that balances effort with benefit
- Focus on Quality, Not Just Quantity: 100% coverage with poor tests is worse than 70% with excellent tests
- Use Coverage to Find Gaps, Not as a Goal: Coverage is a tool, not a target
- Exclude Generated Code: Don’t waste time testing auto-generated code
- Test Behavior, Not Implementation: Focus on what the code does, not how it does it
Surviving Critical Scenarios: Real-World Testing Patterns
1. Testing for Race Conditions
public class InventoryService
{
private readonly ConcurrentDictionary<string, int> _stock = new();
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<bool> TryReserveStockAsync(string productId, int quantity)
{
await _semaphore.WaitAsync();
try
{
if (_stock.TryGetValue(productId, out var currentStock) && currentStock >= quantity)
{
_stock[productId] = currentStock - quantity;
return true;
}
return false;
}
finally
{
_semaphore.Release();
}
}
}
// Test for race conditions
[Fact]
public async Task TryReserveStock_ConcurrentRequests_HandlesRaceCondition()
{
// Arrange
var service = new InventoryService();
service.SetStock("PROD1", 10);
var tasks = new List<Task<bool>>();
// Act - 20 concurrent requests for 1 item each (only 10 should succeed)
for (int i = 0; i < 20; i++)
{
tasks.Add(Task.Run(() => service.TryReserveStockAsync("PROD1", 1)));
}
var results = await Task.WhenAll(tasks);
// Assert
results.Count(r => r == true).Should().Be(10);
results.Count(r => r == false).Should().Be(10);
service.GetStock("PROD1").Should().Be(0);
}
2. Testing for Memory Leaks
[Fact]
public void ShoppingCart_Disposal_ReleasesResources()
{
// Arrange
WeakReference weakRef;
// Create cart in separate method to ensure it goes out of scope
void CreateAndUseCart()
{
var cart = new ShoppingCartService(_inventoryServiceMock.Object, _pricingServiceMock.Object);
cart.AddItem(new Product { Id = "1", Name = "Test", Price = 10m }, 1);
weakRef = new WeakReference(cart);
}
CreateAndUseCart();
// Act
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// Assert
weakRef.IsAlive.Should().BeFalse("Cart should be garbage collected");
}
3. Testing Database Transactions
public class OrderServiceIntegrationTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
[Fact]
public async Task PlaceOrder_FailureInPayment_RollsBackEntireTransaction()
{
// Arrange
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
var orderService = new OrderService(_fixture.ConnectionString);
var paymentService = new Mock<IPaymentService>();
paymentService
.Setup(x => x.ProcessPaymentAsync(It.IsAny<decimal>()))
.ThrowsAsync(new PaymentFailedException("Card declined"));
var order = new Order
{
Items = new[] { new OrderItem { ProductId = "1", Quantity = 2, Price = 50m } },
CustomerId = "CUST123"
};
// Act & Assert
await Assert.ThrowsAsync<PaymentFailedException>(
() => orderService.PlaceOrderAsync(order, paymentService.Object));
// Verify rollback - no order should exist in database
var savedOrder = await orderService.GetOrderAsync(order.Id);
savedOrder.Should().BeNull();
// Don't complete the transaction scope, ensuring rollback
}
}
4. Testing Performance Under Load
[Fact]
public async Task ShoppingCart_HighVolume_MaintainsPerformance()
{
// Arrange
var stopwatch = new Stopwatch();
var cart = new ShoppingCartService(_inventoryServiceMock.Object, _pricingServiceMock.Object);
// Setup mock to always return true for stock
_inventoryServiceMock
.Setup(x => x.IsInStock(It.IsAny<string>(), It.IsAny<int>()))
.Returns(true);
// Act - Add 1000 different products
stopwatch.Start();
for (int i = 0; i < 1000; i++)
{
var product = new Product
{
Id = i.ToString(),
Name = $"Product {i}",
Price = Random.Shared.Next(10, 1000)
};
cart.AddItem(product, Random.Shared.Next(1, 10));
}
stopwatch.Stop();
// Assert
cart.Items.Count.Should().Be(1000);
stopwatch.ElapsedMilliseconds.Should().BeLessThan(100,
"Adding 1000 items should complete within 100ms");
}
5. Testing Error Recovery
public class ResilientOrderService
{
private readonly ICircuitBreaker _circuitBreaker;
private readonly IRetryPolicy _retryPolicy;
public async Task<OrderResult> PlaceOrderWithRetryAsync(Order order)
{
return await _retryPolicy.ExecuteAsync(async () =>
{
try
{
return await _circuitBreaker.ExecuteAsync(async () =>
{
// Process order
return await ProcessOrderInternalAsync(order);
});
}
catch (CircuitBreakerOpenException)
{
// Fallback to queued processing
await QueueOrderForLaterProcessingAsync(order);
return new OrderResult { Status = OrderStatus.Queued };
}
});
}
}
[Fact]
public async Task PlaceOrder_TransientFailures_RetriesAndSucceeds()
{
// Arrange
var service = new ResilientOrderService(_circuitBreaker, _retryPolicy);
var callCount = 0;
_orderProcessorMock
.Setup(x => x.ProcessAsync(It.IsAny<Order>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount < 3)
throw new TransientException("Temporary failure");
return new OrderResult { Status = OrderStatus.Completed };
});
// Act
var result = await service.PlaceOrderWithRetryAsync(new Order());
// Assert
result.Status.Should().Be(OrderStatus.Completed);
callCount.Should().Be(3, "Should retry twice before succeeding");
}
Comprehensive Example: Building a Tested E-Commerce System
Let’s bring everything together with a complete e-commerce order processing system that demonstrates unit testing, BDD, and real-world patterns.
Domain Models
// Models/Order.cs
public class Order
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string CustomerId { get; set; } = string.Empty;
public List<OrderItem> Items { get; set; } = new();
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public decimal TotalAmount => Items.Sum(i => i.TotalPrice);
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public PaymentInfo? PaymentInfo { get; set; }
public ShippingInfo? ShippingInfo { get; set; }
}
public class OrderItem
{
public string ProductId { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice => Quantity * UnitPrice;
}
public enum OrderStatus
{
Pending,
PaymentProcessing,
PaymentFailed,
Paid,
Preparing,
Shipped,
Delivered,
Cancelled
}
Order Processing Service
public interface IOrderProcessor
{
Task<OrderResult> ProcessOrderAsync(Order order);
}
public class OrderProcessor : IOrderProcessor
{
private readonly IPaymentService _paymentService;
private readonly IInventoryService _inventoryService;
private readonly IShippingService _shippingService;
private readonly INotificationService _notificationService;
private readonly IOrderRepository _orderRepository;
private readonly ILogger<OrderProcessor> _logger;
public OrderProcessor(
IPaymentService paymentService,
IInventoryService inventoryService,
IShippingService shippingService,
INotificationService notificationService,
IOrderRepository orderRepository,
ILogger<OrderProcessor> logger)
{
_paymentService = paymentService;
_inventoryService = inventoryService;
_shippingService = shippingService;
_notificationService = notificationService;
_orderRepository = orderRepository;
_logger = logger;
}
public async Task<OrderResult> ProcessOrderAsync(Order order)
{
using var activity = Activity.StartActivity("ProcessOrder");
activity?.SetTag("order.id", order.Id);
activity?.SetTag("order.customer", order.CustomerId);
try
{
// Validate order
var validationResult = ValidateOrder(order);
if (!validationResult.IsValid)
{
_logger.LogWarning("Order validation failed: {Errors}",
string.Join(", ", validationResult.Errors));
return new OrderResult
{
Success = false,
Errors = validationResult.Errors
};
}
// Reserve inventory
var reservationResult = await ReserveInventoryAsync(order);
if (!reservationResult.Success)
{
return new OrderResult
{
Success = false,
Errors = new[] { "Insufficient inventory" }
};
}
try
{
// Process payment
order.Status = OrderStatus.PaymentProcessing;
await _orderRepository.UpdateAsync(order);
var paymentResult = await ProcessPaymentAsync(order);
if (!paymentResult.Success)
{
// Rollback inventory reservation
await ReleaseInventoryAsync(order);
order.Status = OrderStatus.PaymentFailed;
await _orderRepository.UpdateAsync(order);
return new OrderResult
{
Success = false,
Errors = new[] { "Payment processing failed" }
};
}
// Update order status
order.Status = OrderStatus.Paid;
order.PaymentInfo = paymentResult.PaymentInfo;
await _orderRepository.UpdateAsync(order);
// Create shipping label
var shippingResult = await _shippingService.CreateShippingLabelAsync(order);
order.ShippingInfo = shippingResult.ShippingInfo;
order.Status = OrderStatus.Preparing;
await _orderRepository.UpdateAsync(order);
// Send confirmation
await _notificationService.SendOrderConfirmationAsync(order);
_logger.LogInformation("Order {OrderId} processed successfully", order.Id);
return new OrderResult
{
Success = true,
Order = order
};
}
catch (Exception ex)
{
// Rollback on any failure
await ReleaseInventoryAsync(order);
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing order {OrderId}", order.Id);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
return new OrderResult
{
Success = false,
Errors = new[] { "An error occurred processing your order" }
};
}
}
private ValidationResult ValidateOrder(Order order)
{
var errors = new List<string>();
if (order.Items == null || !order.Items.Any())
errors.Add("Order must contain at least one item");
if (string.IsNullOrEmpty(order.CustomerId))
errors.Add("Customer ID is required");
if (order.TotalAmount <= 0)
errors.Add("Order total must be greater than zero");
foreach (var item in order.Items ?? Enumerable.Empty<OrderItem>())
{
if (item.Quantity <= 0)
errors.Add($"Invalid quantity for product {item.ProductId}");
if (item.UnitPrice <= 0)
errors.Add($"Invalid price for product {item.ProductId}");
}
return new ValidationResult
{
IsValid = !errors.Any(),
Errors = errors
};
}
private async Task<InventoryReservationResult> ReserveInventoryAsync(Order order)
{
var reservations = new List<(string ProductId, int Quantity)>();
foreach (var item in order.Items)
{
var reserved = await _inventoryService.TryReserveStockAsync(
item.ProductId,
item.Quantity);
if (!reserved)
{
// Rollback previous reservations
foreach (var (productId, quantity) in reservations)
{
await _inventoryService.ReleaseStockAsync(productId, quantity);
}
return new InventoryReservationResult { Success = false };
}
reservations.Add((item.ProductId, item.Quantity));
}
return new InventoryReservationResult
{
Success = true,
Reservations = reservations
};
}
private async Task ReleaseInventoryAsync(Order order)
{
foreach (var item in order.Items)
{
await _inventoryService.ReleaseStockAsync(item.ProductId, item.Quantity);
}
}
private async Task<PaymentResult> ProcessPaymentAsync(Order order)
{
return await _paymentService.ProcessPaymentAsync(new PaymentRequest
{
OrderId = order.Id,
Amount = order.TotalAmount,
CustomerId = order.CustomerId,
// Additional payment details...
});
}
}
Comprehensive Unit Tests
public class OrderProcessorTests
{
private readonly Mock<IPaymentService> _paymentServiceMock;
private readonly Mock<IInventoryService> _inventoryServiceMock;
private readonly Mock<IShippingService> _shippingServiceMock;
private readonly Mock<INotificationService> _notificationServiceMock;
private readonly Mock<IOrderRepository> _orderRepositoryMock;
private readonly Mock<ILogger<OrderProcessor>> _loggerMock;
private readonly OrderProcessor _sut;
public OrderProcessorTests()
{
_paymentServiceMock = new Mock<IPaymentService>();
_inventoryServiceMock = new Mock<IInventoryService>();
_shippingServiceMock = new Mock<IShippingService>();
_notificationServiceMock = new Mock<INotificationService>();
_orderRepositoryMock = new Mock<IOrderRepository>();
_loggerMock = new Mock<ILogger<OrderProcessor>>();
_sut = new OrderProcessor(
_paymentServiceMock.Object,
_inventoryServiceMock.Object,
_shippingServiceMock.Object,
_notificationServiceMock.Object,
_orderRepositoryMock.Object,
_loggerMock.Object);
}
[Fact]
public async Task ProcessOrder_ValidOrder_CompletesSuccessfully()
{
// Arrange
var order = CreateValidOrder();
SetupSuccessfulMocks();
// Act
var result = await _sut.ProcessOrderAsync(order);
// Assert
result.Success.Should().BeTrue();
result.Order.Should().NotBeNull();
result.Order!.Status.Should().Be(OrderStatus.Preparing);
// Verify all services were called
_inventoryServiceMock.Verify(x => x.TryReserveStockAsync(It.IsAny<string>(), It.IsAny<int>()),
Times.Exactly(order.Items.Count));
_paymentServiceMock.Verify(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()),
Times.Once);
_shippingServiceMock.Verify(x => x.CreateShippingLabelAsync(It.IsAny<Order>()),
Times.Once);
_notificationServiceMock.Verify(x => x.SendOrderConfirmationAsync(It.IsAny<Order>()),
Times.Once);
}
[Fact]
public async Task ProcessOrder_InsufficientInventory_FailsAndRollsBack()
{
// Arrange
var order = CreateValidOrder();
// First item succeeds, second fails
_inventoryServiceMock
.SetupSequence(x => x.TryReserveStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true)
.ReturnsAsync(false);
// Act
var result = await _sut.ProcessOrderAsync(order);
// Assert
result.Success.Should().BeFalse();
result.Errors.Should().Contain("Insufficient inventory");
// Verify rollback was called for the first item
_inventoryServiceMock.Verify(x => x.ReleaseStockAsync(order.Items[0].ProductId, order.Items[0].Quantity),
Times.Once);
// Verify payment was never attempted
_paymentServiceMock.Verify(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()),
Times.Never);
}
[Fact]
public async Task ProcessOrder_PaymentFails_RollsBackInventory()
{
// Arrange
var order = CreateValidOrder();
_inventoryServiceMock
.Setup(x => x.TryReserveStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);
_paymentServiceMock
.Setup(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()))
.ReturnsAsync(new PaymentResult { Success = false });
// Act
var result = await _sut.ProcessOrderAsync(order);
// Assert
result.Success.Should().BeFalse();
result.Errors.Should().Contain("Payment processing failed");
// Verify inventory was rolled back
foreach (var item in order.Items)
{
_inventoryServiceMock.Verify(x => x.ReleaseStockAsync(item.ProductId, item.Quantity),
Times.Once);
}
// Verify order status was updated to PaymentFailed
_orderRepositoryMock.Verify(x => x.UpdateAsync(It.Is<Order>(o => o.Status == OrderStatus.PaymentFailed)),
Times.Once);
}
[Theory]
[MemberData(nameof(InvalidOrders))]
public async Task ProcessOrder_InvalidOrder_ReturnsValidationErrors(Order invalidOrder, string expectedError)
{
// Act
var result = await _sut.ProcessOrderAsync(invalidOrder);
// Assert
result.Success.Should().BeFalse();
result.Errors.Should().Contain(error => error.Contains(expectedError));
// Verify no services were called
_inventoryServiceMock.Verify(x => x.TryReserveStockAsync(It.IsAny<string>(), It.IsAny<int>()),
Times.Never);
_paymentServiceMock.Verify(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()),
Times.Never);
}
public static IEnumerable<object[]> InvalidOrders()
{
yield return new object[]
{
new Order { CustomerId = "123", Items = new List<OrderItem>() },
"at least one item"
};
yield return new object[]
{
new Order { Items = new List<OrderItem> { new() { ProductId = "1", Quantity = 1 } } },
"Customer ID"
};
yield return new object[]
{
new Order
{
CustomerId = "123",
Items = new List<OrderItem>
{
new() { ProductId = "1", Quantity = -1, UnitPrice = 10 }
}
},
"Invalid quantity"
};
}
private Order CreateValidOrder()
{
return new Order
{
CustomerId = "CUST123",
Items = new List<OrderItem>
{
new() { ProductId = "PROD1", ProductName = "Laptop", Quantity = 1, UnitPrice = 999.99m },
new() { ProductId = "PROD2", ProductName = "Mouse", Quantity = 2, UnitPrice = 29.99m }
}
};
}
private void SetupSuccessfulMocks()
{
_inventoryServiceMock
.Setup(x => x.TryReserveStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);
_paymentServiceMock
.Setup(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()))
.ReturnsAsync(new PaymentResult
{
Success = true,
PaymentInfo = new PaymentInfo { TransactionId = "TXN123" }
});
_shippingServiceMock
.Setup(x => x.CreateShippingLabelAsync(It.IsAny<Order>()))
.ReturnsAsync(new ShippingResult
{
Success = true,
ShippingInfo = new ShippingInfo { TrackingNumber = "TRACK123" }
});
_orderRepositoryMock
.Setup(x => x.UpdateAsync(It.IsAny<Order>()))
.Returns(Task.CompletedTask);
_notificationServiceMock
.Setup(x => x.SendOrderConfirmationAsync(It.IsAny<Order>()))
.Returns(Task.CompletedTask);
}
}
BDD Feature for Order Processing
Feature: Order Processing
As an e-commerce system
I want to process customer orders
So that customers can purchase products
Background:
Given the following products are available:
| ProductId | Name | Price | Stock |
| LAPTOP01 | Gaming Laptop | 1299.99 | 10 |
| MOUSE01 | Gaming Mouse | 79.99 | 50 |
And the following customers exist:
| CustomerId | Name | PaymentMethod |
| CUST001 | John Smith | CreditCard |
| CUST002 | Jane Doe | PayPal |
Scenario: Successfully process an order with multiple items
Given customer "CUST001" has the following items in their cart:
| ProductId | Quantity |
| LAPTOP01 | 1 |
| MOUSE01 | 2 |
When the customer places the order
Then the order should be processed successfully
And the order status should be "Preparing"
And the inventory should be reduced by:
| ProductId | Reduction |
| LAPTOP01 | 1 |
| MOUSE01 | 2 |
And the customer should receive an order confirmation email
Scenario: Order fails due to insufficient inventory
Given customer "CUST001" has the following items in their cart:
| ProductId | Quantity |
| LAPTOP01 | 15 |
When the customer places the order
Then the order should fail with error "Insufficient inventory"
And no payment should be processed
And the inventory levels should remain unchanged
Scenario: Order fails due to payment decline
Given customer "CUST001" has the following items in their cart:
| ProductId | Quantity |
| LAPTOP01 | 1 |
And the customer's credit card will be declined
When the customer places the order
Then the order should fail with error "Payment processing failed"
And the order status should be "PaymentFailed"
And the inventory reservation should be released
Best Practices and Advanced Patterns
1. Test Organization Patterns
// Use nested classes for organizing related tests
public class ShoppingCartServiceTests
{
public class AddItemTests : ShoppingCartTestBase
{
[Fact]
public void WhenProductIsValid_AddsToCart() { }
[Fact]
public void WhenProductIsNull_ThrowsException() { }
}
public class RemoveItemTests : ShoppingCartTestBase
{
[Fact]
public void WhenItemExists_RemovesFromCart() { }
[Fact]
public void WhenItemDoesNotExist_DoesNothing() { }
}
}
2. Builder Pattern for Test Data
public class OrderBuilder
{
private readonly Order _order = new();
public OrderBuilder WithCustomer(string customerId)
{
_order.CustomerId = customerId;
return this;
}
public OrderBuilder WithItem(string productId, int quantity, decimal price)
{
_order.Items.Add(new OrderItem
{
ProductId = productId,
Quantity = quantity,
UnitPrice = price
});
return this;
}
public OrderBuilder WithStatus(OrderStatus status)
{
_order.Status = status;
return this;
}
public Order Build() => _order;
// Predefined scenarios
public static Order SimpleOrder => new OrderBuilder()
.WithCustomer("CUST123")
.WithItem("PROD1", 1, 99.99m)
.Build();
public static Order LargeOrder => new OrderBuilder()
.WithCustomer("CUST456")
.WithItem("PROD1", 5, 99.99m)
.WithItem("PROD2", 10, 49.99m)
.WithItem("PROD3", 3, 199.99m)
.Build();
}
// Usage in tests
[Fact]
public void ProcessOrder_SimpleOrder_Succeeds()
{
var order = OrderBuilder.SimpleOrder;
// ... test implementation
}
3. Custom Assertions
public static class OrderAssertions
{
public static void ShouldBeSuccessful(this OrderResult result)
{
result.Success.Should().BeTrue();
result.Errors.Should().BeNullOrEmpty();
result.Order.Should().NotBeNull();
}
public static void ShouldFailWithError(this OrderResult result, string expectedError)
{
result.Success.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains(expectedError));
}
public static void ShouldHaveStatus(this Order order, OrderStatus expectedStatus)
{
order.Status.Should().Be(expectedStatus);
}
}
// Usage
[Fact]
public void ProcessOrder_ValidOrder_Succeeds()
{
// Act
var result = await _orderProcessor.ProcessOrderAsync(order);
// Assert
result.ShouldBeSuccessful();
result.Order.ShouldHaveStatus(OrderStatus.Preparing);
}
4. Testing Time-Dependent Code
public interface ITimeProvider
{
DateTime UtcNow { get; }
}
public class SystemTimeProvider : ITimeProvider
{
public DateTime UtcNow => DateTime.UtcNow;
}
public class TestTimeProvider : ITimeProvider
{
public DateTime UtcNow { get; set; } = DateTime.UtcNow;
public void AdvanceBy(TimeSpan timeSpan)
{
UtcNow = UtcNow.Add(timeSpan);
}
}
// In production code
public class OrderExpirationService
{
private readonly ITimeProvider _timeProvider;
public OrderExpirationService(ITimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool IsOrderExpired(Order order)
{
var expirationTime = order.CreatedAt.AddHours(24);
return _timeProvider.UtcNow > expirationTime;
}
}
// In tests
[Fact]
public void IsOrderExpired_After24Hours_ReturnsTrue()
{
// Arrange
var timeProvider = new TestTimeProvider();
var service = new OrderExpirationService(timeProvider);
var order = new Order { CreatedAt = timeProvider.UtcNow };
// Act
timeProvider.AdvanceBy(TimeSpan.FromHours(25));
var isExpired = service.IsOrderExpired(order);
// Assert
isExpired.Should().BeTrue();
}
5. Integration Test Base Class
public abstract class IntegrationTestBase : IAsyncLifetime
{
protected IServiceProvider ServiceProvider { get; private set; } = null!;
protected IConfiguration Configuration { get; private set; } = null!;
public async Task InitializeAsync()
{
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.test.json", optional: false)
.AddEnvironmentVariables();
Configuration = builder.Build();
var services = new ServiceCollection();
ConfigureServices(services);
ServiceProvider = services.BuildServiceProvider();
await InitializeTestDataAsync();
}
protected virtual void ConfigureServices(IServiceCollection services)
{
// Add common services
services.AddLogging();
services.AddAutoMapper(typeof(MappingProfile));
// Add test-specific implementations
services.AddSingleton<ITimeProvider, TestTimeProvider>();
}
protected virtual Task InitializeTestDataAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await CleanupTestDataAsync();
if (ServiceProvider is IDisposable disposable)
{
disposable.Dispose();
}
}
protected virtual Task CleanupTestDataAsync() => Task.CompletedTask;
protected T GetService<T>() where T : notnull
{
return ServiceProvider.GetRequiredService<T>();
}
}
Conclusion and Next Steps
We’ve covered a comprehensive approach to testing in .NET 9, from unit testing with xUnit to behavior-driven development with SpecFlow. The key takeaways are:
-
Testing is an Investment: Every test you write is an investment in your code’s future maintainability and reliability
-
Start with the Critical Path: Focus your testing efforts on the most important business logic first
-
Code Coverage is a Tool, Not a Goal: Use coverage metrics to find gaps, but don’t chase 100% coverage at the expense of test quality
-
BDD Bridges the Gap: SpecFlow helps ensure your code meets business requirements by making tests readable to all stakeholders
-
Patterns and Practices Matter: Using established patterns like builders, custom assertions, and proper test organization makes your tests more maintainable
Next Steps for Your Testing Journey
- Implement Mutation Testing: Tools like Stryker.NET can help ensure your tests actually catch bugs
- Explore Property-Based Testing: FsCheck can generate test cases you might not think of
- Add Performance Testing: NBomber or k6 can help ensure your system performs under load
- Implement Contract Testing: Pact.NET ensures your APIs maintain their contracts
- Set Up Continuous Testing: Integrate your tests into CI/CD pipelines for immediate feedback
Remember, the goal isn’t to write tests for the sake of testing – it’s to build confidence in your code, enable fearless refactoring, and ultimately deliver better software to your users. Start small, be consistent, and gradually build a comprehensive test suite that serves as both a safety net and living documentation for your system.
Happy testing, and may your builds always be green! 🚀