TDD in C# with xUnit v3: Red-Green-Refactor on .NET 10

Last updated: November 5, 2026 · Tested with .NET 10.0.12 (SDK 10.0.401), xunit.v3 4.0.2, Moq 4.21.0, Windows 11

Short answer: TDD in C# is a loop: write one failing xUnit test (red), write the least code that makes it pass (green), then clean up while the tests stay green (refactor). On .NET 10 that loop needs two setup fixes first. dotnet new xunit still gives you xUnit v2 (2.9.3), so reference xunit.v3 yourself. And with xunit.v3 4.x, dotnet test fails until global.json opts in to Microsoft Testing Platform. Below is a real red-green-refactor run with the captured dotnet test output, then theories, fixtures, async tests, parallelism, and the errors I hit along the way.

SymptomCauseFix
Testing with VSTest target is no longer supported...xunit.v3 4.x on the .NET 10 SDK"test": { "runner": "Microsoft.Testing.Platform" } in global.json
CS0182 on [InlineData(4.50m)]decimal isn’t an attribute constantPass 4.50; xUnit converts it, or use TheoryData
error xUnit1049async void testasync Task
did not have matching fixture dataConstructor parameter with no fixtureIClassFixture<T> or a collection fixture
Timed-out test keeps workingToken not passedTestContext.Current.CancellationToken
Tests pass alone, fail togetherStatic stateInstance fields or a fixture
CS0234 Xunit.Abstractions, CS0738 ValueTaskv2 code on v3Remove the using; return ValueTask

Set Up an xUnit v3 Test Project on .NET 10

The template on SDK 10.0.401 still references v2:

> dotnet new xunit -o TemplateProbe
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />

So I wrote the test project by hand. Don’t let the version numbers confuse you: the package for xUnit.net v3 is called xunit.v3, and its current version is 4.0.2. That’s the “xunit v3 4.0” people search for: same v3 framework, fourth major package release.

<PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
    <PackageReference Include="Moq" Version="4.21.0" />
    <PackageReference Include="xunit.v3" Version="4.0.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
    <Using Include="Xunit" />
    <ProjectReference Include="..\Shop\Shop.csproj" />
</ItemGroup>

Don’t drop <Using Include="Xunit" />. The template has it, and xunit.v3 doesn’t add it for you. Without it:

MissingToken.cs(5,6): error CS0246: The type or namespace name 'Fact' could not be found (are you missing a using directive or an assembly reference?)

Then the first dotnet test:

> dotnet test Shop.Tests
Microsoft.Testing.Platform.MSBuild.targets(395,5): error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error

xunit.v3 4.0 turned on Microsoft Testing Platform (MTP) v2 by default, and on the .NET 10 SDK, dotnet test refuses to run MTP projects in the old VSTest mode. The fix goes in global.json next to the solution:

