The NUnit 5 Guide: Constraint Model, TestCase/TestCaseSource, SetUp/OneTimeSetUp, Parallelism and 5.0 Breaking Changes for xUnit Users
In NUnit 5.0, [Platform("NET")] means modern .NET rather than .NET Framework: an old test that NUnit 4 always skipped on .NET 10 ran for the first time after the upgrade and exposed a real encoding bug in production. An xUnit mapping table, the shared fixture instance, Assert.That constraints, TestCase/TestCaseSource, SetUpFixture, parallelism, MTP and an upgrade from 4.6.1 to 5.0 with real build errors.
Version note: The code in this post was built and run with .NET 10, NUnit 5.0.0 (4.6.1 for comparison), NUnit3TestAdapter 6.3.1 and NUnit.Analyzers 4.15.0.
Predict First
We have used xUnit throughout the series, but in a good share of enterprise .NET projects the tests are written with NUnit. This post explains NUnit for someone who knows xUnit, and it does so through a real upgrade. The Inventory module of Bookstore, the online bookstore used as the shared example across this site's series, has an old NUnit test project. It contains a test for an export that sends warehouse stock to the old accounting system every night:
[TestFixture]
public class StockExportTests
{
[Test, Platform("Net")]
public void Legacy_export_is_written_in_the_turkish_code_page()
{
byte[] file = StockCsvExport.ToLegacyCsv([new StockItem("9780441172719", "Çatalca", 12, 2)]);
string expected = "isbn;warehouse;available\n9780441172719;Çatalca;10\n";
Assert.That(file, Has.Length.EqualTo(expected.Length));
Assert.That(file, Has.Member((byte)0xC7));
}
}
The accounting system reads the file as Windows-1254 (the Turkish code page); 0xC7 is Ç in that code page. The test was written while the project was on .NET Framework. The summary of the suite running on .NET 10 with NUnit 4.6.1:
skipped Legacy_export_is_written_in_the_turkish_code_page
Only supported on Net
Test run summary: Passed!
total: 5
failed: 0
succeeded: 4
skipped: 1
The package was upgraded to NUnit 5.0.0, the build errors (we will see them below) were fixed, and nothing else in the test code was touched. What will this test, which has shown up as skipped for years, do now?
Answer: "NET" No Longer Means .NET Framework
It runs and fails:
failed Legacy_export_is_written_in_the_turkish_code_page (3ms)
System.NotSupportedException : No data is available for encoding 1254. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method.
1) at System.Text.Encoding.GetEncoding(Int32 codepage)
In NUnit 4, [Platform("Net")] meant .NET Framework; the test never ran on .NET 10 and was skipped with "Only supported on Net". NUnit 5.0 aligned the name with Microsoft's current usage: "NET" and "DotNET" now refer to modern .NET (.NET 5 and later), and the new names "NETFramework" and "DotNETFramework" were added for .NET Framework. With the upgrade, the test ran on .NET 10 for the first time.
The reason it fails is not in the test but in production code: .NET (Core and later) includes only a few encodings by default; Windows code pages come from CodePagesEncodingProvider. Since the export moved to .NET 10, it must have been stopping with this exception every night; the skipped test hid it. The fix goes into production:
// .NET (Core and later) ships only a few encodings; code pages come from this provider
Encoding turkish = CodePagesEncodingProvider.Instance.GetEncoding(1254)!;
The Platform attribute came off the test, and the test turned green. The lesson is not specific to NUnit: the skipped count is a metric too. If you see a test that is always skipped in the CI report, know why; either it really is for another platform, or it is hiding a bug nobody looks at.
For xUnit Users: Mapping and the Shared Fixture Instance
| Topic | xUnit v3 4.0 | NUnit 5.0 |
|---|---|---|
| Test class | no attribute needed | [TestFixture] (optional) |
| Test | [Fact] | [Test] |
| Inline data | [Theory] + [InlineData] | [TestCase] |
| Data from a method | [MemberData], TheoryData | [TestCaseSource], TestCaseData |
| Combinations | MatrixTheoryData | [Values], [Range], [Combinatorial] |
| Before each test | constructor | [SetUp] |
| After each test | Dispose/DisposeAsync | [TearDown] |
| Once per class | IClassFixture<T> | [OneTimeSetUp]/[OneTimeTearDown] |
| Per assembly/namespace | AssemblyFixture | [SetUpFixture] |
| Skipping | Skip = "...", Assert.Skip | [Ignore("...")], Assert.Ignore |
| Instance per test | new for every test | default: one per fixture |
| Parallelism | on by default | off by default, [Parallelizable] |
The most important row in the table is the second to last. xUnit creates a new instance of the test class for every test; NUnit, by default, uses one instance for all the tests in a fixture. An NUnit test written with xUnit habits:
[TestFixture]
public class SharedLedgerTests
{
private const string Dune = "9780441172719";
// Field initializer: in NUnit this runs once for the whole fixture
private readonly InMemoryStockLedger ledger = new InMemoryStockLedger().Receive(Dune, 10);
[Test]
public async Task Reserving_four_leaves_six()
{
await ledger.ReserveAsync(Dune, 4, TestContext.CurrentContext.CancellationToken);
Assert.That(ledger.Available(Dune), Is.EqualTo(6));
}
[Test]
public async Task Reserving_eight_leaves_two()
{
await ledger.ReserveAsync(Dune, 8, TestContext.CurrentContext.CancellationToken);
Assert.That(ledger.Available(Dune), Is.EqualTo(2));
}
}
failed Reserving_four_leaves_six (3ms)
Bookstore.Inventory.InsufficientStockException : Not enough stock for 9780441172719: requested 4, available 2.
Reserving_eight_leaves_two ran first (NUnit does not guarantee the order; in this run the tests were sorted by name), brought the stock down to 2, and the second test started with that stock. In xUnit both tests were green. There are two fixes: set up the state in a [SetUp] method (it runs before every test), or put [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] on the fixture. With the second, both tests turned green without any other change to this class; the attribute can also be applied at assembly level. In an existing NUnit project, the first place to look is the fields: every field assigned outside [SetUp] and changed in tests is a candidate for a leak.
Assert.That and Constraints
NUnit assertions are written through a single method: Assert.That(actual, constraint). Constraints are chained from the Is, Has, Does and Throws classes. Using the reorder rule in Inventory (ReorderPolicy, which looks at how many days the stock will last):
[Test]
public void Days_of_cover_is_compared_with_a_tolerance() =>
Assert.That(ReorderPolicy.DaysOfCover(10, 3), Is.EqualTo(3.33).Within(0.01));
[Test]
public void Negative_sales_are_rejected_with_the_parameter_name() =>
Assert.That(
() => ReorderPolicy.DaysOfCover(10, -1),
Throws.TypeOf<ArgumentOutOfRangeException>().ParamName.EqualTo("averageDailySales"));
[Test]
public void Only_titles_below_the_minimum_are_listed()
{
ReorderPolicy policy = new(minimumDaysOfCover: 7);
StockItem[] items = [new(Dune, "Istanbul", 3, 1), new(Hobbit, "Istanbul", 40, 0), new(Emma, "Ankara", 5, 0)];
Dictionary<string, double> sales = new() { [Dune] = 1, [Hobbit] = 2, [Emma] = 0.5 };
IReadOnlyList<string> titles = policy.TitlesToReorder(items, sales);
using (Assert.EnterMultipleScope())
{
Assert.That(titles, Has.Exactly(1).EqualTo(Dune));
Assert.That(titles, Does.Not.Contain(Hobbit));
Assert.That(titles, Has.None.EqualTo(Emma));
}
}
Within(0.01) adds a tolerance to a double comparison; there is also Within(5).Percent, and Within(TimeSpan) for dates. Throws.TypeOf<T>() accepts the exact type, Throws.InstanceOf<T>() subtypes too. .ParamName came with 5.0: it checks the parameter name directly for ArgumentException and its subtypes. Because it is written with C# 14's extension property feature, the test project has to use C# 14 (the default on .NET 10). Previously the same check was done with .With.Property("ParamName"), a string the compiler does not check.
Assert.EnterMultipleScope() (older form: Assert.Multiple(() => ...)) runs every assertion in the block and collects the failures into one report, the counterpart of AssertionScope from post 15. With deliberately wrong expectations:
Multiple failures or warnings in test:
1) Assert.That(titles, Has.Exactly(2).Items)
Expected: exactly 2 items
But was: 1 matching item < "9780441172719" >
2) Assert.That(titles, Does.Contain("9780141439587"))
Expected: some item equal to "9780141439587"
But was: < "9780441172719" >
3) Assert.That(ReorderPolicy.DaysOfCover(10, 3), Is.EqualTo(3.3).Within(0.01))
Expected: 3.2999999999999998d +/- 0.01d
But was: 3.3333333333333335d
Off by: -0.033333333333333659d
The first line of each entry shows the assertion's source code (via CallerArgumentExpression, since NUnit 4). The classic model (Assert.AreEqual(expected, actual), Assert.IsTrue) was moved to the NUnit.Framework.Legacy namespace under the name ClassicAssert in NUnit 4.0; 4.5 brought these methods back onto Assert using C# 14's support for extension members on static classes. In new code the constraint model gives more readable messages; NUnit.Analyzers suggests the same with the NUnit2005 warning on ClassicAssert calls.
TestCase, TestCaseSource, Values and Combinatorial
[TestCase] is the counterpart of xUnit's [InlineData]; the difference is that it can also take the expected value. The test method returns a value, and NUnit compares it with ExpectedResult:
[TestCase(30, 2.0, ExpectedResult = 15.0)]
[TestCase(10, 4.0, ExpectedResult = 2.5)]
[TestCase(0, 3.0, ExpectedResult = 0.0)]
public double Days_of_cover_is_available_divided_by_daily_sales(int available, double dailySales) =>
ReorderPolicy.DaysOfCover(available, dailySales);
If the data has to be computed, or each row needs a name, use [TestCaseSource]. In NUnit 5.0, TestCaseData.Create(...) infers the argument types and .Returns(...) checks the type of the expected result as well; in 4.x, Returns took an object, and a result of the wrong type was only noticed at runtime:
private static IEnumerable<TestCaseDataWithReturn<int, int, bool>> WarehouseSplits()
{
yield return TestCaseData.Create(10, 3).SetName("13 copies in two warehouses").Returns(true);
yield return TestCaseData.Create(10, 4).SetName("14 copies in two warehouses").Returns(false);
}
[TestCaseSource(nameof(WarehouseSplits))]
public bool Stock_is_summed_across_warehouses(int istanbul, int ankara)
{
ReorderPolicy policy = new(minimumDaysOfCover: 7);
StockItem[] items = [new(Dune, "Istanbul", istanbul, 0), new(Dune, "Ankara", ankara, 0)];
return policy.TitlesToReorder(items, new Dictionary<string, double> { [Dune] = 2 }).Contains(Dune);
}
Two sales a day and a seven-day minimum mean 14 copies; 13 copies across two warehouses are not enough, 14 are. [Values] and [Range] give a list of values per parameter, and [Combinatorial] (the default) produces every combination:
[Test, Combinatorial]
public void Days_of_cover_is_never_negative(
[Values(0, 1, 100)] int available, [Range(0.5, 2.0, 0.5)] double dailySales) =>
Assert.That(ReorderPolicy.DaysOfCover(available, dailySales), Is.GreaterThanOrEqualTo(0));
3 × 4 = 12 test cases. [Sequential] pairs the values in order, and [Pairwise] picks a smaller set that covers every pair of values at least once. Which values to choose (boundary values, equivalence classes) is the subject of the upcoming Test Design Techniques series.
SetUp, OneTimeSetUp and SetUpFixture
[SetUp] and [TearDown] run before and after every test. [OneTimeSetUp] and [OneTimeTearDown] run once per fixture; because the fixture instance is shared, they can be instance methods (IClassFixture in xUnit needed a separate class). [SetUpFixture] runs once before and after all the fixtures in a namespace:
// Runs once before all fixtures in this namespace; holds read-only data only
[SetUpFixture]
public class InventoryTestRun
{
public static IReadOnlyList<StockItem> LargeWarehouse { get; private set; } = [];
[OneTimeSetUp]
public void BuildLargeWarehouse() =>
LargeWarehouse =
[
.. Enumerable.Range(0, 10_000)
.Select(i => new StockItem($"978{i:D10}", i % 2 == 0 ? "Istanbul" : "Ankara", OnHand: i % 50, Reserved: 0)),
];
}
The 10,000-item warehouse is built once, and a test in ReorderPolicyTests reads it (1,400 titles have less than 7 in stock). The shared data here is an immutable list; had it been a shared InMemoryStockLedger, we would have had a bigger version of the leak above. NUnit 5.0 also added TestContext.CurrentContext.ActiveTests inside [OneTimeSetUp], which shows the tests that passed the filter: useful for skipping an expensive setup when no test that needs it is going to run.
From 4.x to 5.0: Build Errors and Async Assertions
NUnit 5.0.0 was released on September 27, 2026, under the MIT license. The "NUnit 5 - A summary of the breaking changes" page in the NUnit documentation lists the changes one by one with their reasons; that is the page to read before upgrading. The other test file of the project from Predict First looked like this on 4.6.1:
using Bookstore.Inventory;
using NUnit.Framework.Legacy;
using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert;
namespace Bookstore.Inventory.Tests;
[TestFixture]
public class InMemoryStockLedgerTests
{
private const string Dune = "9780441172719";
private InMemoryStockLedger ledger = null!;
[SetUp]
public void SetUp() => ledger = new InMemoryStockLedger().Receive(Dune, 10);
[Test]
public void Reserving_more_than_available_is_rejected()
{
Assert.ThrowsAsync<InsufficientStockException>(() => ledger.ReserveAsync(Dune, 25, CancellationToken.None));
}
[Test]
public void Rejection_reports_the_available_quantity()
{
InsufficientStockException? ex = Assert.ThrowsAsync<InsufficientStockException>(
async () => await ledger.ReserveAsync(Dune, 25, CancellationToken.None));
ClassicAssert.AreEqual(10, ex?.Available);
}
[Test]
public void Negative_daily_sales_are_rejected()
{
TestDelegate daysOfCover = () => ReorderPolicy.DaysOfCover(10, -1);
Assert.Throws<ArgumentOutOfRangeException>(daysOfCover);
}
[Test]
public void Reservation_reduces_available_stock()
{
ledger.ReserveAsync(Dune, 4, CancellationToken.None).GetAwaiter().GetResult();
ClassicAssert.AreEqual(6, ledger.Available(Dune));
CollectionAssert.AreEqual(new[] { 6 }, new[] { ledger.Available(Dune) });
}
}
4.6.1 gave two warnings; NUnit announces the upgrade in advance:
InMemoryStockLedgerTests.cs(34,9): warning CS0618: 'TestDelegate' is obsolete: 'Use Action instead of TestDelegate'
InMemoryStockLedgerTests.cs(35,9): warning CS0618: 'Assert.Throws<TExpected>(TestDelegate)' is obsolete: 'Use overload with Action instead of TestDelegate'
With the version set to 5.0.0 in Directory.Packages.props, the build stopped with three errors (the first came on its own; the other two showed up after fixing it):
InMemoryStockLedgerTests.cs(3,49): error CS0234: The type or namespace name 'CollectionAssert' does not exist in the namespace 'NUnit.Framework.Legacy' (are you missing an assembly reference?)
InMemoryStockLedgerTests.cs(24,42): error CS0029: Cannot implicitly convert type 'System.Threading.Tasks.Task<Bookstore.Inventory.InsufficientStockException?>' to 'Bookstore.Inventory.InsufficientStockException'
InMemoryStockLedgerTests.cs(33,9): error CS0246: The type or namespace name 'TestDelegate' could not be found (are you missing a using directive or an assembly reference?)
All three are deliberate changes in 5.0:
<ul>
<li><code>StringAssert</code>, <code>CollectionAssert</code>, <code>FileAssert</code> and <code>DirectoryAssert</code> moved back to <code>NUnit.Framework</code> from <code>NUnit.Framework.Legacy</code>, where 4.0 had put them. The aliases added during the 4.0 migration break; deleting the alias was enough. <code>ClassicAssert</code> is still in <code>Legacy</code>.</li>
<li><code>Assert.ThrowsAsync</code>, <code>CatchAsync</code> and <code>DoesNotThrowAsync</code> are now truly asynchronous and return a <code>Task</code>; they have to be awaited. A call whose result is assigned to a variable no longer compiles, and the test method becomes <code>async Task</code>.</li>
<li><code>TestDelegate</code>, <code>AsyncTestDelegate</code> and <code>ActualValueDelegate<T></code> are gone; use <code>Action</code>, <code>Func<Task></code> and <code>Func<T></code> instead. With many usages, the documentation suggests a <code>global using TestDelegate = System.Action;</code> alias.</li>
<li>Changes that do not cause build errors but change behavior: <code>[Platform("NET")]</code> (Predict First), <code>Is.SameAs</code> no longer compiling for value types, <code>[Order]</code> becoming obsolete in favor of <code>[DependsOnTest]</code>, and the .NET 6 target being dropped (5.0 is built for <code>net462</code>, <code>net8.0</code> and <code>net10.0</code>).</li>
</ul>
The most dangerous one is the test in the first row: the result of Assert.ThrowsAsync is not used, so there is no build error. According to the documentation, an async assertion that is not awaited is not evaluated. In our project this test still worked correctly: when I broke ReserveAsync so that it allowed 25 copies, the test turned red, because InMemoryStockLedger completes synchronously and the assertion runs and records the failure before the test method returns. When the code was truly asynchronous (for example with an await Task.Yield() in between, as with a real database or HTTP call), the same code passed green despite the misbehaving code. In other words, whether the test can be trusted depends on whether an await inside the code under test actually waits.
The way to catch this at build time is NUnit.Analyzers. After adding version 4.15.0 (September 12, 2026):
InMemoryStockLedgerTests.cs(18,9): error NUnit2059: Method 'ThrowsAsync' returns a Task and is not being observed for completion or errors (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2059.md)
InMemoryStockLedgerTests.cs(27,9): warning NUnit2005: Consider using the constraint model, Assert.That(actual, Is.EqualTo(expected)), instead of the classic model, ClassicAssert.AreEqual(expected, actual) (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2005.md)
NUnit2059 is an error by default; the code fix in the IDE adds the await and makes the method async Task. Adding the analyzer before the upgrade shows which tests will be affected before you build against 5.0.
Parallelism, TestContext and Microsoft.Testing.Platform
In NUnit, parallelism is off by default; it is switched on with [Parallelizable], and ParallelScope sets the scope: Self (only this test or fixture), Children (child tests among themselves), Fixtures (fixtures with each other), All. In the project, at assembly level:
// Fixtures run in parallel; tests inside one fixture run one after another
[assembly: Parallelizable(ParallelScope.Fixtures)]
This is a starting point that fits the shared fixture instance: tests in the same fixture use the same fields, so they run one after another. If you also want to run tests in parallel (ParallelScope.All or Children), first give each test its own instance with [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]. [NonParallelizable] leaves a single fixture or test out. The number of concurrent workers is set with [assembly: LevelOfParallelism(n)].
TestContext.CurrentContext gives information such as the test's name, its result, the test directory and a CancellationToken. When [CancelAfter(ms)] is put on a test or fixture, the token is cancelled after that time; in the tests we passed it to ReserveAsync instead of CancellationToken.None. For output, use TestContext.Out.WriteLine; for live progress in a long test, TestContext.Progress.
NUnit runs on Microsoft.Testing.Platform (MTP) through NUnit3TestAdapter; version 6.3.1 was released on October 9, 2026. Because the series' global.json runs dotnet test in MTP mode, two properties in the test project were enough:
<PropertyGroup>
<OutputType>Exe</OutputType>
<EnableNUnitRunner>true</EnableNUnitRunner>
</PropertyGroup>
dotnet test now runs the xUnit and NUnit projects in the solution together (124 tests, all green). One difference: --treenode-filter, which xUnit supports, ended with "Zero tests ran" on the NUnit project; NUnit uses the --filter "FullyQualifiedName~SharedLedgerTests" syntax that comes from VSTest.
In Production
Upgrade in two steps. First stay on 4.6.1, add the latest NUnit.Analyzers, and clear the CS0618 and NUnit2059 warnings; this step does not change behavior. Then move to 5.0.0. Even if no build errors remain, go through the tests that use [Platform] one by one; a change in the skipped count in the results is to be expected.
Keep the analyzer up to date. The dotnet new nunit template of the .NET 10 SDK (10.0.404) adds NUnit.Analyzers, but version 4.7.0 (together with NUnit 4.3.2 and NUnit3TestAdapter 5.0.0); NUnit2059 needs at least 4.14. Upgrade the versions by hand in older projects created from the template; rules like NUnit2059 are the only thing that catches async mistakes the compiler cannot see.
In a mixed solution, keep the frameworks in a single runner mode. xUnit v3, MSTest and NUnit projects run together in MTP with the same dotnet test command; leaving one project in VSTest mode brings back the mode-mixing error we covered in post 02.
Common Pitfalls
State created in [OneTimeSetUp] leaking between tests. Because the fixture instance is shared, every mutable object set up in [OneTimeSetUp] is common property of all tests; the result then depends on the order the tests run in. If you share, share read-only data, and set up changing state in [SetUp].
An async assertion that is not awaited. Assert.ThrowsAsync(...) lines left over from 4.x compile on 5.0, but verify nothing where the code is truly asynchronous. Without the analyzer, nobody notices.
Mistaking a field initializer for a constructor. In xUnit a field initializer runs for every test; in NUnit, once per fixture. SharedLedgerTests above is the shortest example.
Writing the assertion the wrong way round. The order in Assert.That(actual, Is.EqualTo(expected)) is the reverse of xUnit's Assert.Equal(expected, actual); in teams that use both frameworks, "Expected" and "But was" swap places in the failure message.
A Small Experiment
★☆☆ Add SharedLedgerTests to your project and run it, first as is, then with [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]. Then remove the attribute and set up the ledger in [SetUp]. Compare the three results.
★★☆ Add a third row to WarehouseSplits and write .Returns("yes"). What happens on 5.0? What happens when you write the same row in the 4.x form, new TestCaseData(10, 5).Returns("yes")?
★★★ Upgrade an NUnit 4 test project in your own work to 5.0 with the two steps in this post. How many CS0618 and NUnit2059 did you find, and did the skipped count change?
In the next post, the last of the series, we look at MSTest 4: MSTest.Sdk, [DataRow]/[DynamicData], the Assert API and what changed from 3.x to 4.x, TestContext, parallelism and the analyzers; and at the end, a quick look at TUnit and the "which framework" question.