
Learn C# unit testing with xUnit and test-driven development in this complete tutorial. Master TDD, Fact, Theory, mocking, and best practices. Start now!
If you want to write reliable, bug-resistant .NET applications, mastering C# unit testing is one of the highest-return skills you can develop. In this complete tutorial you'll learn C# unit testing with xUnit — the most popular testing framework in the modern .NET ecosystem — and how to combine it with test-driven development (TDD) to design cleaner, more maintainable code. Whether you're a beginner searching for "how to write unit tests in C#," an intermediate developer looking for xUnit best practices, or a senior engineer refining your TDD workflow, this guide has you covered with practical, runnable examples.
What Is C# Unit Testing and Why Does It Matter?
A unit test verifies that a small, isolated piece of code — usually a single method — behaves exactly as expected. Instead of manually running your app and clicking through screens, you write automated tests that run in milliseconds and tell you immediately when something breaks.
The why is what matters most. Good unit tests give you:
- Confidence to refactor. Change code freely, and your tests catch regressions instantly.
- Living documentation. A well-named test describes exactly how a method is supposed to behave.
- Faster debugging. When a test fails, you know precisely which unit broke — not "somewhere in the app."
- Better design. Code that's hard to test is usually code that's too tightly coupled. Testing pressure improves architecture.
Why xUnit for C# Unit Testing?
.NET has three major test frameworks: MSTest, NUnit, and xUnit. xUnit has become the de facto choice for new .NET projects, and it's the framework the .NET team itself uses. Here's why xUnit stands out:
- It creates a new instance of the test class for every test, so tests never share state accidentally — a huge source of flaky tests in other frameworks.
- It uses plain constructors and
IDisposablefor setup and teardown instead of magic attributes. - Its
[Theory]feature makes data-driven testing clean and expressive. - It integrates seamlessly with
dotnet test, Visual Studio, Rider, and CI/CD pipelines.
Setting Up Your First xUnit Project
Let's build a project from scratch. Assuming you have the .NET SDK installed, create a solution with a class library and a matching test project:
// Create the solution and projects
dotnet new sln -n BankingApp
dotnet new classlib -n BankingApp.Core
dotnet new xunit -n BankingApp.Tests
// Wire everything together
dotnet sln add BankingApp.Core BankingApp.Tests
dotnet add BankingApp.Tests reference BankingApp.Core
The dotnet new xunit template already includes the xunit and xunit.runner.visualstudio NuGet packages plus the Microsoft.NET.Test.Sdk. You run all tests with a single command:
dotnet test
Writing Your First Unit Test with [Fact]
In xUnit, a test method with no parameters is marked with the [Fact] attribute. A "fact" is something that is always true. Let's test a simple calculator method.
First, the production code:
namespace BankingApp.Core;
public class Calculator
{
public int Add(int a, int b) => a + b;
}
Now the test. A clean unit test follows the Arrange-Act-Assert (AAA) pattern:
using Xunit;
using BankingApp.Core;
public class CalculatorTests
{
[Fact]
public void Add_TwoPositiveNumbers_ReturnsCorrectSum()
{
// Arrange
var calculator = new Calculator();
// Act
int result = calculator.Add(2, 3);
// Assert
Assert.Equal(5, result);
}
}
Notice the descriptive test name: MethodName_Scenario_ExpectedResult. This naming convention turns your test output into readable documentation. When this test fails, the report literally reads "Add two positive numbers returns correct sum."
Data-Driven Testing with [Theory] and [InlineData]
Writing a separate [Fact] for every input combination gets repetitive fast. The [Theory] attribute lets you run the same test logic with multiple data sets, dramatically reducing duplication:
public class CalculatorTheoryTests
{
[Theory]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
[InlineData(-5, -5, -10)]
public void Add_VariousInputs_ReturnsExpectedSum(int a, int b, int expected)
{
// Arrange
var calculator = new Calculator();
// Act
int result = calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
}
Each [InlineData] row runs as an independent test case. If the (-5, -5, -10) case fails, xUnit reports only that specific case — the others still pass and stay green. For more complex objects, use [MemberData] or [ClassData] to supply data from a property or class.
Understanding xUnit Assertions
Assertions are how you express "this must be true." xUnit ships a rich set of assertion methods. Here are the ones you'll use most often:
// Equality
Assert.Equal(expected, actual);
Assert.NotEqual(unexpected, actual);
// Booleans
Assert.True(condition);
Assert.False(condition);
// Null checks
Assert.Null(obj);
Assert.NotNull(obj);
// Collections
Assert.Contains(item, collection);
Assert.Empty(collection);
Assert.Single(collection);
// Types
Assert.IsType<InvalidOperationException>(ex);
// Exceptions
var ex = Assert.Throws<ArgumentException>(() => account.Withdraw(-50));
Assert.Equal("Amount must be positive", ex.Message);
The Assert.Throws<T> method is essential — it verifies that your code fails correctly. Testing the unhappy path is just as important as testing success.
Test-Driven Development (TDD): The Red-Green-Refactor Cycle
Now for the heart of this tutorial. Test-driven development flips the usual order: you write the test before the production code. TDD follows a short, disciplined loop called Red-Green-Refactor:
- Red — Write a failing test that describes the behavior you want. It fails because the code doesn't exist yet.
- Green — Write the simplest code that makes the test pass. Don't over-engineer.
- Refactor — Clean up the code while keeping all tests green.
Let's build a BankAccount class using TDD. We start with the test — the Red phase:
public class BankAccountTests
{
[Fact]
public void Withdraw_AmountGreaterThanBalance_ThrowsInvalidOperation()
{
// Arrange
var account = new BankAccount(initialBalance: 100);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(
() => account.Withdraw(150));
Assert.Equal("Insufficient funds", ex.Message);
}
}
This won't even compile — BankAccount doesn't exist. That's expected in TDD. Now we write just enough code to reach Green:
namespace BankingApp.Core;
public class BankAccount
{
public decimal Balance { get; private set; }
public BankAccount(decimal initialBalance)
{
Balance = initialBalance;
}
public void Withdraw(decimal amount)
{
if (amount > Balance)
throw new InvalidOperationException("Insufficient funds");
Balance -= amount;
}
}
Run dotnet test — green. Now add another failing test for a new rule (rejecting negative withdrawals), watch it fail, implement it, and refactor. This tight feedback loop keeps your code minimal, focused, and fully covered by tests. That's the real power of TDD: you never write code that isn't justified by a test.
Isolating Dependencies with Mocking
Real classes depend on databases, APIs, and other services. A true unit test must isolate the unit under test from those dependencies. This is where mocking in C# comes in, typically using the Moq library:
dotnet add BankingApp.Tests package Moq
Suppose PaymentService depends on an IEmailSender interface. We don't want to send real emails during tests, so we mock it:
using Moq;
using Xunit;
public class PaymentServiceTests
{
[Fact]
public void ProcessPayment_Success_SendsConfirmationEmail()
{
// Arrange
var mockEmail = new Mock<IEmailSender>();
var service = new PaymentService(mockEmail.Object);
// Act
service.ProcessPayment(amount: 99.99m, customerEmail: "user@example.com");
// Assert — verify the email was sent exactly once
mockEmail.Verify(
e => e.Send("user@example.com", It.IsAny<string>()),
Times.Once);
}
}
The mock lets us verify behavior (was Send called?) without touching real infrastructure. This keeps tests fast, deterministic, and independent of external systems — a core principle of good unit testing.
Setup and Teardown in xUnit
Because xUnit creates a fresh test class instance per test, you use the constructor for setup and IDisposable.Dispose for cleanup — no special attributes needed:
public class DatabaseTests : IDisposable
{
private readonly TestDbContext _context;
public DatabaseTests()
{
// Runs before EVERY test
_context = new TestDbContext();
_context.Seed();
}
[Fact]
public void GetUser_ExistingId_ReturnsUser()
{
var user = _context.Users.Find(1);
Assert.NotNull(user);
}
public void Dispose()
{
// Runs after EVERY test
_context.Dispose();
}
}
For expensive shared setup (like spinning up a database once for many tests), use xUnit's IClassFixture<T> or ICollectionFixture<T> to share context safely without sacrificing isolation.
C# Unit Testing Best Practices
Follow these xUnit best practices to keep your test suite fast, reliable, and maintainable:
- One logical assertion per test. Each test should verify a single behavior so failures are unambiguous.
- Follow AAA structure. Arrange, Act, Assert — visually separated — makes tests instantly readable.
- Name tests descriptively. Use
Method_Scenario_ExpectedResult. - Keep tests independent. No test should depend on another running first or on shared mutable state.
- Test behavior, not implementation. Assert on outcomes, not private internals, so refactoring doesn't break tests unnecessarily.
- Make tests fast. Mock I/O, databases, and network calls. A slow suite is a suite that doesn't get run.
Common Pitfalls to Avoid
- Testing the framework, not your code. Don't write tests for auto-properties or standard library behavior.
- Over-mocking. If a test has more mock setup than actual logic, your design may be too coupled.
- Ignoring the unhappy path. Always test exceptions, nulls, and boundary values — that's where real bugs hide.
- Non-deterministic tests. Avoid
DateTime.Now, random values, and real network calls; inject them instead so tests are repeatable. - Chasing 100% coverage. Coverage is a guide, not a goal. A meaningful test on critical logic beats a trivial test written to hit a number.
Conclusion: Key Takeaways
You've now learned C# unit testing with xUnit from the ground up — from your first [Fact] to data-driven [Theory] tests, mocking with Moq, and a full test-driven development workflow. Here are the key takeaways to remember:
- Use
[Fact]for single-case tests and[Theory]with[InlineData]for data-driven testing. - Structure every test with Arrange-Act-Assert and name it descriptively.
- Practice the TDD Red-Green-Refactor cycle to drive cleaner, fully-tested designs.
- Isolate dependencies with mocking so your unit tests stay fast and deterministic.
- Prioritize meaningful tests over raw coverage numbers.
The best way to master C# unit testing is to practice. Open your editor, create an xUnit project, and write your first failing test today. Your future self — debugging production at 2 a.m. — will thank you. Happy testing!
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