
Learn test-driven development in C# with xUnit and Moq. Step-by-step TDD tutorial with runnable code, best practices, and pitfalls. Start writing tests first today.
Test driven development in C# flips the usual workflow on its head: you write a failing test first, then write just enough production code to make it pass, then clean up. It sounds slow. In practice, teams that adopt TDD with xUnit and Moq ship fewer bugs, refactor with confidence, and end up with a design that is naturally decoupled and testable. This guide walks you through the full red-green-refactor cycle in .NET, with runnable examples, mocking with Moq, and the mistakes that trip up most developers when they first try TDD.
What Is Test Driven Development in C#?
TDD is a development discipline with three short, repeating steps:
- Red – write a small test that describes behaviour you want. Run it. It fails (often it doesn't even compile).
- Green – write the simplest production code that makes the test pass. Resist the urge to be clever.
- Refactor – improve the structure of both test and production code while the tests stay green.
The why matters more than the mechanics. Writing the test first forces you to decide what the code should do and how it will be called before you get lost in implementation details. It also guarantees every line of production code exists because a test demanded it, so you never end up with an untested "just in case" branch. And because the test was written against a class that didn't exist yet, you are designing the public API from the caller's point of view, which produces cleaner interfaces.
Setting Up xUnit and Moq in .NET
xUnit is the default test framework for modern .NET (Microsoft uses it for the runtime itself), and Moq is the most widely used C# mocking framework. Create a solution with a class library and a test project:
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
dotnet add OrderSystem.Tests package FluentAssertions
FluentAssertions is optional but produces far more readable failure messages than Assert.Equal. Run dotnet test once to confirm the default sample test passes, then delete it.
Your First TDD Cycle: Red, Green, Refactor
We will build an OrderService that calculates totals and places orders. Start with the simplest behaviour: an empty order has a total of zero.
Step 1 – Red: write the failing test
using FluentAssertions;
using OrderSystem.Core;
using Xunit;
namespace OrderSystem.Tests;
public class OrderTests
{
[Fact]
public void Total_WhenOrderIsEmpty_ReturnsZero()
{
var order = new Order();
order.Total.Should().Be(0m);
}
}
This won't compile because Order doesn't exist. A compile error is a red test. That's expected and correct in TDD.
Step 2 – Green: the simplest thing that works
namespace OrderSystem.Core;
public class Order
{
public decimal Total => 0m;
}
Yes, hard-coding zero feels wrong. But the test only asks for zero, and TDD says don't write code you haven't been forced to write. The next test will force a real implementation.
Step 3 – Red again: add line items
[Fact]
public void Total_WithMultipleLines_SumsPriceTimesQuantity()
{
var order = new Order();
order.AddLine("Keyboard", unitPrice: 50m, quantity: 2);
order.AddLine("Mouse", unitPrice: 25m, quantity: 1);
order.Total.Should().Be(125m);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void AddLine_WithNonPositiveQuantity_Throws(int quantity)
{
var order = new Order();
var act = () => order.AddLine("Keyboard", 50m, quantity);
act.Should().Throw<ArgumentOutOfRangeException>();
}
Notice the [Theory] with [InlineData]. xUnit theories let you run the same test across several inputs without copy-pasting, which keeps edge-case coverage cheap.
Step 4 – Green: real implementation
namespace OrderSystem.Core;
public record OrderLine(string Product, decimal UnitPrice, int Quantity)
{
public decimal LineTotal => UnitPrice * Quantity;
}
public class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.LineTotal);
public void AddLine(string product, decimal unitPrice, int quantity)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
_lines.Add(new OrderLine(product, unitPrice, quantity));
}
}
All three tests pass. The hard-coded zero disappeared naturally because a stronger test replaced it. That is the TDD rhythm: each test tightens the specification, and the implementation grows only as far as the tests require.
Mocking Dependencies with Moq in C#
Pure calculation logic is easy to test. Real services talk to databases, payment gateways, and email providers. You do not want your unit tests hitting Stripe. This is where Moq comes in: it creates fake implementations of interfaces so you can test your class in isolation and verify how it interacts with its collaborators.
Define the dependencies as interfaces first (the test drives this design decision):
namespace OrderSystem.Core;
public interface IPaymentGateway
{
Task<bool> ChargeAsync(decimal amount, string customerId);
}
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public interface IEmailSender
{
Task SendConfirmationAsync(string customerId, decimal total);
}
Red: describe the happy path
using Moq;
public class OrderServiceTests
{
private readonly Mock<IPaymentGateway> _gateway = new();
private readonly Mock<IOrderRepository> _repo = new();
private readonly Mock<IEmailSender> _email = new();
private readonly OrderService _sut;
public OrderServiceTests()
{
_sut = new OrderService(_gateway.Object, _repo.Object, _email.Object);
}
private static Order OrderWorth(decimal amount)
{
var order = new Order();
order.AddLine("Item", amount, 1);
return order;
}
[Fact]
public async Task PlaceOrderAsync_WhenPaymentSucceeds_SavesOrderAndSendsEmail()
{
var order = OrderWorth(125m);
_gateway.Setup(g => g.ChargeAsync(125m, "cust-1")).ReturnsAsync(true);
var result = await _sut.PlaceOrderAsync(order, "cust-1");
result.Should().BeTrue();
_repo.Verify(r => r.SaveAsync(order), Times.Once);
_email.Verify(e => e.SendConfirmationAsync("cust-1", 125m), Times.Once);
}
[Fact]
public async Task PlaceOrderAsync_WhenPaymentFails_DoesNotSaveOrEmail()
{
var order = OrderWorth(125m);
_gateway.Setup(g => g.ChargeAsync(It.IsAny<decimal>(), It.IsAny<string>()))
.ReturnsAsync(false);
var result = await _sut.PlaceOrderAsync(order, "cust-1");
result.Should().BeFalse();
_repo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Never);
_email.Verify(e => e.SendConfirmationAsync(It.IsAny<string>(), It.IsAny<decimal>()), Times.Never);
}
[Fact]
public async Task PlaceOrderAsync_WhenOrderIsEmpty_ThrowsAndNeverCharges()
{
var act = () => _sut.PlaceOrderAsync(new Order(), "cust-1");
await act.Should().ThrowAsync<InvalidOperationException>();
_gateway.Verify(g => g.ChargeAsync(It.IsAny<decimal>(), It.IsAny<string>()), Times.Never);
}
}
Key Moq concepts in play:
Setup(...).ReturnsAsync(...)tells the mock what to return for a given call. This is a stub.Verify(..., Times.Once)asserts an interaction happened. This is a mock in the strict sense. Use it for side effects that matter (saving, emailing), not for every call.It.IsAny<T>()matches any argument. Prefer concrete values when the argument is part of the behaviour you're specifying, as in the first test.- xUnit creates a new instance of the test class for every test, so mocks initialised in the constructor are isolated between tests. No shared-state surprises.
Green: implement OrderService
namespace OrderSystem.Core;
public class OrderService
{
private readonly IPaymentGateway _gateway;
private readonly IOrderRepository _repo;
private readonly IEmailSender _email;
public OrderService(IPaymentGateway gateway, IOrderRepository repo, IEmailSender email)
{
_gateway = gateway;
_repo = repo;
_email = email;
}
public async Task<bool> PlaceOrderAsync(Order order, string customerId)
{
if (order.Lines.Count == 0)
throw new InvalidOperationException("Cannot place an empty order.");
var paid = await _gateway.ChargeAsync(order.Total, customerId);
if (!paid)
return false;
await _repo.SaveAsync(order);
await _email.SendConfirmationAsync(customerId, order.Total);
return true;
}
}
Run dotnet test and everything is green. Notice that the constructor injection, the interfaces, and the guard clause all came from the tests rather than from an upfront design document.
Refactor with a safety net
Now that behaviour is locked down, refactor freely. Perhaps the email should not block the order if it fails, so you wrap it in a try/catch and log. Write a test for that first (_email.Setup(...).ThrowsAsync(new SmtpException()), then assert the result is still true), watch it fail, then make the change. The existing tests guarantee you haven't broken the payment or persistence paths.
TDD Best Practices in C#
- Keep tests tiny. One behaviour per test. If your test name needs the word "and", split it.
- Name tests as specifications.
Method_Scenario_ExpectedResultreads like documentation and makes failures self-explanatory in CI output. - Follow Arrange-Act-Assert. Blank lines between the three sections make tests scannable.
- Mock only what you own or what crosses a boundary. Mock
IPaymentGateway; don't mockList<T>orDateTime. Wrap third-party SDKs in your own interface, then mock that. - Use
Times.Neverdeliberately. Verifying that a charge did not happen for an empty order is often the most valuable assertion in the file. - Prefer
MockBehavior.Strictfor critical services.new Mock<IPaymentGateway>(MockBehavior.Strict)throws on any call you didn't set up, catching unexpected side effects. - Run tests constantly.
dotnet watch testre-runs on every save, which keeps the red-green loop measured in seconds.
Common TDD Pitfalls (and How to Avoid Them)
- Writing the implementation, then "back-filling" tests. That's test-after, not TDD. You lose the design feedback and tend to test what the code does rather than what it should do.
- Over-mocking. If a test has six
Setupcalls, the class under test has too many dependencies. Listen to the test: it is telling you to split the class. - Verifying everything. Asserting that a getter was called twice makes tests brittle. Verify outcomes and meaningful side effects, not implementation choreography.
- Testing private methods. Private methods are implementation detail. Test through the public API; if a private method is complex enough to need its own tests, extract it into its own class.
- Skipping the refactor step. Green is not done. Duplicated setup, unclear names, and long methods accumulate quickly if you jump straight to the next red test.
- Mocking concrete classes. Moq can only override
virtualmembers, so mocking a concrete class often silently runs real code. Depend on interfaces. - Ignoring async. Always return
Taskfrom async tests andawaitthe call. An unawaitedasync voidtest will report a pass even when the assertion throws.
Conclusion: Key Takeaways for Test Driven Development in C#
Test driven development in C# is less about testing and more about design under pressure of a fast feedback loop. With xUnit providing [Fact] and [Theory] for concise specifications, and Moq isolating your class from databases and payment gateways, the red-green-refactor cycle becomes a practical daily habit rather than a textbook ideal.
- Write the failing test first; a compile error counts as red.
- Write the simplest code to go green, even if it feels naive. The next test will push it forward.
- Refactor only when green, and use the tests as your safety net.
- Use Moq to stub inputs (
Setup) and verify meaningful side effects (Verify), and let painful mocking tell you when a class has too many responsibilities. - Keep tests small, well-named, and fast enough to run on every save.
Pick one small class in your current project, delete nothing, and add the next feature test-first. After a week of TDD with xUnit and Moq, you'll find it hard to go back.
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