Testing & Quality Assurance
Comprehensive testing strategies for Minimal APIs
Testing & Quality Assurance in ASP.NET with .NET 10
Master testing and quality assurance in ASP.NET applications with free flashcards and spaced repetition practice. This lesson covers unit testing, integration testing, test-driven development (TDD), mocking frameworks, and automated testing strategiesโessential skills for building reliable, maintainable web applications in .NET 10.
Welcome to Testing & Quality Assurance ๐งช
Building robust ASP.NET applications requires more than just writing code that worksโit requires confidence that your code will continue to work as requirements evolve. Testing and quality assurance provide that confidence through systematic verification of your application's behavior.
In modern .NET development, testing isn't an afterthoughtโit's an integral part of the development process. Whether you're building RESTful APIs, Blazor applications, or enterprise systems, a comprehensive testing strategy ensures your code is reliable, maintainable, and production-ready.
๐ก Did you know? Microsoft's own .NET team maintains over 250,000 automated tests for the framework itself! This massive test suite runs continuously, catching regressions before they reach developers.
Core Concepts in Testing & Quality Assurance ๐ป
Understanding the Testing Pyramid ๐บ
The testing pyramid represents the ideal distribution of different test types:
โฑโฒ
โฑ โฒ
โฑ E2E โฒ โ Few, slow, expensive
โฑโโโโโโโโโฒ
โฑ โฒ
โฑIntegration โฒ โ Moderate number
โฑโโโโโโโโโโโโโโโฒ
โฑ โฒ
โฑ Unit Tests โฒ โ Many, fast, cheap
โฑโโโโโโโโโโโโโโโโโโโโโฒ
Unit Tests form the foundationโthey're fast, isolated, and test individual components. Integration Tests verify that components work together correctly. End-to-End (E2E) Tests validate entire user workflows but are slower and more brittle.
Unit Testing in .NET 10 ๐ฏ
Unit testing focuses on testing individual methods or classes in isolation. .NET 10 supports multiple testing frameworks:
- xUnit (most popular in .NET community)
- NUnit (feature-rich, Java heritage)
- MSTest (Microsoft's framework, tight Visual Studio integration)
| Framework | Strengths | Best For |
|---|---|---|
| xUnit | Modern, extensible, parallel execution | New projects, microservices |
| NUnit | Rich assertions, extensive attributes | Complex test scenarios |
| MSTest | Enterprise support, IDE integration | Corporate environments |
Test-Driven Development (TDD) ๐
TDD is a development methodology where you write tests before implementation:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TDD CYCLE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโ ๐ด Write Failing Test
โ โ
โ โ
โ ๐ข Write Minimal Code
โ (make test pass)
โ โ
โ โ
โโโโโ ๐ต Refactor
(improve code)
๐ง Memory Device - RED, GREEN, REFACTOR: Think of a traffic lightโRed (stop and think about requirements), Green (go ahead and implement), Blue (refactor = cool down and improve).
Mocking and Test Doubles ๐ญ
When testing units in isolation, you need to replace dependencies with test doubles:
| Type | Purpose | Example |
|---|---|---|
| Mock | Verify interactions occurred | Did SaveAsync() get called? |
| Stub | Return predefined data | Return fake user data |
| Fake | Working simplified implementation | In-memory database |
| Spy | Record information about calls | How many times was method called? |
The most popular mocking framework for .NET is Moq, though alternatives like NSubstitute and FakeItEasy are also excellent choices.
Arrange-Act-Assert (AAA) Pattern ๐
The AAA pattern provides structure for well-organized tests:
// ARRANGE - Set up test data and dependencies
var calculator = new Calculator();
var x = 5;
var y = 3;
// ACT - Execute the method under test
var result = calculator.Add(x, y);
// ASSERT - Verify the outcome
Assert.Equal(8, result);
๐ก Tip: Some developers add a fourth "A" for Annihilate (cleanup), though modern test frameworks usually handle this automatically with IDisposable.
Integration Testing in ASP.NET Core ๐
Integration tests verify that multiple components work together correctly. ASP.NET Core provides WebApplicationFactory<TStartup> for testing entire HTTP pipelines:
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
}
This creates a test server that hosts your application in-memoryโno actual HTTP requests leave your machine!
Code Coverage Metrics ๐
Code coverage measures what percentage of your code is exercised by tests:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Code Coverage Types โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โ โ โ ๐ Line Coverage โ โ What % of lines executed? โ โ โ โ ๐ Branch Coverage โ โ What % of if/else paths tested? โ โ โ โ ๐ฏ Method Coverage โ โ What % of methods called? โ โ โ โ ๐ Path Coverage โ โ What % of execution paths tested? โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๏ธ Warning: High coverage doesn't guarantee quality tests! 100% coverage with poor assertions is worthless. Focus on meaningful coverage of critical paths.
Continuous Integration & Testing ๐
CI/CD pipelines automatically run tests on every commit:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CI/CD PIPELINE FOR ASP.NET โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ป Code Commit
โ
โ
๐จ Build Project
โ
โ
๐งช Run Unit Tests โโโโโ โ Fail โ ๐ซ Stop
โ โ
โ โ
Pass Fix Code
๐ Run Integration Tests
โ
โ โ
Pass
๐ฆ Package Application
โ
โ
๐ Deploy to Staging
โ
โ
๐ญ Run E2E Tests
โ
โ โ
Pass
โจ Deploy to Production
Popular CI/CD platforms for .NET:
- GitHub Actions (excellent .NET support)
- Azure DevOps (tight .NET integration)
- GitLab CI (open-source friendly)
Testing Controllers & APIs ๐ฎ
ASP.NET controllers have specific testing considerations:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
{
_service = service;
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var product = await _service.GetProductAsync(id);
if (product == null)
return NotFound();
return Ok(product);
}
}
You'll test:
- Return types (ActionResult
) - Status codes (200, 404, 400, etc.)
- Response data (serialized objects)
- Dependency interactions (was service called correctly?)
Testing Async/Await Code โฑ๏ธ
Modern ASP.NET is heavily asynchronous. Test frameworks fully support async tests:
[Fact]
public async Task GetProductAsync_ValidId_ReturnsProduct()
{
// Arrange
var mockService = new Mock<IProductService>();
mockService.Setup(s => s.GetProductAsync(1))
.ReturnsAsync(new Product { Id = 1, Name = "Widget" });
// Act
var result = await mockService.Object.GetProductAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("Widget", result.Name);
}
๐ก Tip: Always use async/await in testsโdon't call .Result or .Wait(), as they can cause deadlocks in test runners!
Parameterized Tests ๐
Test the same logic with different inputs using theory tests:
[Theory]
[InlineData(0, 0, 0)]
[InlineData(1, 1, 2)]
[InlineData(-1, -1, -2)]
[InlineData(100, 200, 300)]
public void Add_VariousInputs_ReturnsSum(int x, int y, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(x, y);
Assert.Equal(expected, result);
}
This runs four separate tests with one method definitionโmuch more maintainable!
Test Fixtures & Setup/Teardown ๐ง
Fixtures share setup code across multiple tests:
public class DatabaseFixture : IDisposable
{
public ApplicationDbContext Context { get; private set; }
public DatabaseFixture()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase("TestDb")
.Options;
Context = new ApplicationDbContext(options);
}
public void Dispose()
{
Context.Dispose();
}
}
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
}
๐ Fixture lifecycle:
IClassFixture<T>: One instance shared across all tests in the classICollectionFixture<T>: Shared across multiple test classes- Constructor/Dispose: Run before/after each test
Examples: Real-World Testing Scenarios ๐
Example 1: Unit Testing a Service with Moq
Let's test a UserService that depends on a repository:
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id);
Task<bool> EmailExistsAsync(string email);
Task SaveAsync(User user);
}
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public async Task<Result> RegisterUserAsync(string email, string name)
{
if (await _repository.EmailExistsAsync(email))
return Result.Failure("Email already registered");
var user = new User { Email = email, Name = name };
await _repository.SaveAsync(user);
return Result.Success();
}
}
Test implementation:
public class UserServiceTests
{
[Fact]
public async Task RegisterUserAsync_NewEmail_SavesUser()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.EmailExistsAsync(It.IsAny<string>()))
.ReturnsAsync(false);
mockRepo.Setup(r => r.SaveAsync(It.IsAny<User>()))
.Returns(Task.CompletedTask);
var service = new UserService(mockRepo.Object);
// Act
var result = await service.RegisterUserAsync("test@example.com", "John Doe");
// Assert
Assert.True(result.IsSuccess);
mockRepo.Verify(r => r.SaveAsync(It.Is<User>(u =>
u.Email == "test@example.com" &&
u.Name == "John Doe")), Times.Once);
}
[Fact]
public async Task RegisterUserAsync_DuplicateEmail_ReturnsFailure()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.EmailExistsAsync("duplicate@example.com"))
.ReturnsAsync(true);
var service = new UserService(mockRepo.Object);
// Act
var result = await service.RegisterUserAsync("duplicate@example.com", "Jane Doe");
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("Email already registered", result.ErrorMessage);
mockRepo.Verify(r => r.SaveAsync(It.IsAny<User>()), Times.Never);
}
}
๐ฏ Key Points:
- We mock the repository to isolate the service
- We verify that methods are called with expected parameters
- We test both success and failure paths
- We use
Times.OnceandTimes.Neverto ensure correct behavior
Example 2: Integration Testing an API Endpoint
Test a complete HTTP request/response cycle:
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private readonly WebApplicationFactory<Program> _factory;
public ProductsApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real database with in-memory version
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsSuccessAndProducts()
{
// Arrange - Seed test data
using (var scope = _factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Products.AddRange(
new Product { Id = 1, Name = "Widget", Price = 9.99m },
new Product { Id = 2, Name = "Gadget", Price = 19.99m }
);
await context.SaveChangesAsync();
}
// Act
var response = await _client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var products = JsonSerializer.Deserialize<List<Product>>(content);
Assert.NotNull(products);
Assert.Equal(2, products.Count);
}
[Fact]
public async Task CreateProduct_ValidData_ReturnsCreated()
{
// Arrange
var newProduct = new { Name = "New Widget", Price = 14.99 };
var content = new StringContent(
JsonSerializer.Serialize(newProduct),
Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync("/api/products", content);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
}
๐ Real-World Analogy: Integration testing is like testing a car on a closed trackโyou're using real components (engine, transmission, brakes) but in a controlled environment (test database, test server) before taking it on public roads (production).
Example 3: Testing with Realistic Test Data
Use the Builder Pattern for maintainable test data:
public class ProductBuilder
{
private int _id = 1;
private string _name = "Default Product";
private decimal _price = 10.00m;
private string _category = "General";
private bool _inStock = true;
public ProductBuilder WithId(int id)
{
_id = id;
return this;
}
public ProductBuilder WithName(string name)
{
_name = name;
return this;
}
public ProductBuilder WithPrice(decimal price)
{
_price = price;
return this;
}
public ProductBuilder OutOfStock()
{
_inStock = false;
return this;
}
public Product Build()
{
return new Product
{
Id = _id,
Name = _name,
Price = _price,
Category = _category,
InStock = _inStock
};
}
}
// Usage in tests
[Fact]
public void CalculateTotal_OutOfStockProducts_ExcludesFromTotal()
{
// Arrange
var products = new List<Product>
{
new ProductBuilder().WithPrice(10.00m).Build(),
new ProductBuilder().WithPrice(20.00m).OutOfStock().Build(),
new ProductBuilder().WithPrice(15.00m).Build()
};
var calculator = new PriceCalculator();
// Act
var total = calculator.CalculateTotal(products);
// Assert
Assert.Equal(25.00m, total); // Only in-stock items
}
๐ก Benefit: The Builder pattern makes tests readable and maintainableโyou only specify what's different from the default, not every single property!
Example 4: Testing Exception Handling
Verify your code handles errors gracefully:
public class FileService
{
public async Task<string> ReadFileAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path cannot be empty", nameof(path));
if (!File.Exists(path))
throw new FileNotFoundException($"File not found: {path}");
return await File.ReadAllTextAsync(path);
}
}
public class FileServiceTests
{
[Fact]
public async Task ReadFileAsync_EmptyPath_ThrowsArgumentException()
{
// Arrange
var service = new FileService();
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => service.ReadFileAsync(""));
Assert.Equal("path", exception.ParamName);
}
[Fact]
public async Task ReadFileAsync_NonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var service = new FileService();
var fakePath = "/nonexistent/file.txt";
// Act & Assert
var exception = await Assert.ThrowsAsync<FileNotFoundException>(
() => service.ReadFileAsync(fakePath));
Assert.Contains(fakePath, exception.Message);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ReadFileAsync_InvalidPaths_ThrowsArgumentException(string invalidPath)
{
var service = new FileService();
await Assert.ThrowsAsync<ArgumentException>(
() => service.ReadFileAsync(invalidPath));
}
}
โ ๏ธ Testing Philosophy: If your code can throw an exception, your tests must verify that behavior. Untested error paths are time bombs waiting to explode in production!
Common Mistakes to Avoid โ ๏ธ
1. Testing Implementation Instead of Behavior โ
Wrong Approach:
[Fact]
public void ProcessOrder_CallsRepositoryThreeTimes()
{
var mockRepo = new Mock<IOrderRepository>();
var service = new OrderService(mockRepo.Object);
service.ProcessOrder(orderId: 123);
// Testing HOW it works, not WHAT it does
mockRepo.Verify(r => r.GetById(123), Times.Once);
mockRepo.Verify(r => r.Update(It.IsAny<Order>()), Times.Once);
mockRepo.Verify(r => r.Save(), Times.Once);
}
Better Approach:
[Fact]
public void ProcessOrder_MarksOrderAsProcessed()
{
var mockRepo = new Mock<IOrderRepository>();
var order = new Order { Id = 123, Status = OrderStatus.Pending };
mockRepo.Setup(r => r.GetById(123)).Returns(order);
var service = new OrderService(mockRepo.Object);
service.ProcessOrder(123);
// Testing WHAT changed, not HOW
Assert.Equal(OrderStatus.Processed, order.Status);
}
๐ฏ Principle: Test the outcome, not the implementation details. If you refactor the implementation, behavior tests shouldn't break!
2. Tests That Depend on Each Other ๐
Problematic:
private static User _sharedUser;
[Fact]
public void Test1_CreateUser()
{
_sharedUser = new User { Name = "John" };
// Test depends on execution order!
}
[Fact]
public void Test2_UpdateUser()
{
_sharedUser.Name = "Jane"; // Will fail if Test1 hasn't run!
}
Correct:
[Fact]
public void UpdateUser_ChangesName()
{
// Each test is independent
var user = new User { Name = "John" };
user.Name = "Jane";
Assert.Equal("Jane", user.Name);
}
๐ก Golden Rule: Tests must be independent and run in any order. Use fixtures or setup methods for shared resources.
3. Not Testing Edge Cases ๐ฏ
Incomplete:
[Fact]
public void Divide_ReturnsQuotient()
{
Assert.Equal(2, Calculator.Divide(10, 5));
}
Comprehensive:
[Theory]
[InlineData(10, 5, 2)]
[InlineData(10, 3, 3)] // Integer division
[InlineData(0, 5, 0)] // Zero dividend
[InlineData(-10, 5, -2)] // Negative numbers
public void Divide_VariousCases_ReturnsQuotient(int x, int y, int expected)
{
Assert.Equal(expected, Calculator.Divide(x, y));
}
[Fact]
public void Divide_ByZero_ThrowsException()
{
Assert.Throws<DivideByZeroException>(() => Calculator.Divide(10, 0));
}
๐ง Think Like an Attacker: What inputs might break your code? Null values? Empty strings? Negative numbers? Maximum values? Test them all!
4. Ignoring Async/Await Properly โฑ๏ธ
Wrong:
[Fact]
public void GetUserAsync_ReturnsUser()
{
var result = service.GetUserAsync(1).Result; // DON'T USE .Result!
Assert.NotNull(result);
}
Correct:
[Fact]
public async Task GetUserAsync_ReturnsUser()
{
var result = await service.GetUserAsync(1);
Assert.NotNull(result);
}
โ ๏ธ Warning: Using .Result or .Wait() can cause deadlocks in test runners. Always use async/await!
5. Overly Complex Test Setup ๐๏ธ
Too Complex:
[Fact]
public async Task ProcessOrder_ComplexScenario()
{
// 50 lines of setup...
var db = CreateDatabase();
var cache = CreateCache();
var logger = CreateLogger();
var emailService = CreateEmailService();
var paymentGateway = CreatePaymentGateway();
// ... 20 more dependencies
var service = new OrderService(db, cache, logger, emailService, paymentGateway /*, ... */);
// What are we even testing here?
}
Simplified:
public class OrderServiceTestFixture
{
public OrderService CreateService(Action<Mock<IPaymentGateway>>? configurePayment = null)
{
var mockPayment = new Mock<IPaymentGateway>();
configurePayment?.Invoke(mockPayment);
return new OrderService(
new InMemoryDatabase(),
mockPayment.Object);
}
}
[Fact]
public void ProcessOrder_PaymentSucceeds_CompletesOrder()
{
var service = _fixture.CreateService(payment =>
{
payment.Setup(p => p.ChargeAsync(It.IsAny<decimal>()))
.ReturnsAsync(true);
});
// Now the test logic is clear!
}
Key Takeaways ๐
โ Test Pyramid: Favor many fast unit tests over few slow E2E tests
โ AAA Pattern: Structure tests as Arrange-Act-Assert for clarity
โ TDD Workflow: Red (write failing test) โ Green (make it pass) โ Refactor
โ Mocking: Use Moq or similar to isolate units from dependencies
โ
Integration Tests: Use WebApplicationFactory to test complete request pipelines
โ Independence: Tests must run in any order without dependencies
โ Edge Cases: Test boundary conditions, null values, and error scenarios
โ Async/Await: Always use proper async patterns in tests
โ Code Coverage: Aim for high coverage, but focus on meaningful tests
โ CI/CD Integration: Automate tests to run on every commit
๐ Further Study
- Official Microsoft Docs: Unit testing in .NET
- xUnit Documentation: xUnit.net Official Docs
- Moq Framework: Moq GitHub Repository
๐ Quick Reference Card
| Concept | Key Points |
|---|---|
| Unit Test | Tests single component in isolation; fast, focused |
| Integration Test | Tests multiple components together; uses WebApplicationFactory |
| AAA Pattern | Arrange setup โ Act execution โ Assert verification |
| Mock | Simulates dependencies; verify interactions with .Verify() |
| Theory | Parameterized test with [InlineData] for multiple inputs |
| Fixture | IClassFixture for shared setup across tests |
| TDD Cycle | Red (fail) โ Green (pass) โ Refactor (improve) |
| Async Tests | Use async Task, never .Result or .Wait() |
๐ฏ Next Steps: Practice writing tests for existing code, then try TDD on your next feature. You'll quickly appreciate how tests serve as living documentation and provide confidence when refactoring!