Unit Testing Best Practices
Write effective unit tests that catch bugs and enable refactoring.
A Simple Analogy
Unit tests are like quality control in manufacturing. Instead of hoping the product works, you test individual components in isolation. A failed test catches problems before shipping.
What Are Unit Tests?
Unit tests verify that individual units of code (functions, methods) work correctly in isolation. They're fast, focused, and automated.
Why Write Unit Tests?
- Confidence: Verify code works as designed
- Regression detection: Catch breaking changes
- Documentation: Tests show how to use code
- Refactoring: Safe to improve code with tests
- Design: TDD improves code architecture
Good Unit Test Characteristics
| Characteristic | Meaning | |---|---| | Isolated | Tests one unit, mocks dependencies | | Fast | Runs milliseconds, not seconds | | Deterministic | Always passes or fails consistently | | Clear | Easy to understand and maintain | | Independent | Can run in any order |
Test Structure (AAA Pattern)
// Arrange: Setup
// Act: Execute
// Assert: Verify
[Fact]
public void Add_WithPositiveNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.Equal(5, result);
}
Practical Example
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public async Task<User> GetUserAsync(int id)
{
if (id <= 0)
throw new ArgumentException("Invalid ID");
return await _repository.GetAsync(id);
}
}
// Tests
public class UserServiceTests
{
[Fact]
public async Task GetUserAsync_WithValidId_ReturnsUser()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
var user = new User { Id = 1, Name = "Alice" };
mockRepo.Setup(r => r.GetAsync(1))
.ReturnsAsync(user);
var service = new UserService(mockRepo.Object);
// Act
var result = await service.GetUserAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("Alice", result.Name);
}
[Fact]
public async Task GetUserAsync_WithInvalidId_ThrowsException()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
var service = new UserService(mockRepo.Object);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => service.GetUserAsync(-1)
);
}
}
Mocking Dependencies
// Create mock
var mockRepository = new Mock<IRepository>();
// Setup return value
mockRepository
.Setup(r => r.Get(It.IsAny<int>()))
.Returns(new User { Name = "Test" });
// Setup exception
mockRepository
.Setup(r => r.Delete(It.IsAny<int>()))
.Throws<InvalidOperationException>();
// Verify method was called
mockRepository.Verify(r => r.Save(It.IsAny<User>()), Times.Once);
Test Organization
[Collection("Integration")]
public class UserServiceTests
{
private readonly UserService _service;
public UserServiceTests()
{
var mockRepo = new Mock<IUserRepository>();
_service = new UserService(mockRepo.Object);
}
// Happy path tests
[Fact]
public void Create_ValidUser_Succeeds() { }
[Fact]
public void Update_ExistingUser_Succeeds() { }
// Edge cases
[Fact]
public void Create_NullUser_ThrowsException() { }
[Fact]
public void Create_DuplicateEmail_ThrowsException() { }
}
Best Practices
- Test behavior, not implementation: Test what it does, not how
- One assertion per test: Keep tests focused
- Use descriptive names: Method_Scenario_Result
- Avoid test dependencies: Tests run independently
- Mock external dependencies: Test in isolation
Related Concepts to Explore
- Integration testing
- Test-driven development (TDD)
- Code coverage measurement
- Continuous integration with tests
- Fixture and factory patterns
Summary
Unit tests catch bugs early and enable confident refactoring. Write focused, isolated tests that verify behavior, not implementation, to build reliable software.