csharp-nunit
Skill2 starsChanged 42 days agoMITView on GitHub
Use when writing or reviewing C#/.NET tests with modern NUnit (4.x) — the constraint model, `Assert.EnterMultipleScope()`, lifecycle and parallelism attributes, data-driven tests, and running on Microsoft.Testing.Platform. Steers away from NUnit 2/3-era idioms (classic asserts, `Assert.Multiple(() => ...)`, format-string messages). Pairs with the tdd skill.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- github/awesome-copilottheir own version, first seen Oct 25, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- github/awesome-copilot★ 41ksame name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository56 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- benchmarkingSkill
- commit-messagesSkill
- csharp-15-syntaxSkill
- csharp-aot-trimmingSkill
- csharp-api-designSkill
- csharp-assembly-inspectionSkill
- csharp-asyncSkill
- csharp-collections-modernSkill
- csharp-concurrency-primitivesSkill
- csharp-data-layoutSkill
- csharp-debuggingSkill
- csharp-discardsSkill
- csharp-disposable-patternsSkill
- csharp-docsSkill
- csharp-error-handlingSkill
- csharp-extension-membersSkill
- csharp-generics-modernSkill
- csharp-guard-clausesSkill
- csharp-json-source-generationSkill
- csharp-language-versionsSkill
- csharp-logging-diagnosticsSkill
- csharp-lspSkill
- csharp-modern-typesSkill
- csharp-nullabilitySkill
- csharp-nullable-migrationSkill
- csharp-pattern-matchingSkill
- csharp-performanceSkill
- csharp-polyfillsSkill
- csharp-source-generatorsSkill
- csharp-static-lambdasSkill
- csharp-string-handlingSkill
- csharp-tunitSkill
- csharp-verificationSkill
- dispatching-subagentsSkill
- dotnet-11-librariesSkill
- dotnet-analyzersSkill
- dotnet-central-package-managementSkill
- dotnet-file-based-appsSkill
- dotnet-msbuild-usingsSkill
- dotnet-multi-targetingSkill
- dotnet-project-hygieneSkill
- dotnet-runSkill
- dotnet-runtime-asyncSkill
- dotnet-slnxSkill
- dotnet-test-platformsSkill
- executing-plansSkill
- performance-bounded-deduplicationSkill
- roslyn-analyzer-performanceSkill
- roslyn-discoverySkill
- roslyn-duplicate-detectionSkill
- roslyn-guard-rewritersSkill
- roslyn-rewritersSkill
- subagent-driven-developmentSkill
- tddSkill
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 0 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 40
If you're maintaining an old suite full of ClassicAssert/StringAssert/CollectionAssert, that's NUnit.Framework.Legacy — leave it if …
- Very specific: line 129
Default fixture lifecycle creates one instance of the fixture class for the whole class, so instance fields persist across [Test] methods …
- Very specific: line 162
Put [Parallelizable(ParallelScope.All)] on fixtures by default.
- Very specific: line 163
Avoid static mutable state in the fixture or the code under test.
- Very specific: line 196
Prefer a static typed source method returning IEnumerable<TestCaseData> over object[] arrays — .SetName(...) gives readable test explorer …
- Very specific: line 236
Treat NUnit.Analyzers as required, not optional, and build with warnings as errors so a flagged pattern (a constant passed as actual, a …
- Very specific: line 244
Every test that awaits anything is async Task — never async void.
- Very specific: line 245
Never block on async work with .Result or .GetAwaiter().GetResult() inside a test — await it.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Writing decent NUnit tests
NUnit 4.x is the constraint model (Assert.That(actual, constraint)) running by default on VSTest, with first-class support for Microsoft.Testing.Platform via NUnit3TestAdapter's NUnit runner. This skill targets NUnit 4.6.x with NUnit.Analyzers — treat the analyzer as required, not optional.
Don't write NUnit 2/3-era tests
A lot of NUnit code online still shows classic asserts and lambda-based multiple assertions. In NUnit 4 these are legacy, not idiomatic:
// ❌ Classic assert — moved to NUnit.Framework.Legacy in NUnit 4, don't reach for it in new code
ClassicAssert.AreEqual(expected, actual);
Assert.IsNotNull(thing);
// ❌ Assert.Multiple with a lambda — allocates a closure, and awaiting inside the
// lambda doesn't do what you think (the delegate isn't awaited by Assert.Multiple)
Assert.Multiple(() =>
{
Assert.That(a, Is.EqualTo(1));
Assert.That(b, Is.EqualTo(2));
});// ✅ Constraint model, always
Assert.That(actual, Is.EqualTo(expected));
// ✅ Disposable multiple-assertion scope (NUnit 4.2+) — no lambda, no closure
using (Assert.EnterMultipleScope())
{
Assert.That(a, Is.EqualTo(1));
Assert.That(b, Is.EqualTo(2));
}If you're maintaining an old suite full of ClassicAssert/StringAssert/CollectionAssert, that's NUnit.Framework.Legacy — leave it if migrating wholesale is out of scope, but don't add more of it.
Assert.EnterMultipleScope() instead of Assert.Multiple(() => ...)
Introduced in NUnit 4.2, Assert.EnterMultipleScope() returns an IDisposable that you open with a using block (or using var). It replaced the lambda form as the preferred API — NUnit's own analyzer (NUnit2045/NUnit2056) now nudges Assert.Multiple(() => ...) toward it.
Why it's better than the lambda:
- No closure allocation and no delegate indirection — it's a plain scope, not a callback.
- Works naturally with
awaitinside the block. The lambda form's delegate is aFunc<Task>only if you opt in, and awaiting the wrong overload silently doesn't collect failures the way you expect. Ausingscope has no such trap —awaitfreely between assertions and everyAssert.Thatinside is still gathered. - Clearer stack traces — failures point at the actual assertion line inside the
usingblock instead of a lambda frame.
[Test]
public async Task GetOrder_ReturnsExpectedFields()
{
var order = await _sut.GetOrderAsync(42);
using (Assert.EnterMultipleScope())
{
Assert.That(order.Id, Is.EqualTo(42));
Assert.That(order.Status, Is.EqualTo(OrderStatus.Shipped));
var lines = await _sut.GetLineItemsAsync(order.Id); // await freely inside the scope
Assert.That(lines, Has.Count.GreaterThan(0));
}
}Scopes nest and compose across async calls in the same method — there's no separate Assert.MultipleAsync; EnterMultipleScope() is the one API for both sync and async bodies. If you're stuck on NUnit 3, Assert.Multiple(() => ...) is the only option — upgrade if you can.
The constraint model — Assert.That is the only assert you write
Assert.That(actual, Is.EqualTo(expected));
Assert.That(3.14159, Is.EqualTo(3.14).Within(0.01));
Assert.That(numbers, Is.EquivalentTo(new[] { 3, 2, 1 })); // set-like, ignores order
Assert.That(numbers, Has.Exactly(1).EqualTo(2));
Assert.That(numbers, Is.Ordered);
Assert.That(text, Does.Contain("world"));
Assert.That(text, Does.StartWith("hello"));Compound constraints — .And / .Or chain onto the same actual value, and Has.Property("Name").EqualTo(...) reaches into a member without a separate variable:
Assert.That(value, Is.GreaterThan(0).And.LessThan(10));
Assert.That(value, Is.EqualTo(1).Or.EqualTo(2).Or.GreaterThan(4));
Assert.That(exception, Has.Property("Message").EqualTo("boom"));Compose one expressive constraint instead of several asserts on the same value. Chain it with .And/.Or, Has.Property(...), Throws...With..., and modifiers such as .Within(...), .Using(...), .IgnoreCase and .After(timeout, pollInterval). Check docs.nunit.org for the exact call before using a constraint method not shown here.
Exceptions
// Sync — constraint model
Assert.That(() => sut.Divide(1, 0), Throws.TypeOf<DivideByZeroException>().With.Message.Contains("zero"));
// Async delegate — assert against the result of the awaited call
await Assert.ThatAsync(async () => await sut.AddAsync(2, 3), Is.EqualTo(5));
// Classic exception helper for async void-ish throws — still the standard way to assert an async Task throws
Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await sut.FailingOperationAsync();
});Assert.ThatAsync applies a constraint to the result of an async delegate (Func<Task<T>>) and must be awaited — it isn't a polling helper. For polling a value until it settles, use the .After(timeoutMs, pollIntervalMs) modifier on a normal constraint:
Assert.That(() => cache.IsWarmed, Is.True.After(2000, 20));Messages are interpolated strings, not format args
NUnit 4 dropped the params object[] composite-format overloads. The message parameter is a plain string or an interpolated string — no separate format arguments:
// ❌ NUnit 3 style — no longer compiles against modern overloads
Assert.That(actual, Is.EqualTo(expected), "Expected {0} but got {1}", expected, actual);
// ✅ NUnit 4 — interpolate directly; only formats the string if the assertion actually fails
Assert.That(actual, Is.EqualTo(expected), $"Expected {expected} but got {actual}");Lifecycle
Default fixture lifecycle creates one instance of the fixture class for the whole class, so instance fields persist across [Test] methods unless reset in [SetUp]. Opt into a fresh instance per test instead — it's safer, especially once you parallelize:
[TestFixture]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class OrderServiceTests
{
private readonly OrderService _sut = new(); // fine to init inline — a new instance every test
[SetUp]
public void SetUp() { /* only for things that can't be a field initializer, e.g. async setup */ }
[Test]
public void Total_AppliesDiscount() { ... }
}- With
InstancePerTestCase, plain field initializers or a constructor can do what[SetUp]does — reach for[SetUp]/[TearDown]only when setup needs to beasync Task(constructors can't be async) or needs theTestContext. IDisposable/IAsyncDisposableon the fixture is honored per instance underInstancePerTestCase, so it's a reasonable teardown spot too — pick one pattern (hooks or dispose) per fixture, don't mix.[OneTimeSetUp]/[OneTimeTearDown]are still per-class (static-ish, run once regardless of instance lifecycle) — use them only for genuinely expensive shared resources (a container, a compiled schema), and make the fixture[NonParallelizable]if that resource isn't safe for concurrent use.- Async setup:
[SetUp]/[OneTimeSetUp]can beasync Task— neverasync void.
Parallelism
[TestFixture]
[Parallelizable(ParallelScope.All)] // this fixture's tests run in parallel with each other and other fixtures
public class OrderServiceTests { ... }
[assembly: Parallelizable(ParallelScope.Fixtures)] // assembly default: fixtures run in parallel, tests within a fixture don't
[assembly: LevelOfParallelism(4)]- Put
[Parallelizable(ParallelScope.All)]on fixtures by default; reserve[NonParallelizable]for a fixture that genuinely shares a resource (a fixed port, a shared file, a singleton you can't isolate) — treat needing it as a prompt to isolate the resource instead, not a default. - Avoid
staticmutable state in the fixture or the code under test; combined withInstancePerTestCaseand parallel execution, static state is exactly what races. - Output is not a check. Never use
Console.WriteLineorTestContext.Outin place of an assertion: a test that prints a value still passes when the value is wrong. To find an unknown expected value, assert what you expect and read the actual value from the failure message, or explore in a scratch app outside the repo. UseTestContext.Out.WriteLine(...)only for genuine diagnostics alongside real assertions; it is captured per test, unlikeConsole.WriteLineunder parallel runs.TestContext.CurrentContextexposes the running test's name, properties and work directory. [CancelAfter(ms)]on a test cancels aCancellationTokenparameter after the timeout — accept the token and thread it through, the same way you would for a real caller-supplied cancellation:
[Test]
[CancelAfter(5000)]
public async Task ReadAsync_CompletesInTime(CancellationToken cancellationToken)
{
var result = await _sut.ReadAsync(cancellationToken);
Assert.That(result, Is.Not.Null);
}Data-driven tests
[TestCase(1, 2, 3)]
[TestCase(-1, 1, 0)]
public void Add_Cases(int a, int b, int expected)
=> Assert.That(a + b, Is.EqualTo(expected));
[TestCaseSource(nameof(Cases))]
public void Add_FromSource(int a, int b, int expected)
=> Assert.That(a + b, Is.EqualTo(expected));
private static IEnumerable<TestCaseData> Cases()
{
yield return new TestCaseData(1, 2, 3).SetName("OnePlusTwo");
yield return new TestCaseData(-5, 5, 0).SetName("NegFivePlusFive");
}- Prefer a
statictyped source method returningIEnumerable<TestCaseData>overobject[]arrays —.SetName(...)gives readable test explorer names,.Returns(...)lets the test method itself return a value for NUnit to compare. [Values(1, 2, 3)]on a parameter + another[Values(...)]on a second parameter gives the combinatorial cross-product by default; add[Combinatorial]/[Pairwise]/[Sequential]on the method to control the strategy explicitly.[Range(1, 10, 2)]generates a numeric sequence.- Generic test fixtures (
[TestFixture(typeof(int))]over a class with a generic type parameter) exist for testing the same behavior across types — reach for them before hand-duplicating a fixture per type.
Running: Microsoft.Testing.Platform vs VSTest
NUnit3TestAdapter 5+ ships an NUnit runner on Microsoft.Testing.Platform (MTP) as an alternative to the classic VSTest adapter path. Opt in per project:
<PropertyGroup>
<OutputType>Exe</OutputType>
<EnableNUnitRunner>true</EnableNUnitRunner>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>With that in place, dotnet run in the test project executes the suite directly through MTP. To make dotnet test use the MTP path too (rather than falling back to VSTest), set it at the repo root in global.json:
{
"test": { "runner": "Microsoft.Testing.Platform" }
}Once that's set, every test project dotnet test touches is expected to be MTP-enabled — mixing an MTP project with a VSTest-only project in the same dotnet test invocation is an error. Filtering uses the familiar VSTest-style expression on both paths: dotnet test --filter "FullyQualifiedName~Add_Cases". --list-tests enumerates discovered tests without running them.
Required package set:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="NUnit" Version="4.6.*" />
<PackageReference Include="NUnit3TestAdapter" Version="6.*" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>Treat NUnit.Analyzers as required, not optional, and build with warnings as errors so a flagged pattern (a constant passed as actual, a suggestion to use EnterMultipleScope, a redundant assertion) fails the build rather than sitting as a warning nobody reads:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>Async tests
- Every test that awaits anything is
async Task— neverasync void. Anasync voidtest can't be awaited by the runner, so a failure inside it can crash the process instead of failing the test. - Never block on async work with
.Resultor.GetAwaiter().GetResult()inside a test —awaitit. Blocking risks deadlocks and defeats the point of[CancelAfter]/timeout handling, which relies on being able to observe cancellation through the awaited chain. Assert.ThrowsAsync<T>(...)for asserting an async delegate throws;Assert.ThatAsync(...)for asserting a constraint against an async delegate's result. Both must be awaited.
Migrating from NUnit 3
| NUnit 3 idiom | NUnit 4 replacement |
Assert.AreEqual(expected, actual) / Assert.IsTrue(x) | Assert.That(actual, Is.EqualTo(expected)) / Assert.That(x, Is.True) — or NUnit.Framework.Legacy.ClassicAssert if you truly can't touch the call site yet |
StringAssert.* / CollectionAssert.* | Assert.That(...) with Does.* / Is.EquivalentTo(...) / Has.*, or NUnit.Framework.Legacy.StringAssert / CollectionAssert as a fallback |
Assert.Multiple(() => { ... }) | using (Assert.EnterMultipleScope()) { ... } |
Assert.That(actual, Is.EqualTo(expected), "msg {0}", arg) | Assert.That(actual, Is.EqualTo(expected), $"msg {arg}") — composite-format overloads are gone |
| Default one-instance-per-fixture lifecycle assumed safe under parallel runs | Explicit [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] when you want fresh state per test |
Hand-rolled Task.Delay + re-check loops for eventual state | Is.<Constraint>.After(timeoutMs, pollIntervalMs) |
dotnet test always meant VSTest | dotnet test can run on Microsoft.Testing.Platform via EnableNUnitRunner + global.json test.runner |
Checklist
Assert.That(actual, constraint)everywhere; no bareClassicAssert/StringAssert/CollectionAssertin new code- Multiple related assertions grouped with
using (Assert.EnterMultipleScope()), notAssert.Multiple(() => ...) - Messages are plain or interpolated strings, no leftover
{0}-style format args - Async tests are
async Task, neverasync void; no.Result/.GetAwaiter().GetResult() [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]set where fresh-instance-per-test matters (most fixtures)[Parallelizable(ParallelScope.All)]is the default;[NonParallelizable]only where a real shared resource forces it- No mutable
staticstate read or written by tests - Data-driven cases use typed
[TestCase]/TestCaseDatasources with.SetName(...), not magicobject[]arrays NUnit.Analyzersreferenced and warnings treated as errors[CancelAfter]/CancellationTokenthreaded through for tests that can hang- No
Console.WriteLinestanding in for an assertion; tests named for the behaviour under test
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

