Testing

Unit and integration testing, the AAA pattern, test doubles, TDD, .NET test frameworks, testable design, and code coverage.

Unit testing is the practice of testing individual units or components of code in isolation, typically at the function or method level. A unit test verifies that a specific piece of code behaves as expected under various conditions.

Why it's important:

  • Early Bug Detection: Catches bugs early in development when they're cheaper to fix
  • Documentation: Tests serve as living documentation showing how code should be used
  • Refactoring Confidence: Enables safe refactoring by ensuring existing functionality isn't broken
  • Design Improvement: Writing testable code often leads to better architecture and loose coupling
  • Regression Prevention: Prevents old bugs from reappearing
  • Faster Development: Though initial setup takes time, it speeds up long-term development
  • Quality Assurance: Provides confidence that code works correctly

Related Resources

The AAA pattern is a common structure for organizing unit tests, making them clear and consistent:

1. Arrange: Set up the test conditions

  • Initialize objects
  • Configure dependencies
  • Set up test data and expected values

2. Act: Execute the code under test

  • Call the method or function being tested
  • Trigger the behavior you want to verify

3. Assert: Verify the results

  • Check that the actual outcome matches expectations
  • Verify state changes, return values, or method calls

Example:

[Fact]
public void Withdraw_WithSufficientFunds_DecreasesBalance()
{
    // Arrange
    var account = new BankAccount(initialBalance: 100);
    var withdrawAmount = 30;
    
    // Act
    account.Withdraw(withdrawAmount);
    
    // Assert
    Assert.Equal(70, account.Balance);
}

These are different types of test doubles used to isolate code during testing:

Stubbing:

  • Provides predefined answers to method calls
  • Used when you need to control what dependencies return
  • Doesn't verify interactions
  • Simple, passive replacement
var userRepository = new Mock();
userRepository.Setup(x => x.GetById(1)).Returns(new User { Id = 1, Name = "John" });

Mocking:

  • Records and verifies interactions with dependencies
  • Used to verify that specific methods were called with expected parameters
  • Active verification of behavior
var emailService = new Mock();
// Act
service.SendWelcomeEmail(userId);
// Assert
emailService.Verify(x => x.Send(It.IsAny(), "Welcome!"), Times.Once);

Faking:

  • Working implementation with shortcuts (not production-ready)
  • More complex than stubs, simpler than real implementations
  • Example: in-memory database instead of real SQL database
public class FakeUserRepository : IUserRepository
{
    private List _users = new();
    
    public User GetById(int id) => _users.FirstOrDefault(u => u.Id == id);
    public void Add(User user) => _users.Add(user);
}

Key Difference: Stubs provide data, mocks verify behavior, fakes are simplified implementations.

xUnit.net (Modern, Recommended):

  • Most modern and actively maintained
  • Used by .NET Core team
  • No [SetUp]/[TearDown] attributes (uses constructors/IDisposable)
  • Better parallelization support
  • [Fact] for simple tests, [Theory] for parameterized tests
public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var calculator = new Calculator();
        var result = calculator.Add(2, 3);
        Assert.Equal(5, result);
    }
    
    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calculator = new Calculator();
        Assert.Equal(expected, calculator.Add(a, b));
    }
}

NUnit (Mature, Feature-Rich):

  • Oldest and most feature-rich
  • Rich assertion library
  • Supports parallel test execution
  • [Test], [TestCase] attributes
[TestFixture]
public class CalculatorTests
{
    [Test]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var calculator = new Calculator();
        var result = calculator.Add(2, 3);
        Assert.That(result, Is.EqualTo(5));
    }
    
    [TestCase(2, 3, 5)]
    [TestCase(0, 0, 0)]
    public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calculator = new Calculator();
        Assert.That(calculator.Add(a, b), Is.EqualTo(expected));
    }
}

MSTest (Microsoft's Framework):

  • Built into Visual Studio
  • Good integration with Microsoft tools
  • Less popular in community
  • [TestMethod], [DataRow] attributes
[TestClass]
public class CalculatorTests
{
    [TestMethod]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var calculator = new Calculator();
        var result = calculator.Add(2, 3);
        Assert.AreEqual(5, result);
    }
}

Personal Preference: xUnit for new projects due to modern design and .NET team support.

Principles for testable code:

1. Dependency Injection:

  • Inject dependencies rather than creating them internally
  • Enables easy substitution with test doubles
// Bad - Hard to test
public class OrderService
{
    public void ProcessOrder(Order order)
    {
        var repository = new OrderRepository(); // Hard-coded dependency
        repository.Save(order);
    }
}

// Good - Easy to test
public class OrderService
{
    private readonly IOrderRepository _repository;
    
    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
    
    public void ProcessOrder(Order order)
    {
        _repository.Save(order);
    }
}

2. Single Responsibility Principle:

  • Each class/method should have one reason to change
  • Smaller, focused units are easier to test

3. Avoid Static Dependencies:

  • Static methods and classes are difficult to mock
  • Use interfaces and instance methods

4. Pure Functions When Possible:

  • Given the same input, always return the same output
  • No side effects
  • Easiest to test

5. Separate Logic from Infrastructure:

  • Keep business logic separate from database, file system, network calls
  • Makes logic testable without external dependencies

6. Avoid Hidden Dependencies:

  • Make all dependencies explicit in constructor
  • Don't use service locators or global state

7. Keep Methods Small:

  • Easier to understand and test
  • Single level of abstraction

8. Use Interfaces:

  • Program to interfaces, not implementations
  • Enables mocking and substitution