In this lesson, we'll deepen our understanding of the Test-Driven Development (TDD) mindset by focusing on the Red-Green-Refactor cycle with a practical example centered on a CalculateTotal
function. This example will guide you through the process of thinking in tests, prioritizing test writing, and leveraging TDD to enhance code clarity, reliability, and maintainability.
Using C# and xUnit, we'll follow these steps:
- Begin with the Red phase by identifying and writing failing tests for the
CalculateTotal
function, which will compute the total price of items in a shopping cart. - Move to the Green phase to implement the minimal code required to pass each test, ensuring that the
CalculateTotal
function behaves as expected. - Enter the Refactor phase to improve the code structure and readability of
CalculateTotal
, employing techniques like LINQ aggregation while keeping tests green. - Utilize xUnit as a testing framework to efficiently integrate the TDD approach within our .NET project.
By working through this example, you'll gain practical experience with TDD principles and develop the CalculateTotal
function in a way that showcases how TDD fosters code quality and robustness.
Let's begin by writing tests for a function named CalculateTotal
, designed to compute the total price of items in a shopping cart. This is where you engage with the Red phase: Write a failing test.
Let's think about how to build a method that calculates lists of items. What should the interface be? How does the consumer of the code use it? These are the questions we think about first when we "think in tests." Here's one way we might think about it in C#.
C#1using Xunit; 2 3public class CartTests 4{ 5 [Fact] 6 public void CalculateTotal_ShouldReturnZero_ForEmptyCart() 7 { 8 var total = CalculateTotal(new CartItem[]{}); 9 10 Assert.Equal(0, total); 11 } 12}
Explanation:
- We know we want a method called
CalculateTotal()
, so we'll write a test that uses it. - For now, we know that we want an empty array as input to return 0, so we can write that test.
- The expectation is that these tests will initially fail, which is an integral part of the Red phase.
Running these tests confirms they fail, creating a clear path for subsequent development.
Now, let’s move to the Green phase where we implement the minimal code to pass these tests.
Implement the CalculateTotal
method in your class:
C#1public class CartItem 2{ 3} 4 5public double CalculateTotal(CartItem[] items) 6{ 7 return 0; 8}
Explanation:
- The
CalculateTotal
method takes an array ofCartItem
objects. We don't really know the shape of the data yet, and that's okay! - Returning 0 is enough to get the test to pass.
By running the test suite again, we should see all tests passing, demonstrating that our function meets the required condition.
Now is the time to think about what kind of data we want to pass to our CalculateTotal
method. We consider that we'd like to pass the name
, price
, and quantity
as properties in the CartItem
class. The total will be the product of price
* quantity
. Let's do that and see how it feels:
C#1[Fact] 2public void CalculateTotal_ShouldCalculateTotal_ForSingleItem() 3{ 4 var items = new[] 5 { 6 new CartItem { Name = "Apple", Price = 0.5m, Quantity = 3 } 7 }; 8 9 var total = CalculateTotal(items); 10 11 Assert.Equal(1.5m, total); 12}
That feels pretty good; the interface seems clear. Let's see if we can get those tests passing.
Now we need to think about how to make these tests pass. What is the minimum necessary to get this to pass?
C#1public double CalculateTotal(CartItem[] items) 2{ 3 if (items.Length > 0) 4 { 5 return items[0].Price * items[0].Quantity; 6 } 7 8 return 0; 9}
When we run the tests, we should see that both tests pass! We're Green!
Now we ask ourselves: is there anything we can do to make this code better? One thing I might do is get rid of the repetition of accessing the first item in the array. Another thing I might do is ensure each CartItem
has well-defined properties.
C#1public class CartItem 2{ 3 public string Name { get; set; } 4 public double Price { get; set; } 5 public int Quantity { get; set; } 6} 7 8public double CalculateTotal(CartItem[] items) 9{ 10 if (items.Length > 0) 11 { 12 var item = items[0]; 13 return item.Price * item.Quantity; 14 } 15 16 return 0; 17}
When we run the tests again, we're still green. The code is more expressive now and reflects the properties of CartItem
more clearly, so let's do the Red-Green-Refactor loop again!
The existing code works! But it won't be very useful to only use the first item. If we add one more test, we can generalize more.
C#1[Fact] 2public void CalculateTotal_ShouldCalculateTotal_ForMultipleItems() 3{ 4 var items = new[] 5 { 6 new CartItem { Name = "Apple", Price = 0.5m, Quantity = 3 }, 7 new CartItem { Name = "Banana", Price = 0.3m, Quantity = 2 } 8 }; 9 10 var total = CalculateTotal(items); 11 12 Assert.Equal(2.1m, total); 13}
As expected, this new test will fail and we can move to the Green step.
Let's take a stab at getting the test to pass:
C#1public double CalculateTotal(CartItem[] items) 2{ 3 double total = 0; 4 5 for (int i = 0; i < items.Length; i++) 6 { 7 var item = items[i]; 8 total += item.Price * item.Quantity; 9 } 10 11 return total; 12}
This does the job, and we're Green again. I can't help but feel like we could have written that code a bit better. Now that we have tests that cover everything we want this function to do, let's move to the Refactor step!
When we look at the CalculateTotal
function, it is clear that this is an "aggregate function." It takes a list of items and reduces it to an aggregate value. Times like this call for the use of LINQ!
C#1public double CalculateTotal(CartItem[] items) 2{ 3 return items.Sum(item => item.Price * item.Quantity); 4}
The Sum
method aggregates the total price by multiplying the price and quantity of each CartItem
. I like this code a lot better! Best of all, our tests tell us that we're still Green! We've successfully refactored the code.
Running the tests one more time shows we're still green!
In C#, you can run your xUnit tests using a test runner, such as the one integrated into Visual Studio or the command-line tool dotnet test
.
Bash1dotnet test
Throughout this lesson, we focused on refining the TDD mindset by emphasizing writing tests prior to coding and following the Red-Green-Refactor cycle. Here's what we covered:
- Red Phase: We started by writing failing tests, like determining that
CalculateTotal
should return 0 for an empty cart and a specific total for a single item. This helped us clearly define our interface and objectives. - Green Phase: We implemented minimal solutions to pass each test condition. For instance, returning a simple calculation for a single
CartItem
or a total for an entire list of items. - Refactor Phase: We improved the code structure and readability, utilizing techniques like LINQ for aggregation, ensuring that the function remains expressive and maintainable while tests continue passing.
These steps in the TDD workflow have shown how test-first development can clarify requirements, ensure accuracy, and guide continuous improvement. This foundation prepares you for practice exercises aimed at reinforcing these techniques, highlighting how TDD fosters clarity, reliability, and robustness in software development.