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:
- 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.
- 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.
- 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?
- Red: Write a test expecting an
ArgumentOutOfRangeException. - Green: Add validation in the method.
- 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.