Skip to main content

.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:

  1. Red: Write a failing test that defines a function or improvements to a function
  2. Green: Write the minimum amount of code necessary to make the test pass
  3. 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:

bash
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:

csharp
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:

csharp
namespace ShoppingCart
{
public class Item
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
}

And Cart.cs:

csharp
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:

bash
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:

csharp
[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:

csharp
public decimal GetTotalPrice()
{
decimal total = 0;
foreach (var item in _items)
{
total += item.Price;
}
return total;
}

Or more concisely using LINQ:

csharp
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:

csharp
[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:

csharp
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

csharp
[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:

csharp
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:

csharp
[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:

csharp
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

  1. Keep tests small and focused: Each test should verify one specific behavior.

  2. Use descriptive test names: The test name should describe what the test is verifying.

  3. Follow the Arrange-Act-Assert pattern: Structure your tests clearly with setup, action, and verification sections.

  4. Test behavior, not implementation: Your tests should focus on what a method does, not how it does it.

  5. Use test data builders or fixtures: For complex object setup, create helper methods or classes.

  6. Maintain test independence: Each test should be able to run independently of others.

  7. 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:

csharp
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):

csharp
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

Exercises

  1. Shopping Cart Extensions: Add functionality to remove items from the cart, update item quantities, and apply different types of discounts.

  2. Order History Service: Implement a service that stores orders and allows retrieval by customer email using TDD.

  3. Refactoring Challenge: Take an existing piece of code without tests, write tests for it, and then refactor it using TDD principles.

  4. Integration Test: Create an integration test that verifies your shopping cart system works with a real (or in-memory) database.

  5. 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! :)