{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "disable"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

After that, dotnet test --project Shop.Tests works and prints the new summary format. Microsoft’s page on testing with dotnet test explains the two modes.

Red, Green, Refactor: A Worked TDD Example

The feature: a cart PriceCalculator that sums lines, applies a SAVE10 code and rejects bad input. I kept every step as a separate folder and ran dotnet test on each, so the outputs below are the actual runs, in order.

Step 1, red. The test comes first, for a class that doesn’t exist:

public class PriceCalculatorTests
{
    private readonly PriceCalculator _calculator = new();

    [Fact]
    public void Empty_cart_costs_nothing()
    {
        decimal total = _calculator.Total([]);

        Assert.Equal(0m, total);
    }
}
PriceCalculatorTests.cs(5,22): error CS0246: The type or namespace name 'PriceCalculator' could not be found (are you missing a using directive or an assembly reference?)
Build failed with exit code: 1.

A compile error counts as red. It tells you the next thing to write.

Step 2, still red. Just enough to compile:

public record CartLine(string Sku, int Quantity, decimal UnitPrice);

public class PriceCalculator
{
    public decimal Total(IEnumerable<CartLine> lines) => throw new NotImplementedException();
}
failed Cart.Tests.PriceCalculatorTests.Empty_cart_costs_nothing (527ms)
  Xunit.MicrosoftTestingPlatform.XunitException: System.NotImplementedException : The method or operation is not implemented.
Test run summary: Failed!
  total: 1
  failed: 1

Watching the test fail matters. It proves the test runs and can fail. A test that has never been red might not be testing anything.

Step 3, green. => 0m; passes. It looks silly, but the next test forces the real code. Step 4, red again, with a theory:

[Theory]
[InlineData(1, 10.00, 10.00)]
[InlineData(2, 4.50, 9.00)]
[InlineData(3, 0.10, 0.30)]
public void Total_is_quantity_times_unit_price(int quantity, decimal unitPrice, decimal expected)
{
    decimal total = _calculator.Total([new CartLine("SKU-1", quantity, unitPrice)]);

    Assert.Equal(expected, total);
}
failed Cart.Tests.PriceCalculatorTests.Total_is_quantity_times_unit_price(quantity: 2, unitPrice: 4.5, expected: 9) (578ms)
  Assert.Equal() Failure: Values differ
  Expected: 9
  Actual:   0
  total: 4
  failed: 3
  succeeded: 1

Step 5, green: lines.Sum(l => l.Quantity * l.UnitPrice), 4 of 4 passing. Step 6, red: four new tests for the discount and validation rules, and a signature change so they compile:

[Fact]
public void Save10_takes_ten_percent_off_rounded_to_cents()
{
    decimal total = _calculator.Total([new CartLine("SKU-1", 1, 9.99m)], "SAVE10");

    Assert.Equal(8.99m, total);
}

[Fact]
public void Unknown_discount_code_throws()
{
    var ex = Assert.Throws<ArgumentException>(
        () => _calculator.Total([new CartLine("SKU-1", 1, 10m)], "FREE100"));

    Assert.Equal("discountCode", ex.ParamName);
}

[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Quantity_must_be_positive(int quantity)
{
    Assert.Throws<ArgumentOutOfRangeException>(
        () => _calculator.Total([new CartLine("SKU-1", quantity, 10m)]));
}
  Assert.Throws() Failure: No exception was thrown
  Expected: typeof(System.ArgumentException)
  total: 8
  failed: 4
  succeeded: 4

Step 7, my first green attempt, was still red. I wrote the obvious code:

if (discountCode == "SAVE10")
    total = total * 0.9m;
else if (discountCode != null)
    throw new ArgumentException($"Unknown discount code '{discountCode}'.", nameof(discountCode));
failed Cart.Tests.PriceCalculatorTests.Save10_takes_ten_percent_off_rounded_to_cents (1ms)
  Assert.Equal() Failure: Values differ
  Expected: 8.99
  Actual:   8.991
  total: 8
  failed: 1
  succeeded: 7

This is why the loop works. I had forgotten that a price needs rounding to cents, and the test caught it seconds after I wrote the bug, not in a customer’s invoice. Step 8 rounds with Math.Round(total * 0.9m, 2, MidpointRounding.AwayFromZero), and all 8 pass.

Step 9, refactor. With 8 green tests behind me, I rewrote the method without being afraid of breaking it: codes in a dictionary, small helpers, no loop.

private static readonly Dictionary<string, decimal> PercentOff = new()
{
    ["SAVE10"] = 10m,
};

public decimal Total(IEnumerable<CartLine> lines, string? discountCode = null)
{
    decimal subtotal = lines.Sum(LineTotal);
    return discountCode is null ? subtotal : ApplyDiscount(subtotal, discountCode);
}

private static decimal ApplyDiscount(decimal subtotal, string code) =>
    PercentOff.TryGetValue(code, out decimal percent)
        ? Math.Round(subtotal * (100 - percent) / 100, 2, MidpointRounding.AwayFromZero)
        : throw new ArgumentException($"Unknown discount code '{code}'.", "discountCode");
> dotnet test --project TddSteps/Cart.Tests -p:Step=9
Test run summary: Passed!
  total: 8
  failed: 0
  succeeded: 8

During the loop I run one test, not the whole suite. With MTP, the xUnit filter goes straight on dotnet test:

> dotnet test --project TddSteps/Cart.Tests --filter-method "*Save10*"
  total: 1

Theory Data: InlineData, Decimals and TheoryData

Look at step 4 again: decimal parameters, but double literals in [InlineData]. The m suffix doesn’t compile in an attribute:

[InlineData(2, 4.50m, 9.00m)]
DecimalInlineData.cs(6,20): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type

xUnit v3 converted 4.50 to 4.5m for me, and the step 4 tests went red and then green as expected. For anything that isn’t a constant, such as decimals you want written exactly, objects or longer lists, use MemberData with the strongly typed TheoryData:

public static TheoryData<decimal, decimal, decimal> Discounts => new()
{
    { 100m, 10m, 90m },
    { 9.99m, 10m, 8.99m },
    { 0.05m, 50m, 0.03m }, // 0.025 rounds away from zero
    { 59.99m, 100m, 0m },
};

[Theory]
[MemberData(nameof(Discounts))]
public async Task Rounds_the_discounted_amount_to_cents(decimal subtotal, decimal percent, decimal expected)
passed Shop.Tests.CheckoutServiceTests.Rounds_the_discounted_amount_to_cents(subtotal: 9.99, percent: 10, expected: 8.99) (1ms)
passed Shop.Tests.CheckoutServiceTests.Rounds_the_discounted_amount_to_cents(subtotal: 0.05, percent: 50, expected: 0.03) (1ms)

Each row shows up as its own test with its arguments in the name, so a failing row is easy to find.

Async Tests, Moq and TestContext.Current.CancellationToken

The next class, CheckoutService, calls two async dependencies: a discount-code store and a payment gateway. In TDD you fake them, so the test controls the answers and checks the calls. I use Moq for that; the Moq tutorial covers Setup, Verify and its error messages in depth.

[Fact]
public async Task Charges_the_discounted_amount()
{
    var ct = TestContext.Current.CancellationToken;
    _codes.Setup(c => c.GetPercentOffAsync("SAVE10", ct)).ReturnsAsync(10m);

    var result = await _service.CheckoutAsync("cust-1", 59.99m, "SAVE10", ct);

    Assert.Equal(53.99m, result.Amount);
    _payments.Verify(p => p.ChargeAsync("cust-1", 53.99m, ct), Times.Once);
}

Tests return async Task, and Assert.ThrowsAsync covers the failure case. The service gets both dependencies through its constructor, which is what makes it testable at all; see dependency injection in C# if that part is new.

TestContext.Current.CancellationToken is new in v3. The analyzer warns when you skip it:

MissingToken.cs(8,15): warning xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive.

I wanted to know if the warning matters, so I gave two tests [Fact(Timeout = 500)]. One passes the token to a 5-second delay; the other awaits a 1.5-second delay without it. Both were reported as timed out at about 500 ms:

failed (canceled) Pitfalls.Tests.TimeoutTests.Ignores_the_token (517ms)
  Xunit.MicrosoftTestingPlatform.XunitException: Test execution timed out after 500 milliseconds
failed (canceled) Pitfalls.Tests.TimeoutTests.Honors_the_token (502ms)

But each test also logged when its work actually ended:

Honors_the_token: work stopped after 501 ms
Ignores_the_token: work still finished after 1502 ms

The timeout only marks the test as failed. Without the token, the work keeps running in the background while the next tests start. With a real HTTP call or database query, that’s an open connection the runner has given up on. Pass the token.

async void tests don’t compile any more; v3 made it an analyzer error:

AsyncVoid.cs(6,23): error xUnit1049: Support for 'async void' unit tests has been removed from xUnit.net v3. Convert the test to 'async Task' or 'async ValueTask' instead.

For the reasons behind that, see C# async/await mistakes, where an async void exception takes down the whole process.

Fixtures in xUnit v3: Class, Collection and Assembly

xUnit creates a new instance of the test class for every test. That’s the setup step: the constructor runs before each test, Dispose after. For something expensive that should be shared, you use a fixture. I wrote three, each logging when it’s created, and a test class whose constructor logs too:

  • PriceListFixture: a class fixture (IClassFixture<T>) that loads a JSON price list in InitializeAsync.
  • ApiServerFixture: a collection fixture that starts a real Kestrel server on a free port, shared by two test classes.
  • RunInfoFixture: an assembly fixture, new in v3, registered with [assembly: AssemblyFixture(typeof(...))].
public sealed class PriceListFixture : IAsyncLifetime
{
    public IReadOnlyDictionary<string, decimal> Prices { get; private set; } = new Dictionary<string, decimal>();

    public async ValueTask InitializeAsync()
    {
        FixtureLog.Write("PriceListFixture.InitializeAsync");
        await using var file = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "prices.json"));
        Prices = await JsonSerializer.DeserializeAsync<Dictionary<string, decimal>>(file) ?? [];
    }
[CollectionDefinition("Api server")]
public class ApiServerCollection : ICollectionFixture<ApiServerFixture>;

[Collection("Api server")]
public class DiscountCodeStoreTests(ApiServerFixture server)

The log from one run of all 12 tests:

[    0 ms] RunInfoFixture created (assembly fixture)
[   93 ms] PriceListFixture.InitializeAsync
[  218 ms] PriceListTests constructor, instance #1
[  692 ms] PriceListTests constructor, instance #2
[  698 ms] PriceListTests constructor, instance #3
[  704 ms] PriceListFixture.DisposeAsync
[  744 ms] ApiServerFixture started on http://127.0.0.1:57709
[ 1019 ms] ApiServerFixture stopped
[ 1022 ms] RunInfoFixture disposed

Three tests, three test class instances, one fixture. The server started once for the 3 tests in two classes. That’s also the answer to the old question of how to run code once before all tests in xUnit: use an assembly fixture, which is new in v3.

The most-viewed xUnit fixture question on Stack Overflow (141,000 views) is this runtime error. I reproduced it by taking a constructor parameter without registering a fixture:

failed Pitfalls.Tests.MissingFixtureTests.Uses_settings (12ms)
  Xunit.MicrosoftTestingPlatform.XunitException: The following constructor parameters did not have matching fixture data: Settings settings

The analyzer had warned at build time, but only as a warning, which is easy to miss in a long build log:

PitfallTests.cs(15,43): warning xUnit1041: Fixture argument 'settings' does not have a fixture source (if it comes from a collection definition, ensure the definition is in the same assembly as the test) (https://xunit.net/xunit.analyzers/rules/xUnit1041)

The fix is IClassFixture<Settings> on the class, or a [Collection] whose definition lists the fixture. The xUnit shared context docs cover the rules.

Parallel Tests and Shared State

By default, test classes run in parallel with each other, and tests within one class run one at a time. Four tests of 1 second each, in two classes, three runs per mode:

4 tests x 1000 ms, wall time 2228 ms
4 tests x 1000 ms, wall time 4223 ms
4 tests x 1000 ms, wall time 1261 ms

That’s the default mode (collections), then ParallelMode.None, then ParallelMode.All, which is new in 4.0 and runs tests inside a class in parallel too. The three runs of each mode were within 61 ms of each other. Two catches when you set the mode. The snippet from the docs doesn’t compile without two usings, because the types live in other namespaces:

error CS0246: The type or namespace name 'ParallelizationAttribute' could not be found (are you missing a using directive or an assembly reference?)
using Xunit.Sdk; // ParallelMode
using Xunit.v3; // ParallelizationAttribute

[assembly: Parallelization(Mode = ParallelMode.None)]

And the attribute every older answer shows is now a compile error, not just a warning:

[assembly: CollectionBehavior(DisableTestParallelization = true)]
error CS0619: 'CollectionBehaviorAttribute.DisableTestParallelization' is obsolete: 'Please set ParallelizationAttribute.Mode instead. This property will be removed in the next major version.'

Parallelism is also where static state hurts. Two classes each add an item to a static list and expect exactly one:

public static class InMemoryCart
{
    public static readonly List<string> Items = [];
}
failed Pitfalls.Tests.AddFirstItemTests.Cart_has_one_item (231ms)
  Assert.Single() Failure: The collection contained 2 items
failed Pitfalls.Tests.AddSecondItemTests.Cart_has_one_item_too (231ms)
  Assert.Single() Failure: The collection contained 2 items

Run one class at a time with --filter-class and each passes, 1 of 1. Turning parallelism off doesn’t fix it either. In sequence, the second test still sees the first one’s item:

> dotnet test --project Gotchas/Pitfalls.Tests -p:Mode=SERIAL --filter-class "*ItemTests"   (parallelization off)
failed Pitfalls.Tests.AddSecondItemTests.Cart_has_one_item_too (218ms)
  Collection: ["SKU-1", "SKU-2"]
  total: 2
  failed: 1

Keep state in instance fields, which are fresh for every test, or in a fixture that resets it.

One more assertion trap from the same project: Assert.Throws<T> wants the exact type. ArgumentNullException.ThrowIfNull failed Assert.Throws<ArgumentException>:

Assert.Throws() Failure: Exception type was not an exact match
Expected: typeof(System.ArgumentException)
Actual:   typeof(System.ArgumentNullException)

Assert.ThrowsAny<ArgumentException> accepts derived types and passed.

Migrating from xUnit v2 to v3: The Errors I Hit

I compiled a typical v2 test class against xunit.v3 4.0.2:

error CS0234: The type or namespace name 'Abstractions' does not exist in the namespace 'Xunit' (are you missing an assembly reference?)
error CS0738: 'LegacyTests' does not implement interface member 'IAsyncLifetime.InitializeAsync()'. 'LegacyTests.InitializeAsync()' cannot implement 'IAsyncLifetime.InitializeAsync()' because it does not have the matching return type of 'ValueTask'.

Two fixes: delete using Xunit.Abstractions; (ITestOutputHelper is in Xunit now), and return ValueTask from InitializeAsync and DisposeAsync:

public ValueTask InitializeAsync() => ValueTask.CompletedTask;

public ValueTask DisposeAsync() => ValueTask.CompletedTask;

Migration guides say v3 test projects need <OutputType>Exe</OutputType>. With 4.0.2 the migrated project built and ran without it, because the package sets it:

> dotnet msbuild Gotchas/V2Code.Tests -getProperty:OutputType -p:Fixed=true
Exe

I keep the line anyway, so the project file says what it is. The full list of changes is in the official v2 to v3 migration guide. Then swap the packages, add the global.json runner setting from the top of this post, and run your tests in CI as usual; the GitHub Actions for .NET guide has a build-and-test workflow.

Checklist

  • Reference xunit.v3 (4.0.2), not the template’s xunit 2.9.3, and add <Using Include="Xunit" />.
  • Add "test": { "runner": "Microsoft.Testing.Platform" } to global.json on the .NET 10 SDK.
  • See every test fail before you make it pass; a compile error counts.
  • Write the least code that passes, then refactor only while green.
  • double literals in InlineData for decimals, or TheoryData.
  • async Task tests, and pass TestContext.Current.CancellationToken everywhere.
  • Shared setup in fixtures; treat analyzer warning xUnit1041 as an error.
  • No static mutable state in tests.
  • Set the parallel mode with [assembly: Parallelization(...)], with the Xunit.v3 and Xunit.Sdk usings.
  • Assert.Throws is exact; Assert.ThrowsAny accepts subclasses.

Related: Moq in C# tutorial · Dependency injection in C# · C# async/await mistakes · GitHub Actions CI/CD for .NET · all C# tutorials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *