Unit Testing in ASP.NET Core
Learn how to write effective unit tests for ASP.NET Core applications using xUnit, Moq, and best practices.
A Simple Analogy
Imagine building a house where you check each brick before laying it. Unit testing is like that—you test small pieces (functions, methods) in isolation before putting everything together. If a brick is bad, you find it immediately rather than discovering cracks in the wall months later.
What Is Unit Testing?
Unit testing is the practice of writing automated tests for small, individual pieces of code (functions, methods, classes) in isolation. Each test verifies that a specific unit behaves correctly under given conditions.
Why Use Unit Testing?
- Catch bugs early: Find problems during development, not production
- Refactoring confidence: Change code without breaking things
- Living documentation: Tests show how code is supposed to work
- Faster debugging: Failed test pinpoints the exact issue
- Code quality: Forces you to write testable, modular code
- Regression prevention: Catch when old bugs come back
Test Structure: Arrange-Act-Assert (AAA)
Every good unit test follows this pattern:
1. Arrange: Set up test data and mocks
2. Act: Call the method being tested
3. Assert: Verify the result is correct
Setting Up xUnit and Moq
Install Packages
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Moq
dotnet add package Microsoft.NET.Test.Sdk
Create Test Project
dotnet new xunit -n MyApp.Tests
Basic Unit Test Example
Code to Test
public class Calculator
{
public int Add(int a, int b) => a + b;
public int Subtract(int a, int b) => a - b;
}
Unit Tests
public class CalculatorTests
{
[Fact]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
int a = 5;
int b = 3;
// Act
int result = calculator.Add(a, b);
// Assert
Assert.Equal(8, result);
}
[Fact]
public void Subtract_LargerMinusSmallerNumber_ReturnsPositiveDifference()
{
// Arrange
var calculator = new Calculator();
// Act
int result = calculator.Subtract(10, 3);
// Assert
Assert.Equal(7, result);
}
}
Testing with Dependencies (Mocking)
Code with Dependencies
public interface IEmailService
{
Task SendAsync(string email, string message);
}
public class UserService
{
private readonly IUserRepository _repo;
private readonly IEmailService _emailService;
public UserService(IUserRepository repo, IEmailService emailService)
{
_repo = repo;
_emailService = emailService;
}
public async Task RegisterUserAsync(string name, string email)
{
var user = new User { Name = name, Email = email };
await _repo.AddAsync(user);
await _emailService.SendAsync(email, $"Welcome, {name}!");
}
}
Unit Test with Mocks
public class UserServiceTests
{
[Fact]
public async Task RegisterUserAsync_ValidUser_SavesAndSendsEmail()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
var mockEmailService = new Mock<IEmailService>();
var service = new UserService(mockRepo.Object, mockEmailService.Object);
// Act
await service.RegisterUserAsync("Alice", "alice@example.com");
// Assert
mockRepo.Verify(
r => r.AddAsync(It.IsAny<User>()),
Times.Once,
"User should be added to repository");
mockEmailService.Verify(
e => e.SendAsync("alice@example.com", It.IsAny<string>()),
Times.Once,
"Welcome email should be sent");
}
}
Parameterized Tests (Test Multiple Scenarios)
[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(100, 200, 300)]
public void Add_VariousNumbers_ReturnsCorrectSum(int a, int b, int expected)
{
// Arrange
var calc = new Calculator();
// Act
var result = calc.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
Testing Exceptions
[Fact]
public void Divide_ByZero_ThrowsException()
{
// Arrange
var calc = new Calculator();
// Act & Assert
var exception = Assert.Throws<DivideByZeroException>(
() => calc.Divide(10, 0));
Assert.NotNull(exception);
}
Integration Testing (Optional but Useful)
[Fact]
public async Task CreateUser_ValidData_ReturnCreatedAtRoute()
{
// Arrange
using var client = new HttpClient { BaseAddress = new Uri("https://localhost") };
var userData = new { Name = "Bob", Email = "bob@example.com" };
// Act
var response = await client.PostAsJsonAsync("/api/users", userData);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var location = response.Headers.Location;
location.Should().NotBeNull();
}
Practical Examples
Testing a Service
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_ValidItems_ReturnsOrderWithTotalPrice()
{
// Arrange
var mockRepo = new Mock<IOrderRepository>();
var service = new OrderService(mockRepo.Object);
var items = new[] {
new OrderItem { Price = 10, Quantity = 2 },
new OrderItem { Price = 5, Quantity = 3 }
};
// Act
var order = await service.CreateOrderAsync(items);
// Assert
Assert.Equal(35, order.TotalPrice);
mockRepo.Verify(r => r.SaveAsync(order), Times.Once);
}
}
Testing a Controller
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _service.GetProductAsync(id);
if (product == null) return NotFound();
return Ok(product);
}
// Test
public class ProductControllerTests
{
[Fact]
public async Task GetProduct_WithValidId_ReturnsOkWithProduct()
{
// Arrange
var mockService = new Mock<IProductService>();
mockService
.Setup(s => s.GetProductAsync(1))
.ReturnsAsync(new Product { Id = 1, Name = "Widget" });
var controller = new ProductController(mockService.Object);
// Act
var result = await controller.GetProduct(1);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var product = Assert.IsType<Product>(okResult.Value);
Assert.Equal("Widget", product.Name);
}
}
Real-World Use Cases
- E-commerce: Test order processing, payment validation, inventory updates
- APIs: Test endpoint logic, authentication, error handling
- Business logic: Test calculations, rules, workflows
- Data access: Test repository methods with mocked databases
- Services: Test external integrations (email, SMS, payments)
Best Practices
- One assertion per test: Makes it clear what failed
- Descriptive names: Test names should explain what they test
- Keep tests independent: Tests shouldn't depend on each other
- Mock external dependencies: Only test what you wrote
- Avoid brittle tests: Don't test implementation details
- Test behavior, not code: Focus on what should happen, not how
- Keep tests fast: They should run in milliseconds
- Maintain test code: Refactor tests like production code
Common Pitfalls
- Testing implementation details instead of behavior
- Over-mocking (mocking too many things)
- Slow tests (use async mocks, avoid real databases)
- Unclear test names (use Fact_Should_Scenario naming)
- Not testing edge cases or error conditions
- Too many assertions (makes debugging hard)
Related Concepts to Explore
- Test-Driven Development (TDD)
- Integration testing
- End-to-end (E2E) testing
- Test coverage and code coverage tools
- Continuous Integration (CI) pipelines
- Behavior-Driven Development (BDD)
- Performance testing
- Load testing
- Mocking frameworks and stubs
- Test fixtures and setup/teardown
Summary
Unit testing is essential for writing reliable, maintainable code. By testing small units in isolation with clear Arrange-Act-Assert patterns and proper mocking, you build confidence in your code, catch bugs early, and make refactoring safe. Start with simple tests, gradually add complexity, and you'll find bugs before your users do.