Skip to main content

C# Unit Testing with xUnit: TDD Tutorial (2026)

Learn C# unit testing with xUnit and test-driven development. Step-by-step tutorial with code examples, best practices, and common pitfalls.

C# Unit Testing with xUnit: A Complete Test-Driven Development Tutorial

C# unit testing is one of the most essential skills every .NET developer must master in 2026. Whether you are building APIs, desktop applications, or microservices, writing automated tests ensures your code works correctly, catches regressions early, and gives you the confidence to refactor without fear. In this comprehensive xUnit tutorial, you will learn how to write unit tests in C# using the test-driven development (TDD) approach — from your very first test to advanced patterns used in production codebases.

By the end of this guide, you will understand how to set up xUnit in a .NET project, write meaningful tests using the Arrange-Act-Assert pattern, apply TDD to build reliable software, and avoid the most common unit testing mistakes that developers make.

Why Choose xUnit for Unit Testing in C#?

The .NET ecosystem offers three major testing frameworks: MSTest, NUnit, and xUnit. While all three are capable, xUnit has become the preferred choice for modern .NET development. Here is why:

  • Used by the .NET team itself — Microsoft uses xUnit to test the .NET runtime and ASP.NET Core
  • Modern design — xUnit was built from the ground up with modern C# features and best practices in mind
  • Clean test isolation — each test runs in a new class instance, preventing shared state bugs
  • Extensible architecture — easy to customize with traits, custom attributes, and test output
  • Active community — strong ecosystem of companion libraries like FluentAssertions and Moq

If you are starting a new project or learning how to write unit tests in C#, xUnit is the framework you should invest your time in.

Setting Up Your First xUnit Test Project

Let us start by creating a solution with a class library and a corresponding test project. Open your terminal and run these commands:

// Terminal commands to set up the project
// dotnet new sln -n InventorySystem
// dotnet new classlib -n InventorySystem.Core
// dotnet new xunit -n InventorySystem.Tests
// dotnet sln add InventorySystem.Core
// dotnet sln add InventorySystem.Tests
// cd InventorySystem.Tests
// dotnet add reference ../InventorySystem.Core

This creates a clean solution structure where InventorySystem.Core holds your business logic and InventorySystem.Tests contains your tests. The xUnit project template automatically includes the necessary NuGet packages: xunit, xunit.runner.visualstudio, and Microsoft.NET.Test.Sdk.

Understanding Test-Driven Development (TDD) in C#

Test-driven development in C# follows a disciplined three-step cycle known as Red-Green-Refactor:

  • Red — Write a failing test that defines the behavior you want
  • Green — Write the minimum code to make that test pass
  • Refactor — Clean up the code while keeping all tests green

TDD flips the traditional workflow. Instead of writing code first and testing later, you write the test first. This forces you to think about the design and expected behavior before writing implementation code. The result is cleaner interfaces, better separation of concerns, and higher test coverage — naturally.

Let us apply TDD to build an inventory management system step by step.

Writing Your First xUnit Test: The Red Phase

We will build an InventoryService that tracks product stock. Following TDD, we start with a test before writing any production code:

using Xunit;
using InventorySystem.Core;

namespace InventorySystem.Tests;

public class InventoryServiceTests
{
    [Fact]
    public void AddStock_NewProduct_SetsCorrectQuantity()
    {
        // Arrange
        var service = new InventoryService();

        // Act
        service.AddStock("SKU-001", 50);

        // Assert
        Assert.Equal(50, service.GetStock("SKU-001"));
    }
}

This test will not even compile yet — InventoryService does not exist. That is exactly what the Red phase looks like. The test clearly expresses what we expect: when we add 50 units of a product, querying the stock for that product should return 50.

The Arrange-Act-Assert Pattern

Every well-structured unit test follows the Arrange-Act-Assert (AAA) pattern:

  • Arrange — Set up the objects and data needed for the test
  • Act — Execute the method or behavior under test
  • Assert — Verify the result matches expectations

This pattern makes tests readable, consistent, and easy to maintain. Stick to one Act and one logical Assert per test for maximum clarity.

Making the Test Pass: The Green Phase

Now let us write the minimum code to make our test pass:

namespace InventorySystem.Core;

public class InventoryService
{
    private readonly Dictionary<string, int> _stock = new();

    public void AddStock(string sku, int quantity)
    {
        if (_stock.ContainsKey(sku))
            _stock[sku] += quantity;
        else
            _stock[sku] = quantity;
    }

    public int GetStock(string sku)
    {
        return _stock.TryGetValue(sku, out var quantity) ? quantity : 0;
    }
}

Run the test with dotnet test — it passes. We are green. In TDD, resist the temptation to write more code than the tests demand. The tests drive the design.

Adding More Tests: Building Confidence

Let us expand our test suite to cover more behaviors. xUnit provides two key attributes for writing tests:

  • [Fact] — for tests that are always true, with no varying input
  • [Theory] — for data-driven tests that run with multiple inputs
