Skip to main content

TDD in C# with xUnit and Moq: Complete Tutorial

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_FailsWithoutCharging tells you exactly what broke when it fails. A test named Test1 with 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 Verify only when the interaction itself is the requirement (like "never charge an invalid order"). Over-verifying couples tests to implementation details.
  • Use MockBehavior.Strict deliberately. 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 a TimeProvider (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: Setup to stub returns, Throws to simulate failures, Verify sparingly 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.

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