Unit Testing with xUnit
Unit testing verifies that a small, isolated piece of code — typically a single method or class — behaves correctly in response to a range of inputs, including edge cases and error conditions. xUnit.net is the de facto standard testing framework for modern .NET, favored over MSTest and NUnit by the .NET team itself (ASP.NET Core and the runtime use it internally). It embraces conventions that reduce boilerplate: no [TestClass] attribute is needed, test classes are just plain classes, and a fresh instance of the test class is created for every single test method, which naturally isolates test state without requiring manual setup/teardown resets.
Cricket analogy: Testing a single batter's technique in the nets is like unit testing — isolated from the full match; and just as each net session starts with a fresh pitch, xUnit creates a fresh instance of the test class for every single test method, avoiding leftover state.
Facts, Theories, and Assertions
xUnit distinguishes between two kinds of tests. A [Fact] is a test that is always true for a fixed scenario — it takes no parameters. A [Theory] is a parameterized test that should hold true for a set of data supplied via [InlineData], [MemberData], or [ClassData] attributes, letting you exercise the same assertion logic against many inputs without duplicating code. Assertions live on the static Assert class — Assert.Equal, Assert.True, Assert.Throws<T>, Assert.Null, and so on — and a failed assertion throws an exception that the test runner catches and reports, immediately halting that test method.
Cricket analogy: A [Fact] is like checking 'does this bat meet the weight limit' — always true for one fixed bat; a [Theory] with [InlineData] is like checking that rule across a dozen different bats, and the moment one fails inspection, the umpire halts that specific check immediately.
public class DiscountCalculator
{
public decimal ApplyDiscount(decimal price, decimal percent)
{
if (percent is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percent));
return price - (price * percent / 100m);
}
}
public class DiscountCalculatorTests
{
private readonly DiscountCalculator _sut = new();
[Fact]
public void ApplyDiscount_ZeroPercent_ReturnsOriginalPrice()
{
var result = _sut.ApplyDiscount(100m, 0m);
Assert.Equal(100m, result);
}
[Theory]
[InlineData(200, 10, 180)]
[InlineData(50, 50, 25)]
[InlineData(80, 25, 60)]
public void ApplyDiscount_ValidPercent_ReturnsExpectedPrice(
decimal price, decimal percent, decimal expected)
{
var result = _sut.ApplyDiscount(price, percent);
Assert.Equal(expected, result);
}
[Fact]
public void ApplyDiscount_NegativePercent_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => _sut.ApplyDiscount(100m, -5m));
}
}Sharing Setup with Fixtures
Because xUnit creates a new test class instance per test, a constructor is the natural place for per-test setup, and implementing IDisposable on the test class gives you per-test teardown. For expensive setup that should be shared *across* tests in a class — such as spinning up a database connection or an in-memory web host — use IClassFixture<T>, which xUnit instantiates once and injects into every test class constructor that requests it. For setup shared across multiple test classes, ICollectionFixture<T> combined with a [Collection] attribute achieves the same sharing at a broader scope, and also disables parallel execution between those classes so they don't race over the shared resource. A common mistake is asserting on floating-point equality with Assert.Equal(expected, actual) for double values, which can fail due to rounding error even when the values are 'logically' equal. Use the overload Assert.Equal(expected, actual, precision) or a tolerance-based comparison instead.
Cricket analogy: A fresh net session's setup (pitch marked, stumps placed) is per-test constructor work, and tearing down the nets after is like IDisposable; but sharing one expensive, pre-booked stadium across an entire tournament of matches (IClassFixture) avoids rebuilding it each time — and just as comparing exact run-rates with rounding errors misleads a table, comparing exact doubles instead of tolerant precision misleads a test.
xUnit deliberately runs test classes in parallel by default (though tests within one class run sequentially), which surfaces hidden shared-state bugs far faster than frameworks that default to serial execution. This is a philosophical difference from NUnit and MSTest, and it's why xUnit avoids [SetUp]/[TearDown]-style shared mutable fixtures in favor of constructor/dispose semantics scoped to one instance.
Mocking Dependencies
Real unit tests isolate the system under test from its collaborators. Libraries like Moq or NSubstitute let you create fake implementations of interfaces at test time, configure their return values, and verify that specific methods were called. Combined with dependency injection (constructor injection in particular), this makes classes trivially testable: pass a mock IEmailSender into a class that would otherwise send a real email, assert that Send was called with the expected arguments, and never touch a real mail server during the test run.
Cricket analogy: To test a captain's decision-making, you don't need a real opposing team — a mock 'bowler' that always delivers a scripted yorker lets you verify the captain calls the right field placement, without ever touching a real match.
- xUnit test classes need no special attribute; a new instance is created per test method for automatic isolation.
[Fact]tests take no parameters;[Theory]tests are parameterized via[InlineData],[MemberData], or[ClassData].Assert.Throws<T>verifies exception type and can inspect the thrown exception's properties.IClassFixture<T>shares expensive setup across tests in one class;ICollectionFixture<T>shares it across classes.- xUnit parallelizes across test classes by default, which helps catch shared-state bugs early.
- Combine xUnit with a mocking library (Moq, NSubstitute) and constructor injection to isolate the system under test from its dependencies.
Practice what you learned
1. What is the key structural difference between a xUnit `[Fact]` and a `[Theory]`?
2. How does xUnit achieve test isolation between individual test methods in the same class?
3. Which xUnit feature should be used to share one expensive resource (e.g. a database connection) across all tests in a single class?
4. What does xUnit do differently from many other test frameworks regarding test execution by default?
5. Why is `Assert.Equal(expected, actual)` risky when comparing two `double` values computed differently?
Was this page helpful?
You May Also Like
Dependency Injection Basics
Understand how dependency injection decouples classes from their collaborators, and how the built-in .NET DI container manages object lifetimes.
Exception Handling in C#
C# uses structured try/catch/finally blocks and a typed exception hierarchy to detect, propagate, and recover from runtime errors predictably.
Common C# Pitfalls
A tour of the mistakes that trip up C# developers most often — from closures over loop variables to silent null reference bugs and misused async void.
C# Interview Questions
A curated set of common C# interview questions and precise answers covering value vs reference types, async/await, LINQ, and object-oriented design.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics