![testing]()
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
7. Testing ASP.NET Core APIs
8. Database Testing Approaches
Entity Framework Core Testing
Repository Pattern Testing
In-Memory Database Testing
Migration Testing
9. Performance and Load 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.