public class InventoryServiceTests
{
    private readonly InventoryService _service = new();

    [Fact]
    public void AddStock_ExistingProduct_AccumulatesQuantity()
    {
        _service.AddStock("SKU-001", 50);
        _service.AddStock("SKU-001", 30);

        Assert.Equal(80, _service.GetStock("SKU-001"));
    }

    [Fact]
    public void GetStock_NonExistentProduct_ReturnsZero()
    {
        Assert.Equal(0, _service.GetStock("UNKNOWN"));
    }

    [Fact]
    public void RemoveStock_SufficientQuantity_ReducesStock()
    {
        _service.AddStock("SKU-001", 100);

        bool result = _service.RemoveStock("SKU-001", 40);

        Assert.True(result);
        Assert.Equal(60, _service.GetStock("SKU-001"));
    }

    [Fact]
    public void RemoveStock_InsufficientQuantity_ReturnsFalse()
    {
        _service.AddStock("SKU-001", 10);

        bool result = _service.RemoveStock("SKU-001", 50);

        Assert.False(result);
        Assert.Equal(10, _service.GetStock("SKU-001"));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-5)]
    public void AddStock_InvalidQuantity_ThrowsException(int quantity)
    {
        Assert.Throws<ArgumentException>(
            () => _service.AddStock("SKU-001", quantity)
        );
    }
}

Notice how the [Theory] with [InlineData] lets us test multiple invalid inputs without duplicating code. This is one of xUnit's most powerful features for parameterized testing.

Implementing RemoveStock with TDD

The RemoveStock tests we wrote above are currently failing (Red phase). Let us implement the method:

public class InventoryService
{
    private readonly Dictionary<string, int> _stock = new();

    public void AddStock(string sku, int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive.", nameof(quantity));

        if (_stock.ContainsKey(sku))
            _stock[sku] += quantity;
        else
            _stock[sku] = quantity;
    }

    public bool RemoveStock(string sku, int quantity)
    {
        if (!_stock.TryGetValue(sku, out var current) || current < quantity)
            return false;

        _stock[sku] = current - quantity;
        return true;
    }

    public int GetStock(string sku)
    {
        return _stock.TryGetValue(sku, out var quantity) ? quantity : 0;
    }
}

All tests pass. This is the rhythm of TDD — small, incremental steps where tests guide every line of production code you write.

Testing Exceptions and Edge Cases

Robust unit testing in C# means covering edge cases and error paths. xUnit provides several assertion methods for this:

[Fact]
public void AddStock_NullSku_ThrowsArgumentNullException()
{
    var service = new InventoryService();

    var exception = Assert.Throws<ArgumentNullException>(
        () => service.AddStock(null!, 10)
    );

    Assert.Equal("sku", exception.ParamName);
}

[Fact]
public void AddStock_EmptySku_ThrowsArgumentException()
{
    var service = new InventoryService();

    Assert.Throws<ArgumentException>(
        () => service.AddStock("", 10)
    );
}

Always test boundary conditions: null inputs, empty strings, zero values, negative numbers, and maximum values. These are exactly the places where bugs hide in production.

Using Theory and MemberData for Data-Driven Tests

When you need complex test data that goes beyond simple inline values, use [MemberData] or [ClassData]:

public class InventoryServiceTests
{
    public static IEnumerable<object[]> StockScenarios()
    {
        yield return new object[] { "SKU-A", 100, 30, 70 };
        yield return new object[] { "SKU-B", 200, 200, 0 };
        yield return new object[] { "SKU-C", 50, 1, 49 };
    }

    [Theory]
    [MemberData(nameof(StockScenarios))]
    public void RemoveStock_ValidScenarios_UpdatesCorrectly(
        string sku, int initial, int remove, int expected)
    {
        var service = new InventoryService();
        service.AddStock(sku, initial);

        service.RemoveStock(sku, remove);

        Assert.Equal(expected, service.GetStock(sku));
    }
}

This approach keeps your tests clean while testing a wide range of scenarios. Each data row runs as a separate test case, making failures easy to diagnose.

Mocking Dependencies with Moq

Real-world services depend on databases, APIs, and other components. Unit tests should isolate the class under test by mocking its dependencies. Install Moq with dotnet add package Moq:

public interface IProductRepository
{
    Product? GetBySku(string sku);
    void Save(Product product);
}

public class Product
{
    public string Sku { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public int Stock { get; set; }
}

public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository;
    }

    public bool Restock(string sku, int quantity)
    {
        var product = _repository.GetBySku(sku);
        if (product == null) return false;

        product.Stock += quantity;
        _repository.Save(product);
        return true;
    }
}
using Moq;
using Xunit;

public class ProductServiceTests
{
    [Fact]
    public void Restock_ExistingProduct_UpdatesAndSaves()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        var product = new Product { Sku = "SKU-001", Name = "Widget", Stock = 10 };
        mockRepo.Setup(r => r.GetBySku("SKU-001")).Returns(product);

