Isaac.

Unit Testing Best Practices

Write effective unit tests that catch bugs and enable refactoring.

By EMEPublished: February 20, 2025
unit testingtestingtddmockingxunit

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

  1. Test behavior, not implementation: Test what it does, not how
  2. One assertion per test: Keep tests focused
  3. Use descriptive names: Method_Scenario_Result
  4. Avoid test dependencies: Tests run independently
  5. 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.