
Learn test-driven development in C# with xUnit and Moq. Step-by-step TDD tutorial with runnable code, mocking, best practices. Start writing tests first!
What Is Test-Driven Development in C#?
Test-driven development (TDD) in C# flips the traditional workflow on its head: you write a failing unit test first, then write just enough production code to make it pass, and finally refactor with confidence. Combined with xUnit (the most popular .NET testing framework) and Moq (the go-to mocking library), test-driven development in C# gives you a fast feedback loop, a living specification of your code's behavior, and a safety net that makes refactoring painless instead of terrifying.
In this tutorial you'll build a small but realistic feature — an order service with a payment gateway dependency — entirely test-first. Along the way we'll cover the red-green-refactor cycle, mocking with Moq, best practices, and the pitfalls that trip up most teams when they adopt TDD.
Why Write Tests First? The Case for TDD
Writing tests after the code feels natural, so why reverse it? Because test-first changes what you build, not just how you verify it:
- Design pressure. Code that's hard to test is usually badly designed — hidden dependencies, tight coupling, god classes. Writing the test first forces you to design the API from the caller's perspective before you commit to an implementation.
- Executable specification. A well-named test suite documents exactly what the system does. Unlike comments and wikis, tests can't silently go stale — they fail.
- Regression safety. Every behavior has a test guarding it, so refactoring and upgrading dependencies stops being a leap of faith.
- Smaller steps, fewer debugging sessions. When a test fails, the cause is almost always the last few lines you wrote. Contrast that with debugging a feature you wrote over two days and tested at the end.
The cost is real too: TDD is slower on day one and requires discipline. The payoff compounds over weeks and months, which is why it's most valuable on code you'll maintain, and least valuable on throwaway scripts.
Setting Up xUnit and Moq
Create a solution with a class library and a test project. From the terminal:
// Run these in your terminal (not C#):
// dotnet new classlib -n OrderProcessing
// dotnet new xunit -n OrderProcessing.Tests
// dotnet add OrderProcessing.Tests reference OrderProcessing
// cd OrderProcessing.Tests
// dotnet add package Moq
The xUnit template already includes xunit and the Visual Studio test runner. Adding Moq gives you dynamic mock objects so you can test a class in isolation from its dependencies. That's the entire setup — run dotnet test to confirm the empty project builds.
The Red-Green-Refactor Cycle Explained
TDD is a loop with three steps, repeated every few minutes:
- Red: Write a small test for behavior that doesn't exist yet. Run it and watch it fail. A failing test proves the test can actually detect the bug it's guarding against.
- Green: Write the simplest code that makes the test pass. Resist the urge to build ahead.
- Refactor: Clean up duplication and improve names — in both production and test code — while all tests stay green.
Let's apply it for real.
Step 1 — Red: Write the First Failing Test
We're building an OrderService that charges a customer through a payment gateway. Start with the test, even though none of these types exist yet:
using Moq;
using Xunit;
namespace OrderProcessing.Tests;
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_WithValidOrder_ChargesPaymentGateway()
{
// Arrange
var gatewayMock = new Mock<IPaymentGateway>();
gatewayMock
.Setup(g => g.Charge("customer-42", 99.90m))
.Returns(new PaymentResult(Success: true, TransactionId: "tx-1"));
var service = new OrderService(gatewayMock.Object);
var order = new Order("customer-42", 99.90m);
// Act
var result = service.PlaceOrder(order);
// Assert
Assert.True(result.Success);
gatewayMock.Verify(g => g.Charge("customer-42", 99.90m), Times.Once);
}
}
This won't even compile — that's fine. In TDD, a compilation error is a failing test. Notice what the test just decided for us: OrderService takes its gateway through the constructor (dependency injection), the gateway is an interface (so it's mockable), and PlaceOrder returns a result object rather than throwing. We made three design decisions before writing a line of production code.
Step 2 — Green: Make It Pass with Minimal Code
namespace OrderProcessing;
public record Order(string CustomerId, decimal Amount);
public record PaymentResult(bool Success, string? TransactionId);
public record OrderResult(bool Success, string? Message = null);
public interface IPaymentGateway
{
PaymentResult Charge(string customerId, decimal amount);
}
public class OrderService
{
private readonly IPaymentGateway _gateway;
public OrderService(IPaymentGateway gateway)
{
_gateway = gateway;
}
public OrderResult PlaceOrder(Order order)
{
var payment = _gateway.Charge(order.CustomerId, order.Amount);
return new OrderResult(payment.Success);
}
}
Run dotnet test — green. Yes, this implementation is naive. It has no validation, no error handling. That's intentional: each of those behaviors deserves its own red-green cycle, driven by its own test.
Step 3 — Red Again: Drive Out the Edge Cases
What should happen with an invalid amount? Write the test that answers the question:
[Theory]
[InlineData(0)]
[InlineData(-10.50)]
public void PlaceOrder_WithNonPositiveAmount_FailsWithoutCharging(decimal amount)
{
var gatewayMock = new Mock<IPaymentGateway>();
var service = new OrderService(gatewayMock.Object);
var result = service.PlaceOrder(new Order("customer-42", amount));
Assert.False(result.Success);
Assert.Equal("Order amount must be positive.", result.Message);
gatewayMock.Verify(g => g.Charge(It.IsAny<string>(), It.IsAny<decimal>()),
Times.Never);
}
Two xUnit features are doing heavy lifting here. [Theory] with [InlineData] runs the same test with multiple inputs — one test method, two test cases. And Times.Never asserts a negative: the gateway must not be called at all for invalid orders. That's a business rule (never charge a customer for an invalid order) captured as an executable check. Now make it green:
public OrderResult PlaceOrder(Order order)
{
if (order.Amount <= 0)
{
return new OrderResult(false, "Order amount must be positive.");
}
var payment = _gateway.Charge(order.CustomerId, order.Amount);
return payment.Success
? new OrderResult(true)
: new OrderResult(false, "Payment was declined.");
}
Handling Exceptions from Dependencies
Real payment gateways throw — networks fail. Moq can simulate that with Throws, letting you test failure paths that are nearly impossible to trigger reliably against a real service:
[Fact]
public void PlaceOrder_WhenGatewayThrows_ReturnsFailureInsteadOfCrashing()
{
var gatewayMock = new Mock<IPaymentGateway>();
gatewayMock
.Setup(g => g.Charge(It.IsAny<string>(), It.IsAny<decimal>()))
.Throws(new TimeoutException("Gateway unreachable"));
var service = new OrderService(gatewayMock.Object);
var result = service.PlaceOrder(new Order("customer-42", 25m));
Assert.False(result.Success);
Assert.Equal("Payment service is unavailable. Please try again.", result.Message);
}
Watch it fail (the unhandled TimeoutException crashes the test), then wrap the charge call:
try
{
var payment = _gateway.Charge(order.CustomerId, order.Amount);
return payment.Success
? new OrderResult(true)
: new OrderResult(false, "Payment was declined.");
}
catch (TimeoutException)
{
return new OrderResult(false, "Payment service is unavailable. Please try again.");
}
Three cycles in, we have a service with validation, decline handling, and resilience to outages — and every one of those behaviors is pinned down by a test that we watched fail first.
TDD Best Practices in C#
- One behavior per test. A test named
PlaceOrder_WithNonPositiveAmount_FailsWithoutChargingtells you exactly what broke when it fails. A test namedTest1with ten asserts tells you nothing. - Follow Arrange-Act-Assert. Set up, do the thing, check the result. If your test has multiple act/assert sections, split it.
- Mock roles, not data. Mock interfaces that represent collaborators with behavior (gateways, repositories, clocks). Don't mock DTOs, records, or value objects — just construct them.
- Prefer state verification over interaction verification. Assert on return values and observable state when you can; use
Verifyonly when the interaction itself is the requirement (like "never charge an invalid order"). Over-verifying couples tests to implementation details. - Use
MockBehavior.Strictdeliberately. Strict mocks fail on any call you didn't set up, which catches unexpected interactions but makes tests brittle. Default (loose) behavior is the right call for most tests. - Keep the cycle short. If you've been red for more than ten minutes, your step was too big. Delete, take a smaller bite.
- Refactor test code too. Extract builder methods for common setup. Test code is production code for your test suite.
Common Pitfalls (and How to Avoid Them)
- Testing the mock. If your test only verifies that Moq returned what you told it to return, you've tested nothing. Every test should exercise real logic in the system under test.
- Mocking everything. Classic mistake: five mocks per test, each with elaborate setups. That's a sign the class has too many dependencies. Listen to the pain — it's design feedback, which is the whole point of TDD.
- Skipping the "watch it fail" step. A test you never saw red might be passing vacuously — wrong assert, wrong setup, wrong object. Always confirm red before going green.
- Chasing 100% coverage. Coverage measures what ran, not what was verified. Drive tests from behaviors and requirements, and let coverage be a byproduct.
- Static and
new-ed dependencies. Moq can only mock interfaces and virtual members.DateTime.Now, static helpers, and dependencies constructed inside the class are untestable seams — inject aTimeProvider(built into .NET 8+) or an interface instead. - Slow tests. Unit tests that touch databases, files, or the network get skipped by developers within a month. Keep the TDD loop in-memory and milliseconds-fast; save real integrations for a separate integration test suite.
Conclusion: Key Takeaways for Test-Driven Development in C#
Test-driven development in C# with xUnit and Moq is less about testing and more about design under a safety net. The red-green-refactor rhythm — fail first, pass minimally, clean up — produces loosely coupled code with dependency injection baked in, because that's the only kind of code that's easy to test-drive.
- Write the failing test first and watch it fail — that failure is proof your test works.
- Use xUnit's
[Fact]for single cases and[Theory]with[InlineData]for parameterized ones. - Use Moq to isolate the system under test:
Setupto stub returns,Throwsto simulate failures,Verifysparingly for interactions that are genuine requirements. - Keep each cycle small, each test focused on one behavior, and each name descriptive enough to serve as documentation.
- Treat hard-to-test code as a design smell, not a mocking challenge.
Start small: pick one class in your current project, write its next feature test-first, and run the loop a few times. The habit takes about two weeks to feel natural — and once it does, coding without tests will feel like driving without a seatbelt.
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