
Learn test driven development in C# with xUnit and Moq. Step-by-step TDD tutorial with real code examples, best practices, and pitfalls. Start testing today!
Test driven development in C# is one of the most in-demand skills for .NET developers in 2026 — and for good reason. Teams that practice TDD ship fewer production bugs, refactor with confidence, and produce code that is easier to maintain. Yet many developers still write tests after the code (if at all), missing the real benefit: TDD is a design technique first and a testing technique second.
In this tutorial, you'll learn test driven development in C# from the ground up using xUnit (the most popular .NET testing framework) and Moq (the most widely used mocking library). We'll build a real feature — an order processing service — by writing the tests first and the code second, following the classic Red-Green-Refactor cycle. Along the way, we'll cover best practices, common pitfalls, and the reasoning behind each step.
What Is Test Driven Development in C#?
Test driven development (TDD) is a workflow in which you write a failing unit test before writing the production code that makes it pass. The cycle has three steps:
- Red — Write a small test for behavior that doesn't exist yet. Run it. It must fail (that proves the test can fail, which proves it's actually testing something).
- Green — Write the simplest production code that makes the test pass. No gold-plating, no "while I'm here" extras.
- Refactor — Clean up the code (and the tests) while keeping everything green. Duplication is removed, names improve, structure emerges.
Why does writing tests first matter? Because it forces you to design your API from the caller's perspective before you commit to an implementation. Code that is hard to test is almost always code with hidden dependencies, tangled responsibilities, or poor abstractions. TDD surfaces those design problems in minutes instead of months.
Setting Up xUnit and Moq in a .NET Project
You need the .NET SDK (8 or later — these steps work identically on .NET 8, 9, and 10). Create a solution with a class library and an xUnit test project:
// Run these in your terminal:
// dotnet new sln -n OrderSystem
// dotnet new classlib -n OrderSystem.Core
// dotnet new xunit -n OrderSystem.Tests
// dotnet sln add OrderSystem.Core OrderSystem.Tests
// dotnet add OrderSystem.Tests reference OrderSystem.Core
// dotnet add OrderSystem.Tests package Moq
Why xUnit over MSTest or NUnit? xUnit is the framework Microsoft itself uses for .NET runtime and ASP.NET Core tests. It creates a new instance of the test class for every test method, which eliminates shared-state bugs between tests by design. Why Moq? It lets you create fake implementations of interfaces in one line, so you can test a class in complete isolation from its dependencies — databases, payment gateways, email servers — without spinning any of them up.
Your First TDD Cycle: Red, Green, Refactor
Let's build an OrderService that processes customer orders. Requirements: it must reject empty orders, charge the customer through a payment gateway, and save the order to a repository.
Step 1: Red — Write a Failing Test First
We start with the simplest rule: an order with no items must be rejected. Notice we're writing this test against classes that don't exist yet — that's intentional. The test defines the API we wish we had.
using Moq;
using Xunit;
namespace OrderSystem.Tests;
public class OrderServiceTests
{
[Fact]
public void ProcessOrder_WithNoItems_ThrowsInvalidOperationException()
{
// Arrange
var paymentGateway = new Mock<IPaymentGateway>();
var orderRepository = new Mock<IOrderRepository>();
var sut = new OrderService(paymentGateway.Object, orderRepository.Object);
var emptyOrder = new Order { Items = new List<OrderItem>() };
// Act & Assert
Assert.Throws<InvalidOperationException>(() => sut.ProcessOrder(emptyOrder));
}
}
This won't even compile — and in TDD, a compilation failure counts as Red. The compiler is telling us exactly what to create next.
Step 2: Green — Write Just Enough Code to Pass
Now we create the minimal production code in OrderSystem.Core:
namespace OrderSystem.Core;
public class Order
{
public int Id { get; set; }
public List<OrderItem> Items { get; set; } = new();
public decimal Total => Items.Sum(i => i.Price * i.Quantity);
}
public class OrderItem
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Quantity { get; set; }
}
public interface IPaymentGateway
{
PaymentResult Charge(decimal amount);
}
public record PaymentResult(bool Success, string? TransactionId);
public interface IOrderRepository
{
void Save(Order order);
}
public class OrderService
{
private readonly IPaymentGateway _paymentGateway;
private readonly IOrderRepository _orderRepository;
public OrderService(IPaymentGateway paymentGateway, IOrderRepository orderRepository)
{
_paymentGateway = paymentGateway;
_orderRepository = orderRepository;
}
public void ProcessOrder(Order order)
{
if (order.Items.Count == 0)
throw new InvalidOperationException("Order must contain at least one item.");
}
}
Run dotnet test — green. Notice ProcessOrder doesn't charge anyone or save anything yet. That's correct TDD discipline: no test demands that behavior yet, so we don't write it.
Step 3: Drive Out the Real Behavior with Moq
Next requirement: a valid order must charge the customer for the correct total. This is where Moq shines — we verify that our service interacted with its dependency correctly, without any real payment system:
[Fact]
public void ProcessOrder_WithValidOrder_ChargesCustomerForOrderTotal()
{
// Arrange
var paymentGateway = new Mock<IPaymentGateway>();
paymentGateway
.Setup(g => g.Charge(It.IsAny<decimal>()))
.Returns(new PaymentResult(true, "TXN-123"));
var orderRepository = new Mock<IOrderRepository>();
var sut = new OrderService(paymentGateway.Object, orderRepository.Object);
var order = new Order
{
Items = new List<OrderItem>
{
new() { Name = "Keyboard", Price = 79.99m, Quantity = 1 },
new() { Name = "Mouse", Price = 25.00m, Quantity = 2 }
}
};
// Act
sut.ProcessOrder(order);
// Assert: verify the gateway was charged exactly once for 129.99
paymentGateway.Verify(g => g.Charge(129.99m), Times.Once);
}
[Fact]
public void ProcessOrder_WhenPaymentSucceeds_SavesOrder()
{
var paymentGateway = new Mock<IPaymentGateway>();
paymentGateway
.Setup(g => g.Charge(It.IsAny<decimal>()))
.Returns(new PaymentResult(true, "TXN-123"));
var orderRepository = new Mock<IOrderRepository>();
var sut = new OrderService(paymentGateway.Object, orderRepository.Object);
var order = new Order
{
Items = new List<OrderItem> { new() { Name = "Keyboard", Price = 50m, Quantity = 1 } }
};
sut.ProcessOrder(order);
orderRepository.Verify(r => r.Save(order), Times.Once);
}
[Fact]
public void ProcessOrder_WhenPaymentFails_DoesNotSaveOrderAndThrows()
{
var paymentGateway = new Mock<IPaymentGateway>();
paymentGateway
.Setup(g => g.Charge(It.IsAny<decimal>()))
.Returns(new PaymentResult(false, null));
var orderRepository = new Mock<IOrderRepository>();
var sut = new OrderService(paymentGateway.Object, orderRepository.Object);
var order = new Order
{
Items = new List<OrderItem> { new() { Name = "Keyboard", Price = 50m, Quantity = 1 } }
};
Assert.Throws<PaymentFailedException>(() => sut.ProcessOrder(order));
orderRepository.Verify(r => r.Save(It.IsAny<Order>()), Times.Never);
}
These three tests fail (Red). Now we make them pass (Green):
public class PaymentFailedException : Exception
{
public PaymentFailedException(string message) : base(message) { }
}
public class OrderService
{
private readonly IPaymentGateway _paymentGateway;
private readonly IOrderRepository _orderRepository;
public OrderService(IPaymentGateway paymentGateway, IOrderRepository orderRepository)
{
_paymentGateway = paymentGateway;
_orderRepository = orderRepository;
}
public void ProcessOrder(Order order)
{
if (order.Items.Count == 0)
throw new InvalidOperationException("Order must contain at least one item.");
var result = _paymentGateway.Charge(order.Total);
if (!result.Success)
throw new PaymentFailedException($"Payment declined for order {order.Id}.");
_orderRepository.Save(order);
}
}
All four tests pass. The payment-failure test is the one that earns its keep: it pins down the order of operations (never save an unpaid order), which is exactly the kind of business rule that regresses silently when someone refactors later.
Step 4: Refactor — Clean Up the Tests Too
Our tests repeat mock setup. Refactoring applies to test code as much as production code — duplicated arrange blocks are the #1 reason teams abandon test suites. Extract a builder-style helper:
public class OrderServiceTests
{
private readonly Mock<IPaymentGateway> _paymentGateway = new();
private readonly Mock<IOrderRepository> _orderRepository = new();
private OrderService CreateSut() => new(_paymentGateway.Object, _orderRepository.Object);
private static Order ValidOrder(decimal price = 50m) => new()
{
Items = new List<OrderItem> { new() { Name = "Keyboard", Price = price, Quantity = 1 } }
};
// Tests now shrink to a few readable lines each...
}
Because xUnit creates a fresh instance of the test class per test, those field initializers give every test brand-new mocks automatically — no [SetUp] attribute needed and no shared-state leakage.
TDD in C# Best Practices
- Test behavior, not implementation. Assert on outcomes (an exception thrown, a repository saved, a value returned), not on private methods or internal state. Tests coupled to implementation break on every refactor and train the team to ignore them.
- One logical assertion per test. A test named
ProcessOrder_WhenPaymentFails_DoesNotSaveOrderthat also checks logging is lying about its name. MultipleAssertcalls are fine if they verify one behavior. - Use the naming convention
Method_Scenario_ExpectedResult. When a test fails in CI six months from now, the name alone should tell you what broke. - Prefer
Times.Onceand specific arguments inVerify.paymentGateway.Verify(g => g.Charge(129.99m), Times.Once)catches double-charging bugs thatIt.IsAny<decimal>()would let through. - Use
[Theory]with[InlineData]for input variations. Testing boundary values (0 items, 1 item, negative price) with parameterized tests keeps the suite small and the coverage broad. - Keep the cycle short. If you've written more than ~10 lines of production code without running the tests, you've left TDD. Small steps are the whole point.
Common Pitfalls (and How to Avoid Them)
- Mocking everything. Moq is for dependencies with side effects or external I/O — databases, HTTP clients, clocks. Don't mock plain data objects or pure logic; use the real thing. Over-mocked tests verify your mocks, not your code.
- Skipping the Red step. If you never see the test fail, you don't know it can fail. A test that passes against an empty method is worse than no test — it's false confidence.
- Mocking classes instead of interfaces. Moq can only override
virtualmembers on classes. Design against interfaces (IPaymentGateway, notStripeGateway) and inject them via constructor — which also makes your code DI-container friendly. - Strict mocks by default.
MockBehavior.Strictfails on any un-setup call, which couples tests tightly to implementation details. Prefer the default loose behavior plus targetedVerifycalls. - Testing
DateTime.Nowdirectly. Time is a dependency too. On .NET 8+, inject the built-inTimeProviderabstraction and useFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testingin tests. - Chasing 100% coverage. Coverage measures what executed, not what was verified. 80% coverage of real business rules beats 100% coverage full of assertion-free tests.
Conclusion: Why Test Driven Development in C# Pays Off
Test driven development in C# isn't about writing more tests — it's about letting tests drive better design. By writing the test first, you were forced to invent IPaymentGateway and IOrderRepository as clean, injectable abstractions before a single line of OrderService existed. That's the hidden payoff: TDD produces loosely coupled, dependency-injected code as a natural side effect.
Key takeaways:
- Follow Red-Green-Refactor in small steps: failing test, minimal code, clean up — repeat.
- Use xUnit for isolated, per-test instances and Moq to fake external dependencies through interfaces.
- Verify interactions that matter (exact amounts,
Times.Once, save-only-after-payment) and assert on behavior, not implementation. - Refactor test code as ruthlessly as production code — readable tests are the ones that survive.
- Watch for the classic pitfalls: over-mocking, skipping Red, strict mocks, and untestable static dependencies like
DateTime.Now.
Start small: pick one class in your current project, write one failing test for its next change, and make it pass. Within a week the cycle becomes muscle memory — and within a month, you'll wonder how you ever refactored without a safety net.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment