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