Test-Driven Development Workflow in C

Test-Driven Development (TDD) is a software development methodology where automated tests are written before the functional code. This article provides a comprehensive overview of the TDD workflow within the C# and .NET ecosystem, outlining the classic Red-Green-Refactor cycle, demonstrating its practical implementation with code examples using xUnit, and detailing how this process improves software architecture and reliability.


The Red-Green-Refactor Cycle

The foundation of TDD is a short, iterative feedback loop known as the Red-Green-Refactor cycle:

  1. Red (Write a Failing Test): Write a unit test that defines the expected behavior of a new feature or method. Run the test and verify that it fails (or fails to compile), confirming that the test works and the feature does not yet exist.
  2. Green (Make the Test Pass): Write the simplest, most minimal amount of C# code necessary to make the test pass. Do not focus on perfection, performance, or clean architecture at this stage.
  3. Refactor (Clean the Code): Improve the internal structure of both the production code and the test suite while keeping all tests passing. Remove duplication, improve naming, apply design patterns, and ensure readability.

Step-by-Step TDD Example in C

To see TDD in practice, consider implementing a simple DiscountCalculator that applies a percentage discount to an order amount.

Step 1: The Red Phase (Write the Failing Test)

Create a test project using a framework like xUnit, NUnit, or MSTest. Define the test based on the requirement: applying a 10% discount to an order of $100 should return $90.

using Xunit;

public class DiscountCalculatorTests
{
    [Fact]
    public void CalculateDiscount_WithValidDiscount_ReturnsDiscountedPrice()
    {
        // Arrange
        var calculator = new DiscountCalculator();
        decimal originalPrice = 100m;
        decimal discountPercentage = 10m;

        // Act
        decimal result = calculator.CalculateDiscount(originalPrice, discountPercentage);

        // Assert
        Assert.Equal(90m, result);
    }
}

At this stage, the code fails to compile because the DiscountCalculator class and the CalculateDiscount method do not exist. In TDD, a compilation failure counts as a failing test (Red).

Step 2: The Green Phase (Make the Test Pass)

Write the minimum production code needed to satisfy the test.

public class DiscountCalculator
{
    public decimal CalculateDiscount(decimal originalPrice, decimal discountPercentage)
    {
        return originalPrice - (originalPrice * (discountPercentage / 100m));
    }
}

Run the test runner via the CLI (dotnet test) or through an IDE test runner. The test passes (Green).

Step 3: The Refactor Phase

Examine the code for improvements. Add edge cases by repeating the cycle. For instance, what happens if the discount percentage is negative?

  1. Red: Write a test expecting an ArgumentOutOfRangeException.
  2. Green: Add validation in the method.
  3. Refactor: Extract validation logic or simplify expressions.
// New failing test
[Fact]
public void CalculateDiscount_WithNegativeDiscount_ThrowsArgumentOutOfRangeException()
{
    var calculator = new DiscountCalculator();
    Assert.Throws<ArgumentOutOfRangeException>(() => calculator.CalculateDiscount(100m, -5m));
}

// Updated production code to pass the test
public class DiscountCalculator
{
    public decimal CalculateDiscount(decimal originalPrice, decimal discountPercentage)
    {
        if (discountPercentage < 0 || discountPercentage > 100)
        {
            throw new ArgumentOutOfRangeException(nameof(discountPercentage), "Discount must be between 0 and 100.");
        }

        decimal discountAmount = originalPrice * (discountPercentage / 100m);
        return originalPrice - discountAmount;
    }
}

Run all tests again to verify that both the original functionality and the new edge-case handling pass without regressions.


Essential Practices for TDD in C

  • Isolate Dependencies: Use interfaces and mocking libraries like Moq or NSubstitute to isolate the system under test (SUT) from databases, external APIs, and file systems.
  • Write Meaningful Test Names: Follow clear naming conventions, such as MethodName_StateUnderTest_ExpectedBehavior, to document code behavior through tests.
  • Keep Tests Fast: Unit tests must execute in milliseconds. Fast test suites encourage developers to run tests continuously after every minor change.
  • Refactor Tests Too: Test code is production code. Keep it maintainable, remove duplication, and avoid hardcoded magic numbers when they obscure intent.