Unit Testing in C#: xUnit, Theory/InlineData and Testable Code
The hidden dependencies behind a flaky test, an xUnit test project, Fact and Assert, Theory/InlineData/MemberData, taking time from outside with IClock, and pitfalls like decimal InlineData and async void.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
The bookstore's old pricing code has a weekend discount: on Saturdays and Sundays prices drop by 20 percent. Someone wrote a test for it saying "returns the undiscounted price", and the test has been green for months. I'm writing this post on November 7, 2026. Does the test pass today?
namespace Bookstore;
public static class LegacyPricing
{
public static decimal PriceFor(decimal listPrice) =>
DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
? listPrice * 0.80m
: listPrice;
}
using Bookstore;
public class LegacyPricingTests
{
[Fact]
public void PriceFor_returns_list_price()
{
decimal price = LegacyPricing.PriceFor(10m);
Assert.Equal(10m, price);
}
}
Why the Test Is Red Today
It doesn't pass:
[xUnit.net 00:00:00.05] LegacyPricingTests.PriceFor_returns_list_price [FAIL]
Failed LegacyPricingTests.PriceFor_returns_list_price [4 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 10
Actual: 8.00
Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 4 ms - Legacy.Tests.dll (net6.0)
November 7, 2026 is a Saturday. The test passes Monday to Friday and breaks on weekends; in other words, a test whose result changes without any change to the code. This is called a flaky test, and it's the most dangerous kind: the team learns to ignore red, and then a real bug gets ignored too. The problem isn't the test, it's the code. PriceFor has a hidden input: DateTime.Now. Because of this dependency, which doesn't show in the method signature, the test can't control which day it's testing. On top of that, DateTime.Now is local time; the same moment can be a different day on a CI server in another time zone.
In this post we'll see how to write tests with xUnit, but this is the real lesson: good tests come from testable code. Let's set up the tool first, then fix this code.
The Test Project
Tests live in a separate project that references the project under test. xUnit needs three packages; they're all the project's own NuGet references, and nothing gets installed globally on the machine:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\Bookstore.Core\Bookstore.Core.csproj" />
</ItemGroup>
xunit brings the attributes for writing tests and the Assert class. xunit.runner.visualstudio is the adapter that lets dotnet test and IDEs find and run xUnit tests. Microsoft.NET.Test.Sdk is the test infrastructure itself. <Using Include="Xunit" /> is a global using so you don't have to write using Xunit; in every file. Running the tests is one command; since this machine has no .NET 6 runtime I ran them with roll-forward, and with the en-US culture so the numbers in the output aren't affected by culture:
LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 DOTNET_ROLL_FORWARD=Major dotnet test --logger "console;verbosity=normal"
Fact and Assert
The first code we'll test is a simple calculator: unit price times quantity, a 10 percent discount at ten items or more, and an exception for zero or negative quantities:
namespace Bookstore;
public sealed class PriceCalculator
{
public decimal Total(decimal unitPrice, int quantity)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be positive.");
decimal total = unitPrice * quantity;
if (quantity >= 10)
total *= 0.90m;
return Math.Round(total, 2);
}
}
Its tests:
using Bookstore;
public class PriceCalculatorTests
{
private readonly PriceCalculator _calculator = new();
[Fact]
public void Total_multiplies_unit_price_by_quantity()
{
// Arrange
const decimal unitPrice = 9.99m;
// Act: the single call under test
decimal total = _calculator.Total(unitPrice, 3);
// Assert: the expected result
Assert.Equal(29.97m, total);
}
[Theory]
[InlineData(9, 90)]
[InlineData(10, 90)]
[InlineData(11, 99)]
public void Total_applies_bulk_discount_from_ten_items(int quantity, int expected)
{
decimal total = _calculator.Total(10m, quantity);
Assert.Equal(expected, total);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Total_rejects_non_positive_quantity(int quantity)
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => _calculator.Total(10m, quantity));
Assert.Equal("quantity", ex.ParamName);
}
}
A test class is an ordinary public class, and test methods are ordinary public methods. [Fact] says "this method is a test without parameters". The first test follows the Arrange-Act-Assert layout I marked in the comments: prepare the inputs, make the single call under test, verify the result. Each test verifies a single behavior and its name says what that behavior is; when a test breaks, reading its name should tell you what broke.
Order matters in Assert.Equal: first the expected value, then the actual one. If you swap them, the test still runs, but when it breaks the Expected and Actual in the error message are swapped and mislead whoever is debugging. xUnit creates a new instance of the class for every test; that's why the _calculator field is fresh in every test and tests can't see each other's state.
Theory and InlineData
When testing the discount threshold, you don't want to write the same test over and over with different data. [Theory] defines a parameterized test, and every [InlineData] line becomes a separate test case. Discount rules break most often at the boundary, so we try 9, 10 and 11 separately. 9 items without the discount is 90, 10 items with the discount is also 90, 11 items with the discount is 99.
For exception tests there's Assert.Throws<T>: it runs the lambda you give it, expects an exception of exactly type T and returns it. That lets you verify the exception's details too; here we checked which parameter was invalid (ParamName). If you're fine with a derived exception type as well, you use Assert.ThrowsAny<T>.
In the detailed output, each theory case shows up as a separate test, with its arguments:
Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 10, expected: 90) [3 ms]
Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-07T00:00:00.0000000, expected: 8) [3 ms]
Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 11, expected: 99) [< 1 ms]
Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-06T00:00:00.0000000, expected: 10) [< 1 ms]
Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 9, expected: 90) [< 1 ms]
Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-08T00:00:00.0000000, expected: 8) [< 1 ms]
Passed PriceCalculatorTests.Total_multiplies_unit_price_by_quantity [< 1 ms]
Passed PriceCalculatorTests.Total_rejects_non_positive_quantity(quantity: -1) [< 1 ms]
Passed PriceCalculatorTests.Total_rejects_non_positive_quantity(quantity: 0) [< 1 ms]
Test Run Successful.
Total tests: 9
Passed: 9
Total time: 0.2490 Seconds
Notice that the order is mixed up. xUnit doesn't guarantee the order of tests, and by default it runs different test classes in parallel. That's why a test needing state left behind by another test is a design flaw.
Testable Code: Taking Time from Outside
Now back to the opening code. The fix is the same idea as dependency injection in the previous post: make the hidden dependency visible and take it from outside. We put time behind an interface:
namespace Bookstore;
public interface IClock
{
DateTime Now { get; }
}
public sealed class SystemClock : IClock
{
public DateTime Now => DateTime.Now;
}
public sealed class WeekendPricing
{
private readonly IClock _clock;
public WeekendPricing(IClock clock) => _clock = clock;
public decimal PriceFor(decimal listPrice) =>
_clock.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
? listPrice * 0.80m
: listPrice;
}
The application registers SystemClock in the container; the test passes a small fake clock that returns whatever day it wants:
using Bookstore;
public class WeekendPricingTests
{
[Theory]
[MemberData(nameof(Days))]
public void PriceFor_depends_only_on_the_given_day(DateTime now, decimal expected)
{
var pricing = new WeekendPricing(new FixedClock(now));
Assert.Equal(expected, pricing.PriceFor(10m));
}
public static TheoryData<DateTime, decimal> Days => new()
{
{ new DateTime(2026, 11, 6), 10m },
{ new DateTime(2026, 11, 7), 8m },
{ new DateTime(2026, 11, 8), 8m },
};
private sealed class FixedClock : IClock
{
public FixedClock(DateTime now) => Now = now;
public DateTime Now { get; }
}
}
The test is now independent of the calendar: it tests Friday, Saturday and Sunday with the same result every day, in every time zone. Here we used MemberData instead of InlineData, because DateTime and decimal can't be attribute arguments (we'll see that in the pitfalls shortly). TheoryData<DateTime, decimal> is the type-safe way to define the cases.
We didn't need a mocking library for FixedClock; a five-line class was enough. As the number of dependencies grows, libraries like Moq or NSubstitute make life easier; in the unit test post of the ASP.NET Core Web API series we test services and controllers with Moq. But the first question always stays the same: what inputs does this code hide? DateTime.Now, Guid.NewGuid(), Random, the file system, environment variables, the network. The moment you take them from outside, the code becomes testable.
Common Pitfalls
Passing a decimal to InlineData. If you do money calculations, it's the first thing you'll try, and it doesn't compile:
using Bookstore;
public class PriceTests
{
[Theory]
[InlineData(9.99m, 3, 29.97m)]
public void Total_with_decimal_data(decimal unitPrice, int quantity, decimal expected)
{
Assert.Equal(expected, new PriceCalculator().Total(unitPrice, quantity));
}
}
PriceTests.cs(6,17): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type
PriceTests.cs(6,27): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type
Attribute arguments must be compile-time constants, and from the CLR's point of view decimal isn't a constant type that can be used in attributes (neither is DateTime). The fixes: pass the value as an int or a string and convert it inside the test (we wrote the expected values as int in the discount test above), or use MemberData with a TheoryData<decimal, int, decimal>.
An async void test. When testing async code it's easy to write the method as async void; xUnit 2 still runs it, but the analyzer warns:
public class StockTests
{
[Fact]
public async void Reserve_reduces_stock()
{
await Task.Delay(10);
Assert.Equal(4, 5 - 1);
}
}
StockTests.cs(4,23): warning xUnit1048: Support for 'async void' unit tests is being removed from xUnit.net v3. To simplify upgrading, convert the test to 'async Task' instead. (https://xunit.net/xunit.analyzers/rules/xUnit1048)
Async tests should always be async Task; that way the test framework can await the task and attach the exception inside it to the right test. In the Asynchronous Programming series we discussed in detail why async void belongs only to event handlers.
Shared static state. Every test gets a new instance, but static fields are shared. If one test writes to a static cache and another reads from it, the tests pass on their own and break randomly when run together. If you can't avoid static state, you put those tests in the same collection to stop them running in parallel, but the real fix is usually to pull that state out as a dependency.
Trying to test a private method. Calling a private method through reflection to test it ties the test to an implementation detail; when you rename the method, the test breaks even though the behavior didn't change. Test the public behavior. If a private piece is complex enough to deserve its own tests, it wants to move into its own class.
A Small Experiment
★☆☆ Add a check to PriceCalculator that throws ArgumentOutOfRangeException when the unit price is negative, and write its test first. Write the code while the test is red, then watch it turn green.
★★☆ Rewrite the bulk discount test with a MemberData that uses TheoryData<decimal, int, decimal>, with real prices like 9.99m. What is the discounted result for 10 × 9.99, and what is Math.Round doing here?
★★★ Set up a test project for the DescribeGenerator from the source generator post: create a compilation with CSharpCompilation.Create containing a class with [GenerateDescribe], run the generator with CSharpGeneratorDriver, and verify that the generated source contains Describe().
This post completes the core posts of the Advanced C# series: from delegates to expression trees, from spans to source generators, from DI to unit tests, we wrote all of it with C# 10 and .NET 6. Next we move on to the C# Versions series: first a short history from C# 1.0 to 10, then what C# 11, 12, 13 and 14 brought, and how the code we wrote here gets shorter in the newer versions.