Skip to main content

TDD in C# with xUnit and Moq: Test-Driven Development Guide

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_ExpectedResult reads 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 mock List<T> or DateTime. Wrap third-party SDKs in your own interface, then mock that.
  • Use Times.Never deliberately. Verifying that a charge did not happen for an empty order is often the most valuable assertion in the file.
  • Prefer MockBehavior.Strict for 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 test re-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 Setup calls, 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 virtual members, so mocking a concrete class often silently runs real code. Depend on interfaces.
  • Ignoring async. Always return Task from async tests and await the call. An unawaited async void test 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...