Skip to main content

TDD in C# with xUnit and Moq: Complete Tutorial (2026)

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_DoesNotSaveOrder that also checks logging is lying about its name. Multiple Assert calls 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.Once and specific arguments in Verify. paymentGateway.Verify(g => g.Charge(129.99m), Times.Once) catches double-charging bugs that It.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 virtual members on classes. Design against interfaces (IPaymentGateway, not StripeGateway) and inject them via constructor — which also makes your code DI-container friendly.
  • Strict mocks by default. MockBehavior.Strict fails on any un-setup call, which couples tests tightly to implementation details. Prefer the default loose behavior plus targeted Verify calls.
  • Testing DateTime.Now directly. Time is a dependency too. On .NET 8+, inject the built-in TimeProvider abstraction and use FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing in 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.

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