Skip to main content

5.5 - Unit Testing

Unit testing is a preventive debugging technique that helps catch issues before they reach production. By writing tests for individual units of code, you can verify that each component works correctly in isolation and catch regressions when making changes.

5.5.1 - Introduction to Unit Testing

Unit testing involves testing individual components or "units" of code in isolation from the rest of the application.

5.5.1.1 - Benefits of Unit Testing

Unit testing provides several benefits:

  1. Early Bug Detection: Catch bugs during development rather than in production
  2. Regression Prevention: Ensure changes don't break existing functionality
  3. Documentation: Tests serve as executable documentation of how code should behave
  4. Design Improvement: Writing testable code often leads to better design
  5. Confidence: Make changes with confidence that existing functionality remains intact

5.5.1.2 - Characteristics of Good Unit Tests

Good unit tests are:

  1. Automated: Run without manual intervention
  2. Repeatable: Produce the same results each time they run
  3. Isolated: Test a single unit of code in isolation
  4. Fast: Execute quickly to enable frequent running
  5. Clear: Easy to understand what's being tested and what the expected outcome is

5.5.1.3 - The AAA Pattern

Unit tests typically follow the Arrange-Act-Assert (AAA) pattern:

[Test]
public void CalculateTotal_WithValidItems_ReturnsCorrectTotal()
{
// Arrange: Set up the test data
var order = new Order
{
Items = new List<OrderItem>
{
new OrderItem { Price = 10.0m, Quantity = 2 },
new OrderItem { Price = 15.0m, Quantity = 1 }
}
};
var calculator = new OrderCalculator();

// Act: Perform the action being tested
decimal total = calculator.CalculateTotal(order);

// Assert: Verify the result
Assert.AreEqual(35.0m, total);
}

5.5.2 - Test Frameworks

Several test frameworks are available for C# development, each with its own features and syntax.

5.5.2.1 - MSTest

MSTest is Microsoft's built-in testing framework:

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class OrderCalculatorTests
{
[TestMethod]
public void CalculateTotal_WithValidItems_ReturnsCorrectTotal()
{
// Arrange
var order = new Order
{
Items = new List<OrderItem>
{
new OrderItem { Price = 10.0m, Quantity = 2 },
new OrderItem { Price = 15.0m, Quantity = 1 }
}
};
var calculator = new OrderCalculator();

// Act
decimal total = calculator.CalculateTotal(order);

// Assert
Assert.AreEqual(35.0m, total);
}
}

5.5.2.2 - NUnit

NUnit is a popular third-party testing framework:

using NUnit.Framework;

[TestFixture]
public class OrderCalculatorTests
{
[Test]
public void CalculateTotal_WithValidItems_ReturnsCorrectTotal()
{
// Arrange
var order = new Order
{
Items = new List<OrderItem>
{
new OrderItem { Price = 10.0m, Quantity = 2 },
new OrderItem { Price = 15.0m, Quantity = 1 }
}
};
var calculator = new OrderCalculator();

// Act
decimal total = calculator.CalculateTotal(order);

// Assert
Assert.That(total, Is.EqualTo(35.0m));
}
}

5.5.2.3 - xUnit

xUnit is a modern testing framework designed for .NET:

using Xunit;

public class OrderCalculatorTests
{
[Fact]
public void CalculateTotal_WithValidItems_ReturnsCorrectTotal()
{
// Arrange
var order = new Order
{
Items = new List<OrderItem>
{
new OrderItem { Price = 10.0m, Quantity = 2 },
new OrderItem { Price = 15.0m, Quantity = 1 }
}
};
var calculator = new OrderCalculator();

// Act
decimal total = calculator.CalculateTotal(order);

// Assert
Assert.Equal(35.0m, total);
}
}

5.5.3 - Writing Effective Tests

Writing effective tests requires following certain principles and practices.

5.5.3.1 - Test Naming

Use descriptive test names that indicate what's being tested and the expected outcome:

[Test]
public void CalculateTotal_WithEmptyOrder_ReturnsZero()
{
// Test code
}

[Test]
public void CalculateTotal_WithNegativePrices_ThrowsArgumentException()
{
// Test code
}

5.5.3.2 - Test Data

Provide diverse test data to cover different scenarios:

[Theory]
[InlineData(0, 0, 0)]
[InlineData(10, 1, 10)]
[InlineData(10, 2, 20)]
[InlineData(10, -1, -10)]
public void Multiply_ReturnsExpectedResult(decimal price, int quantity, decimal expected)
{
// Arrange
var calculator = new Calculator();

// Act
decimal result = calculator.Multiply(price, quantity);

// Assert
Assert.Equal(expected, result);
}

5.5.3.3 - Mocking Dependencies

Use mocking frameworks to isolate the unit being tested:

[Test]
public void ProcessOrder_WithValidOrder_CallsPaymentService()
{
// Arrange
var order = new Order { /* ... */ };
var mockPaymentService = new Mock<IPaymentService>();
mockPaymentService.Setup(s => s.ProcessPayment(It.IsAny<decimal>()))
.Returns(true);

var orderProcessor = new OrderProcessor(mockPaymentService.Object);

// Act
orderProcessor.ProcessOrder(order);

// Assert
mockPaymentService.Verify(s => s.ProcessPayment(order.Total), Times.Once);
}

5.5.4 - Test-Driven Development

Test-Driven Development (TDD) is a development approach where tests are written before the code they test.

5.5.4.1 - The TDD Cycle

TDD follows a Red-Green-Refactor cycle:

  1. Red: Write a failing test
  2. Green: Write the minimum code to make the test pass
  3. Refactor: Improve the code while keeping the tests passing
// Step 1: Write a failing test
[Test]
public void CalculateDiscount_PremiumCustomer_Returns20Percent()
{
// Arrange
var calculator = new DiscountCalculator();
var customer = new Customer { Type = CustomerType.Premium };

// Act
decimal discount = calculator.CalculateDiscount(customer);

// Assert
Assert.AreEqual(0.2m, discount);
}

// Step 2: Write the minimum code to make the test pass
public decimal CalculateDiscount(Customer customer)
{
if (customer.Type == CustomerType.Premium)
{
return 0.2m;
}
return 0.0m;
}

// Step 3: Refactor if necessary
public decimal CalculateDiscount(Customer customer)
{
return customer.Type switch
{
CustomerType.Premium => 0.2m,
CustomerType.Regular => 0.1m,
_ => 0.0m
};
}

5.5.4.2 - Benefits of TDD

TDD provides several benefits:

  1. Focus on Requirements: Forces you to think about what the code should do before writing it
  2. Comprehensive Test Coverage: Ensures all code is covered by tests
  3. Simpler Design: Leads to simpler, more modular code
  4. Faster Feedback: Provides immediate feedback on code correctness
  5. Confidence in Refactoring: Makes it safer to improve code design

5.5.4.3 - TDD Best Practices

Follow these best practices when practicing TDD:

  1. Write the Simplest Test: Start with the simplest test case
  2. Write the Simplest Code: Write just enough code to make the test pass
  3. Refactor Regularly: Continuously improve the code design
  4. Small Steps: Take small, incremental steps
  5. Run Tests Frequently: Run tests after each change

In the next chapter, we'll explore real-world debugging case studies that demonstrate how to apply the techniques covered in previous chapters to solve complex debugging challenges.