        var service = new ProductService(mockRepo.Object);

        // Act
        bool result = service.Restock("SKU-001", 25);

        // Assert
        Assert.True(result);
        Assert.Equal(35, product.Stock);
        mockRepo.Verify(r => r.Save(product), Times.Once);
    }

    [Fact]
    public void Restock_NonExistentProduct_ReturnsFalse()
    {
        var mockRepo = new Mock<IProductRepository>();
        mockRepo.Setup(r => r.GetBySku("UNKNOWN")).Returns((Product?)null);

        var service = new ProductService(mockRepo.Object);

        bool result = service.Restock("UNKNOWN", 10);

        Assert.False(result);
        mockRepo.Verify(r => r.Save(It.IsAny<Product>()), Times.Never);
    }
}

Notice how mockRepo.Verify checks that the Save method was called exactly once for a successful restock, and never called when the product does not exist. This verifies behavior, not just return values.

C# Unit Testing Best Practices

After years of writing and reviewing tests across production .NET codebases, these are the best practices that matter most:

1. Name Tests Clearly

Use the pattern MethodName_Scenario_ExpectedResult. A test named RemoveStock_InsufficientQuantity_ReturnsFalse tells you exactly what failed without opening the code.

2. One Logical Assert Per Test

Each test should verify one behavior. If a test fails, you should know immediately what broke. Multiple unrelated asserts in one test make debugging harder.

3. Never Test Implementation Details

Test what a method does, not how it does it. If you refactor the internals and your tests break even though the behavior is unchanged, your tests are too tightly coupled.

4. Keep Tests Fast

Unit tests should run in milliseconds. If a test touches the file system, database, or network, it is an integration test and belongs in a separate project.

5. Use Constructor for Shared Setup

In xUnit, the constructor replaces [SetUp] methods from other frameworks. Each test gets a fresh instance, so constructor initialization is naturally isolated:

public class InventoryServiceTests
{
    private readonly InventoryService _service;

    public InventoryServiceTests()
    {
        _service = new InventoryService();
    }

    [Fact]
    public void AddStock_NewProduct_SetsQuantity()
    {
        _service.AddStock("SKU-001", 50);
        Assert.Equal(50, _service.GetStock("SKU-001"));
    }
}

6. Use FluentAssertions for Readability

The FluentAssertions library makes your assertions read like English:

using FluentAssertions;

[Fact]
public void GetStock_AfterAdding_ReturnsCorrectAmount()
{
    _service.AddStock("SKU-001", 75);

    int stock = _service.GetStock("SKU-001");

    stock.Should().Be(75);
    stock.Should().BePositive();
    stock.Should().BeInRange(1, 100);
}

Common C# Unit Testing Mistakes to Avoid

Even experienced developers fall into these traps:

  • Testing trivial code — Do not write tests for auto-properties, simple DTOs, or one-line wrapper methods. Test behavior that can actually break.
  • Sharing state between tests — Static fields or shared mutable objects cause tests to pass in isolation but fail when run together. xUnit prevents this by design, but watch out for static state.
  • Over-mocking — If you are mocking five dependencies to test one method, your class has too many responsibilities. Refactor the design instead of adding more mocks.
  • Ignoring test maintenance — Treat test code with the same care as production code. Duplicated setup, unclear names, and dead tests slow the whole team down.
  • Writing tests after the fact — Retrofitting tests often leads to testing implementation rather than behavior. TDD naturally avoids this problem.

Running and Organizing Your Tests

Run your tests from the terminal or your IDE:

// Run all tests
// dotnet test

// Run with detailed output
// dotnet test --verbosity normal

// Run a specific test class
// dotnet test --filter "FullyQualifiedName~InventoryServiceTests"

// Run tests matching a pattern
// dotnet test --filter "DisplayName~RemoveStock"

For larger projects, organize your test project to mirror the source project structure. If your source has Services/InventoryService.cs, your tests should have Services/InventoryServiceTests.cs. This makes it easy to find the tests for any class.

Conclusion: Start Writing C# Unit Tests Today

C# unit testing with xUnit and test-driven development is not optional for professional .NET developers — it is a core skill that separates hobbyist code from production-quality software. Here are the key takeaways from this tutorial:

  • xUnit is the modern standard for unit testing in C# and is used by the .NET team itself
  • TDD (Red-Green-Refactor) produces better designs and higher test coverage naturally
  • Use [Fact] for single tests and [Theory] with [InlineData] for parameterized tests
  • Follow the AAA pattern — Arrange, Act, Assert — for consistent, readable tests
  • Mock dependencies with Moq to isolate the unit under test
  • Test behavior, not implementation — your tests should survive refactoring

Start small. Pick one class in your current project, write a few tests for it, and build from there. Once you experience the confidence that a solid test suite provides, you will never want to ship untested code again.

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

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

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

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...