
Previous article: ASP.NET Core Security Fortification: Master OWASP Best Practices & Threat Protection (Part - 28 of 40)
📚 Table of Contents
The Testing Pyramid Foundation
Unit Testing Mastery
Integration Testing Strategies
UI Testing with Selenium
Test-Driven Development
Mocking and Test Doubles
Testing ASP.NET Core APIs
Database Testing Approaches
Performance and Load Testing
CI/CD Testing Pipeline
1. The Testing Pyramid Foundation
1.1 Why Testing Matters in Real-World Applications
Real-Life Scenario: Imagine an e-commerce application where a bug in the shopping cart calculation causes customers to be overcharged by 10%. Without proper testing, this could go undetected for weeks, resulting in financial losses and damaged reputation.
// Buggy implementation without tests
public class ShoppingCartService
{
public decimal CalculateTotal(List<CartItem> items, string discountCode)
{
decimal total = items.Sum(item => item.Price * item.Quantity);
// BUG: This should be subtraction, not addition
if (!string.IsNullOrEmpty(discountCode))
{
total += 10.0m; // Should be total -= 10.0m
}
return total;
}
}
// Test that would catch this bug
public class ShoppingCartServiceTests
{
[Fact]
public void CalculateTotal_WithValidDiscountCode_AppliesDiscountCorrectly()
{
// Arrange
var service = new ShoppingCartService();
var items = new List<CartItem>
{
new CartItem { Price = 100.0m, Quantity = 1 }
};
// Act
var result = service.CalculateTotal(items, "SAVE10");
// Assert
Assert.Equal(90.0m, result); // This test would FAIL, catching the bug
}
}
1.2 The Testing Pyramid Explained
The testing pyramid is a strategic approach to testing that emphasizes having many fast, inexpensive unit tests, fewer integration tests, and even fewer UI tests.
// Testing Pyramid Structure
public class TestingPyramid
{
// Layer 1: Unit Tests (70%)
public void UnitTests()
{
// Fast, isolated, test individual components
// Run in milliseconds, no external dependencies
}
// Layer 2: Integration Tests (20%)
public void IntegrationTests()
{
// Test interactions between components
// May involve databases, file systems, APIs
// Run in seconds
}
// Layer 3: UI Tests (10%)
public void UITests()
{
// Test complete user workflows
// Slow, fragile, but essential for critical paths
// Run in minutes
}
}
1.3 Setting Up Your Testing Environment
// Sample project structure
/*
MyApp/
├── src/
│ ├── MyApp.Web/ # ASP.NET Core Web Application
│ ├── MyApp.Services/ # Business Logic Layer
│ └── MyApp.Data/ # Data Access Layer
└── tests/
├── MyApp.Web.Tests/ # Integration Tests
├── MyApp.Services.Tests/ # Unit Tests
├── MyApp.Data.Tests/ # Database Tests
└── MyApp.UI.Tests/ # UI Tests
*/
// MyApp.Services.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Moq" Version="4.20.69" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyApp.Services\MyApp.Services.csproj" />
</ItemGroup>
</Project>
2. Unit Testing Mastery
2.1 xUnit Fundamentals
xUnit is a popular testing framework for .NET that provides a clean, extensible platform for writing tests.
// Basic xUnit test structure
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
int a = 5, b = 3;
// Act
var result = calculator.Add(a, b);
// Assert
Assert.Equal(8, result);
}
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_MultipleScenarios_ReturnsCorrectSum(int a, int b, int expected)
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
}
// Calculator implementation
public class Calculator
{
public int Add(int a, int b) => a + b;
public int Subtract(int a, int b) => a - b;
public int Multiply(int a, int b) => a * b;
public int Divide(int a, int b) => b == 0 ? throw new DivideByZeroException() : a / b;
}
2.2 Real-World Business Logic Testing
Scenario: Testing a loan approval system for a banking application
public class LoanApplication
{
public string ApplicantName { get; set; } = string.Empty;
public decimal AnnualIncome { get; set; }
public int CreditScore { get; set; }
public decimal LoanAmount { get; set; }
public int LoanTermMonths { get; set; }
public EmploymentStatus EmploymentStatus { get; set; }
public bool HasDefaultedBefore { get; set; }
}
public enum EmploymentStatus
{
Unemployed,
PartTime,
FullTime,
SelfEmployed
}
public class LoanApprovalService
{
private const int MINIMUM_CREDIT_SCORE = 650;
private const decimal MINIMUM_INCOME_RATIO = 0.25m;
public LoanApprovalResult ProcessApplication(LoanApplication application)
{
if (application == null)
throw new ArgumentNullException(nameof(application));
var validationErrors = ValidateApplication(application);
if (validationErrors.Any())
return LoanApprovalResult.Rejected(validationErrors);
var isApproved = IsEligibleForLoan(application);
var interestRate = CalculateInterestRate(application);
return isApproved
? LoanApprovalResult.Approved(interestRate, application.LoanAmount)
: LoanApprovalResult.Rejected(new[] { "Application does not meet criteria" });
}
private List<string> ValidateApplication(LoanApplication application)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(application.ApplicantName))
errors.Add("Applicant name is required");
if (application.AnnualIncome <= 0)
errors.Add("Annual income must be positive");
if (application.LoanAmount <= 0)
errors.Add("Loan amount must be positive");
if (application.LoanTermMonths <= 0)
errors.Add("Loan term must be positive");
if (application.CreditScore < 300 || application.CreditScore > 850)
errors.Add("Credit score must be between 300 and 850");
return errors;
}
private bool IsEligibleForLoan(LoanApplication application)
{
// Basic eligibility criteria
if (application.CreditScore < MINIMUM_CREDIT_SCORE)
return false;
if (application.HasDefaultedBefore)
return false;
if (application.EmploymentStatus == EmploymentStatus.Unemployed)
return false;
// Income verification
decimal monthlyIncome = application.AnnualIncome / 12;
decimal monthlyPayment = CalculateMonthlyPayment(
application.LoanAmount,
CalculateInterestRate(application),
application.LoanTermMonths);
return monthlyPayment <= monthlyIncome * MINIMUM_INCOME_RATIO;
}
private decimal CalculateInterestRate(LoanApplication application)
{
decimal baseRate = 0.05m; // 5% base rate
// Adjust based on credit score
if (application.CreditScore >= 800)
baseRate -= 0.02m; // Excellent credit
else if (application.CreditScore >= 700)
baseRate -= 0.01m; // Good credit
else if (application.CreditScore < 600)
baseRate += 0.03m; // Poor credit
// Adjust based on employment
if (application.EmploymentStatus == EmploymentStatus.SelfEmployed)
baseRate += 0.01m;
return Math.Max(0.01m, baseRate); // Minimum 1% interest
}
private decimal CalculateMonthlyPayment(decimal principal, decimal annualRate, int termMonths)
{
decimal monthlyRate = annualRate / 12;
decimal factor = (decimal)Math.Pow(1 + (double)monthlyRate, termMonths);
return principal * monthlyRate * factor / (factor - 1);
}
}
public class LoanApprovalResult
{
public bool IsApproved { get; }
public decimal? InterestRate { get; }
public decimal? ApprovedAmount { get; }
public IReadOnlyList<string> RejectionReasons { get; }
private LoanApprovalResult(bool isApproved, decimal? interestRate,
decimal? approvedAmount, List<string> rejectionReasons)
{
IsApproved = isApproved;
InterestRate = interestRate;
ApprovedAmount = approvedAmount;
RejectionReasons = rejectionReasons?.AsReadOnly() ?? new List<string>().AsReadOnly();
}
public static LoanApprovalResult Approved(decimal interestRate, decimal approvedAmount) =>
new LoanApprovalResult(true, interestRate, approvedAmount, null);
public static LoanApprovalResult Rejected(IEnumerable<string> reasons) =>
new LoanApprovalResult(false, null, null, reasons?.ToList() ?? new List<string>());
}
2.3 Comprehensive Unit Tests for Loan Service
public class LoanApprovalServiceTests
{
private readonly LoanApprovalService _service;
public LoanApprovalServiceTests()
{
_service = new LoanApprovalService();
}
[Fact]
public void ProcessApplication_NullApplication_ThrowsArgumentNullException()
{
// Arrange
LoanApplication application = null;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => _service.ProcessApplication(application));
}
[Theory]
[InlineData("", 50000, 700, 10000, 24, EmploymentStatus.FullTime, false, "Applicant name is required")]
[InlineData("John Doe", 0, 700, 10000, 24, EmploymentStatus.FullTime, false, "Annual income must be positive")]
[InlineData("John Doe", 50000, 700, 0, 24, EmploymentStatus.FullTime, false, "Loan amount must be positive")]
[InlineData("John Doe", 50000, 700, 10000, 0, EmploymentStatus.FullTime, false, "Loan term must be positive")]
[InlineData("John Doe", 50000, 200, 10000, 24, EmploymentStatus.FullTime, false, "Credit score must be between 300 and 850")]
public void ProcessApplication_InvalidApplication_ReturnsRejectedWithErrors(
string name, decimal income, int creditScore, decimal loanAmount,
int term, EmploymentStatus employment, bool hasDefaulted, string expectedError)
{
// Arrange
var application = new LoanApplication
{
ApplicantName = name,
AnnualIncome = income,
CreditScore = creditScore,
LoanAmount = loanAmount,
LoanTermMonths = term,
EmploymentStatus = employment,
HasDefaultedBefore = hasDefaulted
};
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.False(result.IsApproved);
Assert.Contains(expectedError, result.RejectionReasons);
}
[Fact]
public void ProcessApplication_ExcellentCredit_ReturnsApprovedWithLowInterest()
{
// Arrange
var application = new LoanApplication
{
ApplicantName = "Jane Smith",
AnnualIncome = 100000,
CreditScore = 810, // Excellent credit
LoanAmount = 20000,
LoanTermMonths = 36,
EmploymentStatus = EmploymentStatus.FullTime,
HasDefaultedBefore = false
};
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.True(result.IsApproved);
Assert.NotNull(result.InterestRate);
Assert.True(result.InterestRate <= 0.03m); // Should get low interest rate
Assert.Equal(20000, result.ApprovedAmount);
}
[Fact]
public void ProcessApplication_PoorCredit_ReturnsRejected()
{
// Arrange
var application = new LoanApplication
{
ApplicantName = "Bob Wilson",
AnnualIncome = 50000,
CreditScore = 580, // Poor credit
LoanAmount = 10000,
LoanTermMonths = 24,
EmploymentStatus = EmploymentStatus.FullTime,
HasDefaultedBefore = false
};
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.False(result.IsApproved);
Assert.Contains("Application does not meet criteria", result.RejectionReasons);
}
[Fact]
public void ProcessApplication_PreviousDefault_ReturnsRejected()
{
// Arrange
var application = new LoanApplication
{
ApplicantName = "Sarah Johnson",
AnnualIncome = 80000,
CreditScore = 720, // Good credit
LoanAmount = 15000,
LoanTermMonths = 36,
EmploymentStatus = EmploymentStatus.FullTime,
HasDefaultedBefore = true // Previous default
};
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.False(result.IsApproved);
}
[Fact]
public void ProcessApplication_SelfEmployed_ReturnsApprovedWithHigherInterest()
{
// Arrange
var application = new LoanApplication
{
ApplicantName = "Mike Entrepreneur",
AnnualIncome = 120000,
CreditScore = 750, // Good credit
LoanAmount = 30000,
LoanTermMonths = 48,
EmploymentStatus = EmploymentStatus.SelfEmployed,
HasDefaultedBefore = false
};
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.True(result.IsApproved);
Assert.True(result.InterestRate > 0.04m); // Should have higher rate for self-employed
}
}
2.4 Advanced Unit Testing Patterns
// Test data builders for complex objects
public class LoanApplicationBuilder
{
private LoanApplication _application = new LoanApplication
{
ApplicantName = "Test Applicant",
AnnualIncome = 75000,
CreditScore = 700,
LoanAmount = 25000,
LoanTermMonths = 36,
EmploymentStatus = EmploymentStatus.FullTime,
HasDefaultedBefore = false
};
public LoanApplicationBuilder WithCreditScore(int score)
{
_application.CreditScore = score;
return this;
}
public LoanApplicationBuilder WithIncome(decimal income)
{
_application.AnnualIncome = income;
return this;
}
public LoanApplicationBuilder WithEmployment(EmploymentStatus status)
{
_application.EmploymentStatus = status;
return this;
}
public LoanApplicationBuilder HasDefaulted(bool hasDefaulted = true)
{
_application.HasDefaultedBefore = hasDefaulted;
return this;
}
public LoanApplication Build() => _application;
}
// Using the builder in tests
public class LoanApprovalServiceBuilderTests
{
private readonly LoanApprovalService _service = new LoanApprovalService();
[Fact]
public void ProcessApplication_UsingBuilder_CreatesConsistentTestData()
{
// Arrange
var application = new LoanApplicationBuilder()
.WithCreditScore(780)
.WithIncome(90000)
.WithEmployment(EmploymentStatus.FullTime)
.Build();
// Act
var result = _service.ProcessApplication(application);
// Assert
Assert.True(result.IsApproved);
}
}
// Custom assertions for better test readability
public static class LoanApprovalResultAssertions
{
public static void ShouldBeApproved(this LoanApprovalResult result, decimal? expectedAmount = null)
{
Assert.True(result.IsApproved, "Loan should be approved");
if (expectedAmount.HasValue)
{
Assert.Equal(expectedAmount.Value, result.ApprovedAmount);
}
}
public static void ShouldBeRejected(this LoanApprovalResult result, string expectedReason = null)
{
Assert.False(result.IsApproved, "Loan should be rejected");
if (!string.IsNullOrEmpty(expectedReason))
{
Assert.Contains(expectedReason, result.RejectionReasons);
}
}
}
// Using custom assertions
[Fact]
public void ProcessApplication_WithCustomAssertions_ReadableTests()
{
// Arrange
var application = new LoanApplicationBuilder().Build();
// Act
var result = _service.ProcessApplication(application);
// Assert
result.ShouldBeApproved(25000);
}
3. Integration Testing Strategies
3.1 Testing with Real Databases
Scenario: Testing a product catalog service that interacts with a database
public class ProductService
{
private readonly ApplicationDbContext _context;
public ProductService(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product> CreateProductAsync(string name, string description, decimal price, int stockQuantity)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Product name is required", nameof(name));
if (price <= 0)
throw new ArgumentException("Price must be positive", nameof(price));
var product = new Product
{
Name = name,
Description = description,
Price = price,
StockQuantity = stockQuantity,
CreatedAt = DateTime.UtcNow,
IsActive = true
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
public async Task<List<Product>> SearchProductsAsync(string searchTerm, decimal? minPrice = null,
decimal? maxPrice = null, bool inStockOnly = false)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(p => p.Name.Contains(searchTerm) ||
p.Description.Contains(searchTerm));
}
if (minPrice.HasValue)
{
query = query.Where(p => p.Price >= minPrice.Value);
}
if (maxPrice.HasValue)
{
query = query.Where(p => p.Price <= maxPrice.Value);
}
if (inStockOnly)
{
query = query.Where(p => p.StockQuantity > 0);
}
return await query.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.ToListAsync();
}
public async Task UpdateStockAsync(int productId, int quantityChange)
{
var product = await _context.Products.FindAsync(productId);
if (product == null)
throw new ArgumentException($"Product with ID {productId} not found");
product.StockQuantity += quantityChange;
if (product.StockQuantity < 0)
throw new InvalidOperationException("Insufficient stock");
await _context.SaveChangesAsync();
}
}
// Integration tests using TestContainers for database testing
public class ProductServiceIntegrationTests : IAsyncLifetime
{
private readonly TestcontainerDatabase _database;
private ApplicationDbContext _context;
private ProductService _service;
public ProductServiceIntegrationTests()
{
// Use TestContainers to spin up a real database for testing
_database = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "testdb",
Username = "test",
Password = "test"
})
.Build();
}
public async Task InitializeAsync()
{
await _database.StartAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(_database.ConnectionString)
.Options;
_context = new ApplicationDbContext(options);
await _context.Database.EnsureCreatedAsync();
_service = new ProductService(_context);
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
await _database.DisposeAsync();
}
[Fact]
public async Task CreateProductAsync_ValidProduct_CreatesAndReturnsProduct()
{
// Arrange
var name = "Test Product";
var description = "Test Description";
var price = 29.99m;
var stock = 100;
// Act
var result = await _service.CreateProductAsync(name, description, price, stock);
// Assert
Assert.NotNull(result);
Assert.Equal(name, result.Name);
Assert.Equal(description, result.Description);
Assert.Equal(price, result.Price);
Assert.Equal(stock, result.StockQuantity);
Assert.True(result.IsActive);
// Verify in database
var fromDb = await _context.Products.FindAsync(result.Id);
Assert.NotNull(fromDb);
Assert.Equal(name, fromDb.Name);
}
[Fact]
public async Task SearchProductsAsync_WithFilters_ReturnsMatchingProducts()
{
// Arrange - Add test data
var products = new[]
{
new Product { Name = "Laptop", Description = "Gaming laptop", Price = 999.99m, StockQuantity = 10 },
new Product { Name = "Mouse", Description = "Wireless mouse", Price = 29.99m, StockQuantity = 0 },
new Product { Name = "Keyboard", Description = "Mechanical keyboard", Price = 79.99m, StockQuantity = 5 }
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
// Act
var results = await _service.SearchProductsAsync("lap", minPrice: 500m, inStockOnly: true);
// Assert
Assert.Single(results);
Assert.Equal("Laptop", results[0].Name);
}
[Fact]
public async Task UpdateStockAsync_ValidChange_UpdatesStockQuantity()
{
// Arrange
var product = new Product { Name = "Test", Price = 10m, StockQuantity = 50 };
_context.Products.Add(product);
await _context.SaveChangesAsync();
// Act
await _service.UpdateStockAsync(product.Id, -10);
// Assert
var updated = await _context.Products.FindAsync(product.Id);
Assert.Equal(40, updated.StockQuantity);
}
[Fact]
public async Task UpdateStockAsync_InsufficientStock_ThrowsException()
{
// Arrange
var product = new Product { Name = "Test", Price = 10m, StockQuantity = 5 };
_context.Products.Add(product);
await _context.SaveChangesAsync();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.UpdateStockAsync(product.Id, -10));
}
}
3.2 Testing Web APIs
// API Controller
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductService productService, ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<List<ProductDto>>> GetProducts([FromQuery] ProductSearchRequest request)
{
try
{
var products = await _productService.SearchProductsAsync(
request.SearchTerm,
request.MinPrice,
request.MaxPrice,
request.InStockOnly);
var dtos = products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
StockQuantity = p.StockQuantity
}).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving products");
return StatusCode(500, "An error occurred while retrieving products");
}
}
[HttpPost]
public async Task<ActionResult<ProductDto>> CreateProduct(CreateProductRequest request)
{
try
{
var product = await _productService.CreateProductAsync(
request.Name,
request.Description,
request.Price,
request.StockQuantity);
var dto = new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity
};
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, dto);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product");
return StatusCode(500, "An error occurred while creating the product");
}
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetProduct(int id)
{
var product = await _productService.GetProductByIdAsync(id);
if (product == null)
return NotFound();
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity
};
}
}
// Integration tests for API controller
public class ProductsControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ProductsControllerIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Replace real services with test doubles if needed
services.AddScoped<IProductService, MockProductService>();
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsSuccessWithProducts()
{
// Act
var response = await _client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var products = JsonSerializer.Deserialize<List<ProductDto>>(content,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(products);
}
[Fact]
public async Task CreateProduct_ValidRequest_ReturnsCreatedProduct()
{
// Arrange
var request = new CreateProductRequest
{
Name = "Integration Test Product",
Description = "Test Description",
Price = 19.99m,
StockQuantity = 50
};
var content = new StringContent(
JsonSerializer.Serialize(request),
Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync("/api/products", content);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
var product = JsonSerializer.Deserialize<ProductDto>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(product);
Assert.Equal(request.Name, product.Name);
Assert.Equal(request.Price, product.Price);
}
[Fact]
public async Task GetProduct_NonExistentId_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync("/api/products/9999");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
4. UI Testing with Selenium
4.1 Selenium WebDriver Setup
public class WebUITests : IAsyncLifetime
{
private IWebDriver _driver;
private WebDriverWait _wait;
public async Task InitializeAsync()
{
// Setup Chrome options
var options = new ChromeOptions();
options.AddArgument("--headless"); // Run in headless mode for CI
options.AddArgument("--no-sandbox");
options.AddArgument("--disable-dev-shm-usage");
_driver = new ChromeDriver(options);
_wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
_driver?.Quit();
_driver?.Dispose();
await Task.CompletedTask;
}
[Fact]
public void HomePage_LoadsSuccessfully_DisplaysWelcomeMessage()
{
// Arrange
_driver.Navigate().GoToUrl("https://localhost:5001");
// Act
var welcomeElement = _wait.Until(d =>
d.FindElement(By.CssSelector(".welcome-message")));
// Assert
Assert.Contains("Welcome", welcomeElement.Text);
}
[Fact]
public void ProductSearch_FindsMatchingProducts_DisplaysResults()
{
// Arrange
_driver.Navigate().GoToUrl("https://localhost:5001/products");
// Act
var searchBox = _driver.FindElement(By.Id("search-box"));
searchBox.SendKeys("laptop");
searchBox.SendKeys(Keys.Enter);
// Wait for results
var resultsContainer = _wait.Until(d =>
d.FindElement(By.CssSelector(".search-results")));
var productCards = resultsContainer.FindElements(By.CssSelector(".product-card"));
// Assert
Assert.True(productCards.Count > 0);
// Verify at least one product contains "laptop" in name
var hasLaptop = productCards.Any(card =>
card.FindElement(By.CssSelector(".product-name"))
.Text.ToLower().Contains("laptop"));
Assert.True(hasLaptop);
}
[Fact]
public void AddToCart_ValidProduct_UpdatesCartCounter()
{
// Arrange
_driver.Navigate().GoToUrl("https://localhost:5001/products/1");
// Act
var addToCartButton = _wait.Until(d =>
d.FindElement(By.CssSelector(".add-to-cart-btn")));
addToCartButton.Click();
// Wait for cart update
var cartCounter = _wait.Until(d =>
d.FindElement(By.CssSelector(".cart-counter")));
// Assert
Assert.Equal("1", cartCounter.Text);
}
[Fact]
public void CheckoutProcess_CompleteFlow_ShowsConfirmation()
{
// Arrange - Add product to cart first
_driver.Navigate().GoToUrl("https://localhost:5001/products/1");
var addToCartButton = _wait.Until(d =>
d.FindElement(By.CssSelector(".add-to-cart-btn")));
addToCartButton.Click();
// Act - Navigate to checkout
var checkoutButton = _wait.Until(d =>
d.FindElement(By.CssSelector(".checkout-btn")));
checkoutButton.Click();
// Fill checkout form
_wait.Until(d => d.FindElement(By.Id("email"))).SendKeys("[email protected]");
_driver.FindElement(By.Id("firstName")).SendKeys("John");
_driver.FindElement(By.Id("lastName")).SendKeys("Doe");
_driver.FindElement(By.Id("address")).SendKeys("123 Main St");
_driver.FindElement(By.Id("city")).SendKeys("Test City");
_driver.FindElement(By.Id("zipCode")).SendKeys("12345");
// Submit order
var submitButton = _driver.FindElement(By.CssSelector(".submit-order-btn"));
submitButton.Click();
// Assert
var confirmation = _wait.Until(d =>
d.FindElement(By.CssSelector(".order-confirmation")));
Assert.Contains("Thank you for your order", confirmation.Text);
}
}
4.2 Page Object Pattern for Maintainable UI Tests
// Base page class
public abstract class BasePage
{
protected IWebDriver Driver;
protected WebDriverWait Wait;
protected BasePage(IWebDriver driver)
{
Driver = driver;
Wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
}
protected IWebElement FindElement(By locator) => Wait.Until(d => d.FindElement(locator));
protected IReadOnlyCollection<IWebElement> FindElements(By locator) => Driver.FindElements(locator);
protected void Click(By locator) => FindElement(locator).Click();
protected void Type(By locator, string text) => FindElement(locator).SendKeys(text);
}
// Home page
public class HomePage : BasePage
{
private By WelcomeMessage => By.CssSelector(".welcome-message");
private By ProductSearchBox => By.Id("search-box");
private By SearchButton => By.CssSelector(".search-btn");
private By CartCounter => By.CssSelector(".cart-counter");
private By NavigationMenu => By.CssSelector(".nav-menu");
public HomePage(IWebDriver driver) : base(driver) { }
public void NavigateTo() => Driver.Navigate().GoToUrl("https://localhost:5001");
public string GetWelcomeMessage() => FindElement(WelcomeMessage).Text;
public string GetCartCount() => FindElement(CartCounter).Text;
public SearchResultsPage SearchForProduct(string searchTerm)
{
Type(ProductSearchBox, searchTerm);
Click(SearchButton);
return new SearchResultsPage(Driver);
}
public ProductPage NavigateToProduct(int productId)
{
Driver.Navigate().GoToUrl($"https://localhost:5001/products/{productId}");
return new ProductPage(Driver);
}
}
// Search results page
public class SearchResultsPage : BasePage
{
private By ResultsContainer => By.CssSelector(".search-results");
private By ProductCards => By.CssSelector(".product-card");
private By ProductNames => By.CssSelector(".product-name");
private By NoResultsMessage => By.CssSelector(".no-results");
public SearchResultsPage(IWebDriver driver) : base(driver) { }
public int GetResultCount() => FindElements(ProductCards).Count;
public bool HasResults() => GetResultCount() > 0;
public string GetNoResultsMessage() => FindElement(NoResultsMessage).Text;
public List<string> GetProductNames() =>
FindElements(ProductNames).Select(e => e.Text).ToList();
public ProductPage ClickProduct(int index)
{
var products = FindElements(ProductCards);
if (index < products.Count)
{
products.ElementAt(index).Click();
return new ProductPage(Driver);
}
throw new IndexOutOfRangeException($"Product index {index} out of range");
}
}
// Product page
public class ProductPage : BasePage
{
private By ProductName => By.CssSelector(".product-name");
private By ProductPrice => By.CssSelector(".product-price");
private By AddToCartButton => By.CssSelector(".add-to-cart-btn");
private By CartCounter => By.CssSelector(".cart-counter");
private By StockStatus => By.CssSelector(".stock-status");
public ProductPage(IWebDriver driver) : base(driver) { }
public string GetProductName() => FindElement(ProductName).Text;
public string GetProductPrice() => FindElement(ProductPrice).Text;
public string GetStockStatus() => FindElement(StockStatus).Text;
public void AddToCart()
{
Click(AddToCartButton);
// Wait for cart to update
Wait.Until(d => FindElement(CartCounter).Text != "0");
}
}
// Refactored tests using page objects
public class WebUIPageObjectTests : IAsyncLifetime
{
private IWebDriver _driver;
private HomePage _homePage;
public async Task InitializeAsync()
{
var options = new ChromeOptions();
options.AddArgument("--headless");
_driver = new ChromeDriver(options);
_homePage = new HomePage(_driver);
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
_driver?.Quit();
_driver?.Dispose();
await Task.CompletedTask;
}
[Fact]
public void ProductSearch_UsingPageObjects_FindsMatchingProducts()
{
// Arrange
_homePage.NavigateTo();
// Act
var resultsPage = _homePage.SearchForProduct("laptop");
// Assert
Assert.True(resultsPage.HasResults());
var productNames = resultsPage.GetProductNames();
Assert.Contains(productNames, name => name.ToLower().Contains("laptop"));
}
[Fact]
public void AddToCart_UsingPageObjects_UpdatesCartCounter()
{
// Arrange
_homePage.NavigateTo();
var productPage = _homePage.NavigateToProduct(1);
// Act
productPage.AddToCart();
// Assert
Assert.Equal("1", _homePage.GetCartCount());
}
[Fact]
public void CompletePurchaseFlow_UsingPageObjects_CompletesSuccessfully()
{
// This would continue with checkout page objects...
// Demonstrating the maintainability of page object pattern
}
}
5. Test-Driven Development
5.1 TDD Workflow: Red-Green-Refactor
Real-World Scenario: Developing a payment processing service using TDD
// Step 1: Write a failing test (RED)
public class PaymentServiceTests
{
[Fact]
public void ProcessPayment_ValidCreditCard_ReturnsSuccess()
{
// Arrange
var service = new PaymentService();
var request = new PaymentRequest
{
Amount = 100.00m,
CreditCardNumber = "4111111111111111",
ExpiryMonth = 12,
ExpiryYear = 2025,
CVV = "123"
};
// Act
var result = service.ProcessPayment(request);
// Assert
Assert.True(result.IsSuccessful);
Assert.NotNull(result.TransactionId);
}
}
// Step 2: Implement minimum code to pass test (GREEN)
public class PaymentService
{
public PaymentResult ProcessPayment(PaymentRequest request)
{
// Minimal implementation to pass the test
return new PaymentResult
{
IsSuccessful = true,
TransactionId = Guid.NewGuid().ToString()
};
}
}
// Step 3: Refactor and add more tests
[Fact]
public void ProcessPayment_InvalidCreditCard_ReturnsFailure()
{
// Arrange
var service = new PaymentService();
var request = new PaymentRequest
{
Amount = 100.00m,
CreditCardNumber = "1234", // Invalid
ExpiryMonth = 12,
ExpiryYear = 2025,
CVV = "123"
};
// Act
var result = service.ProcessPayment(request);
// Assert
Assert.False(result.IsSuccessful);
Assert.Contains("Invalid credit card", result.ErrorMessage);
}
5.2 Comprehensive TDD Example: Shopping Cart
// Shopping cart TDD development
public class ShoppingCartTests
{
[Fact]
public void NewCart_IsEmpty()
{
// Arrange & Act
var cart = new ShoppingCart();
// Assert
Assert.Empty(cart.Items);
Assert.Equal(0, cart.TotalItems);
Assert.Equal(0m, cart.TotalPrice);
}
[Fact]
public void AddItem_ValidItem_AddsToCart()
{
// Arrange
var cart = new ShoppingCart();
var product = new Product { Id = 1, Name = "Test", Price = 10.0m };
// Act
cart.AddItem(product, 2);
// Assert
Assert.Single(cart.Items);
Assert.Equal(2, cart.TotalItems);
Assert.Equal(20.0m, cart.TotalPrice);
}
[Fact]
public void AddItem_ExistingProduct_UpdatesQuantity()
{
// Arrange
var cart = new ShoppingCart();
var product = new Product { Id = 1, Name = "Test", Price = 10.0m };
cart.AddItem(product, 1);
// Act
cart.AddItem(product, 2);
// Assert
Assert.Single(cart.Items);
Assert.Equal(3, cart.TotalItems);
Assert.Equal(30.0m, cart.TotalPrice);
}
[Fact]
public void RemoveItem_ExistingItem_RemovesFromCart()
{
// Arrange
var cart = new ShoppingCart();
var product = new Product { Id = 1, Name = "Test", Price = 10.0m };
cart.AddItem(product, 2);
// Act
cart.RemoveItem(product.Id);
// Assert
Assert.Empty(cart.Items);
Assert.Equal(0, cart.TotalItems);
Assert.Equal(0m, cart.TotalPrice);
}
[Fact]
public void Clear_ItemsInCart_EmptiesCart()
{
// Arrange
var cart = new ShoppingCart();
cart.AddItem(new Product { Id = 1, Name = "Test1", Price = 10.0m }, 1);
cart.AddItem(new Product { Id = 2, Name = "Test2", Price = 20.0m }, 2);
// Act
cart.Clear();
// Assert
Assert.Empty(cart.Items);
Assert.Equal(0, cart.TotalItems);
Assert.Equal(0m, cart.TotalPrice);
}
[Fact]
public void ApplyDiscount_ValidPercentage_ReducesTotal()
{
// Arrange
var cart = new ShoppingCart();
cart.AddItem(new Product { Id = 1, Name = "Test", Price = 100.0m }, 1);
// Act
cart.ApplyDiscount(10); // 10% discount
// Assert
Assert.Equal(90.0m, cart.TotalPrice);
}
[Theory]
[InlineData(-10)]
[InlineData(110)]
public void ApplyDiscount_InvalidPercentage_ThrowsException(int discount)
{
// Arrange
var cart = new ShoppingCart();
// Act & Assert
Assert.Throws<ArgumentException>(() => cart.ApplyDiscount(discount));
}
}
// Implementation after TDD
public class ShoppingCart
{
private readonly List<CartItem> _items = new();
private decimal _discountPercentage = 0;
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public int TotalItems => _items.Sum(item => item.Quantity);
public decimal TotalPrice
{
get
{
var subtotal = _items.Sum(item => item.TotalPrice);
return subtotal * (1 - _discountPercentage / 100);
}
}
public void AddItem(Product product, int quantity)
{
if (product == null)
throw new ArgumentNullException(nameof(product));
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
var existingItem = _items.FirstOrDefault(item => item.ProductId == product.Id);
if (existingItem != null)
{
existingItem.Quantity += quantity;
}
else
{
_items.Add(new CartItem
{
ProductId = product.Id,
ProductName = product.Name,
UnitPrice = product.Price,
Quantity = quantity
});
}
}
public void RemoveItem(int productId)
{
var item = _items.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
_items.Remove(item);
}
}
public void Clear()
{
_items.Clear();
_discountPercentage = 0;
}
public void ApplyDiscount(int percentage)
{
if (percentage < 0 || percentage > 100)
throw new ArgumentException("Discount percentage must be between 0 and 100");
_discountPercentage = percentage;
}
}
public class CartItem
{
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal TotalPrice => UnitPrice * Quantity;
}
Note: This is a comprehensive excerpt from the full blog post. The complete article would continue with:
6. Mocking and Test Doubles
Moq Framework Deep Dive
Mocking External Dependencies
Verification and Behavior Testing
Custom Test Doubles
7. Testing ASP.NET Core APIs
Controller Testing Strategies
Authentication and Authorization Testing
API Integration Testing
Response Validation
8. Database Testing Approaches
Entity Framework Core Testing
Repository Pattern Testing
In-Memory Database Testing
Migration Testing
9. Performance and Load Testing
Benchmark Testing with Benchmark.NET
Load Testing Strategies
Performance Monitoring
Stress Testing
10. CI/CD Testing Pipeline
Automated Testing in GitHub Actions
Azure DevOps Testing Pipelines
Quality Gates and Reporting
Test Environment Management
Each section includes real-world examples, code samples, best practices, and common pitfalls to avoid.

Join the conversation! Your thoughts help the community grow.