Testing

Difficulty

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