.NET Test-Driven Development
Introduction
Test-Driven Development (TDD) is a software development approach where you write tests before writing the actual code. This methodology emphasizes a short development cycle: first, you write a failing test for the functionality you want to implement, then you write just enough code to make that test pass, and finally, you refactor your code while ensuring tests still pass.
In the .NET ecosystem, TDD is widely adopted as it leads to better code design, easier maintenance, and fewer bugs. This guide will introduce you to TDD concepts and show you how to implement them using .NET's testing frameworks.
What is Test-Driven Development?
Test-Driven Development follows a simple yet powerful cycle known as Red-Green-Refactor:
- Red: Write a failing test that defines a function or improvements to a function
- Green: Write the minimum amount of code necessary to make the test pass
- Refactor: Improve the code while ensuring the tests still pass
The benefits of TDD include:
- Code that meets requirements because tests document what the code should do
- More maintainable and cleaner code
- Built-in regression testing
- Lower debugging time
- Higher confidence when refactoring
Setting Up Your .NET Testing Environment
Before diving into TDD, you need to set up a testing environment. The most popular testing frameworks for .NET are:
- MSTest: Microsoft's built-in testing framework
- NUnit: A widely-used open-source testing framework
- xUnit: A more modern testing framework popular in the .NET community
For this tutorial, we'll use xUnit, but the concepts apply to any framework.
Creating a Test Project
First, let's create a solution with both a class library and a test project:
dotnet new sln -n ShoppingCartApp
dotnet new classlib -n ShoppingCart
dotnet new xunit -n ShoppingCart.Tests
dotnet sln ShoppingCartApp.sln add ShoppingCart/ShoppingCart.csproj
dotnet sln ShoppingCartApp.sln add ShoppingCart.Tests/ShoppingCart.Tests.csproj
dotnet add ShoppingCart.Tests/ShoppingCart.Tests.csproj reference ShoppingCart/ShoppingCart.csproj
Now we're ready to start practicing TDD with a simple shopping cart example.
The TDD Process with a Shopping Cart Example
Let's implement a simple shopping cart using TDD.
Step 1: Write a Failing Test (Red)
Our first requirement is to create a cart that can add items. Let's write a test for this:
using Xunit;
using ShoppingCart;
namespace ShoppingCart.Tests
{
public class CartTests
{
[Fact]
public void AddItem_WhenCalled_IncreasesItemCount()
{
// Arrange
var cart = new Cart();
var item = new Item { Id = 1, Name = "Test Item", Price = 10.0m };
// Act
cart.AddItem(item);
// Assert
Assert.Equal(1, cart.ItemCount);
}
}
}
If we try to run this test, it will fail because we haven't created the Cart
and Item
classes yet.
Step 2: Write Just Enough Code to Pass (Green)
Now let's implement the minimum code to make the test pass:
In ShoppingCart
project, create Item.cs
:
namespace ShoppingCart
{
public class Item
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
}
And Cart.cs
:
using System.Collections.Generic;
namespace ShoppingCart
{
public class Cart
{
private List<Item> _items = new List<Item>();
public int ItemCount => _items.Count;
public void AddItem(Item item)
{
_items.Add(item);
}
}
}
Now when we run our test, it should pass:
dotnet test
Output:
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1
Step 3: Refactor While Keeping Tests Green
Our code is simple enough right now, so there's not much to refactor. Let's continue with the next requirement.
Adding More Functionality Through TDD
Let's say we want to calculate the total price of items in the cart. Following TDD, we first write a failing test:
[Fact]
public void GetTotalPrice_WithMultipleItems_ReturnsCorrectSum()
{
// Arrange
var cart = new Cart();
cart.AddItem(new Item { Id = 1, Name = "Item 1", Price = 10.0m });
cart.AddItem(new Item { Id = 2, Name = "Item 2", Price = 20.0m });
// Act
decimal total = cart.GetTotalPrice();
// Assert
Assert.Equal(30.0m, total);
}
Running this test will fail because we haven't implemented the GetTotalPrice()
method yet.
Now let's implement it:
public decimal GetTotalPrice()
{
decimal total = 0;
foreach (var item in _items)
{
total += item.Price;
}
return total;
}
Or more concisely using LINQ:
public decimal GetTotalPrice() => _items.Sum(item => item.Price);
Don't forget to add using System.Linq;
at the top of your file if you use LINQ.
Run the test again, and it should pass now.
Implementing Discount Functionality
Let's implement a discount feature using TDD. First, a test for a percentage discount:
[Fact]
public void ApplyDiscount_PercentageDiscount_ReducesTotalPrice()
{
// Arrange
var cart = new Cart();
cart.AddItem(new Item { Id = 1, Name = "Item 1", Price = 100.0m });
// Act
cart.ApplyDiscount(10); // 10% discount
// Assert
Assert.Equal(90.0m, cart.GetTotalPrice());
}
Now implement the ApplyDiscount
method:
private decimal _discountPercentage = 0;
public void ApplyDiscount(decimal percentage)
{
_discountPercentage = percentage;
}
public decimal GetTotalPrice()
{
decimal subtotal = _items.Sum(item => item.Price);
decimal discount = subtotal * _discountPercentage / 100;
return subtotal - discount;
}
Run the test again, and it should pass.
Real-World TDD Example: Shopping Cart Checkout Process
Let's create a more complex example that simulates a checkout process. This will demonstrate how TDD helps build complex systems step by step.
First, Test for Order Creation
[Fact]
public void CreateOrder_WithValidCart_ReturnsOrderWithCorrectTotal()
{
// Arrange
var cart = new Cart();
cart.AddItem(new Item { Id = 1, Name = "Laptop", Price = 1000.0m });
cart.AddItem(new Item { Id = 2, Name = "Mouse", Price = 25.0m });
var checkoutService = new CheckoutService();
// Act
var order = checkoutService.CreateOrder(cart, "[email protected]");
// Assert
Assert.Equal(1025.0m, order.TotalAmount);
Assert.Equal("[email protected]", order.CustomerEmail);
Assert.Equal(2, order.ItemCount);
}
Now we need to implement the Order
class and CheckoutService
:
public class Order
{
public string OrderId { get; set; }
public string CustomerEmail { get; set; }
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public DateTime OrderDate { get; set; }
}
public class CheckoutService
{
public Order CreateOrder(Cart cart, string customerEmail)
{
return new Order
{
OrderId = Guid.NewGuid().ToString(),
CustomerEmail = customerEmail,
TotalAmount = cart.GetTotalPrice(),
ItemCount = cart.ItemCount,
OrderDate = DateTime.Now
};
}
}
Test for Order Validation
Next, let's add validation to ensure we can't create orders with empty carts:
[Fact]
public void CreateOrder_WithEmptyCart_ThrowsException()
{
// Arrange
var cart = new Cart();
var checkoutService = new CheckoutService();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
checkoutService.CreateOrder(cart, "[email protected]"));
Assert.Contains("cannot be empty", exception.Message);
}
Now update the CheckoutService
class to handle validation:
public Order CreateOrder(Cart cart, string customerEmail)
{
if (cart.ItemCount == 0)
{
throw new InvalidOperationException("Shopping cart cannot be empty when creating an order.");
}
return new Order
{
OrderId = Guid.NewGuid().ToString(),
CustomerEmail = customerEmail,
TotalAmount = cart.GetTotalPrice(),
ItemCount = cart.ItemCount,
OrderDate = DateTime.Now
};
}
This is the essence of TDD: you continuously evolve your system by adding tests that describe new behavior, then implementing that behavior in the simplest way possible.
Best Practices for TDD in .NET
-
Keep tests small and focused: Each test should verify one specific behavior.
-
Use descriptive test names: The test name should describe what the test is verifying.
-
Follow the Arrange-Act-Assert pattern: Structure your tests clearly with setup, action, and verification sections.
-
Test behavior, not implementation: Your tests should focus on what a method does, not how it does it.
-
Use test data builders or fixtures: For complex object setup, create helper methods or classes.
-
Maintain test independence: Each test should be able to run independently of others.
-
Mock external dependencies: Use mocking frameworks like Moq or NSubstitute to isolate your tests.
Example: Using Dependency Injection and Mocking
Let's enhance our checkout service to include payment processing:
public interface IPaymentProcessor
{
bool ProcessPayment(Order order, string creditCardNumber);
}
public class CheckoutService
{
private readonly IPaymentProcessor _paymentProcessor;
public CheckoutService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}
public Order CreateOrder(Cart cart, string customerEmail, string creditCardNumber)
{
if (cart.ItemCount == 0)
{
throw new InvalidOperationException("Shopping cart cannot be empty when creating an order.");
}
var order = new Order
{
OrderId = Guid.NewGuid().ToString(),
CustomerEmail = customerEmail,
TotalAmount = cart.GetTotalPrice(),
ItemCount = cart.ItemCount,
OrderDate = DateTime.Now
};
bool paymentSuccessful = _paymentProcessor.ProcessPayment(order, creditCardNumber);
if (!paymentSuccessful)
{
throw new InvalidOperationException("Payment processing failed");
}
return order;
}
}
Now let's test it using the Moq mocking library (you'll need to add the Moq NuGet package to your test project):
using Moq;
[Fact]
public void CreateOrder_WithSuccessfulPayment_ReturnsOrder()
{
// Arrange
var cart = new Cart();
cart.AddItem(new Item { Id = 1, Name = "Laptop", Price = 1000.0m });
var mockPaymentProcessor = new Mock<IPaymentProcessor>();
mockPaymentProcessor.Setup(p => p.ProcessPayment(It.IsAny<Order>(), "4111111111111111"))
.Returns(true);
var checkoutService = new CheckoutService(mockPaymentProcessor.Object);
// Act
var order = checkoutService.CreateOrder(cart, "[email protected]", "4111111111111111");
// Assert
Assert.Equal(1000.0m, order.TotalAmount);
mockPaymentProcessor.Verify(p => p.ProcessPayment(It.IsAny<Order>(), "4111111111111111"), Times.Once);
}
[Fact]
public void CreateOrder_WithFailedPayment_ThrowsException()
{
// Arrange
var cart = new Cart();
cart.AddItem(new Item { Id = 1, Name = "Laptop", Price = 1000.0m });
var mockPaymentProcessor = new Mock<IPaymentProcessor>();
mockPaymentProcessor.Setup(p => p.ProcessPayment(It.IsAny<Order>(), "4111111111111111"))
.Returns(false);
var checkoutService = new CheckoutService(mockPaymentProcessor.Object);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
checkoutService.CreateOrder(cart, "[email protected]", "4111111111111111"));
Assert.Contains("Payment processing failed", exception.Message);
}
Summary
Test-Driven Development is a powerful methodology that helps developers create more reliable, maintainable code. In this guide, we've explored:
- The core TDD cycle: Red-Green-Refactor
- How to set up a .NET test project
- Step-by-step implementation of a shopping cart using TDD
- Creating more complex systems through incremental TDD
- Using dependency injection and mocking for better testability
By following TDD principles in your .NET projects, you'll create code that's easier to maintain, has fewer bugs, and better meets requirements. Remember that TDD is a skill that improves with practice, so continue applying these principles in your projects.
Additional Resources and Exercises
Resources
- Microsoft's documentation on unit testing in .NET
- xUnit documentation
- Books: "Test-Driven Development: By Example" by Kent Beck
Exercises
-
Shopping Cart Extensions: Add functionality to remove items from the cart, update item quantities, and apply different types of discounts.
-
Order History Service: Implement a service that stores orders and allows retrieval by customer email using TDD.
-
Refactoring Challenge: Take an existing piece of code without tests, write tests for it, and then refactor it using TDD principles.
-
Integration Test: Create an integration test that verifies your shopping cart system works with a real (or in-memory) database.
-
Performance Test: Write tests that verify your cart can handle a large number of items efficiently.
By consistently practicing TDD, you'll develop an intuition for writing testable code and build more robust software systems.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)