dotnet-unit-tests
.NET unit testing (C# / F# / VB.NET) with xUnit.net as the primary framework - `[Fact]` single tests, `[Theory]` + `[InlineData]`/`[ClassData]`/`[MemberData]` parametrization, class and collection fixtures (`IClassFixture` / `ICollectionFixture`), parallel-execution config, `ITestOutputHelper` output, skip/traits filtering, and `dotnet test` CI with trx + coverage. Includes framework choice (xUnit for new projects; match an existing NUnit/MSTest convention detected from csproj PackageReferences; legacy .NET Framework 4.x → NUnit or MSTest) and test-authoring conventions (AAA mapping, argument-order traps, no fabricated methods, no smoke asserts). References cover NUnit (`[TestCase]`, constraint-model `Assert.That`), MSTest (`[TestClass]` / `[DataRow]` / TestContext), and the FluentAssertions `.Should()` catalog including the v8 commercial-license change. Use for any .NET unit-test task: choosing or configuring a framework, writing or parameterizing tests, fixtures, or wiring CI.
Install with skills.sh (any agent)
npx skills add testland/qa --skill dotnet-unit-testsdotnet-unit-tests
Overview
Per xunit.net (opens in new window):
xUnit.net is the current .NET test standard (used by .NET Foundation projects and Microsoft's own .NET runtime). v3 released 2024; v2 still widely used in production. This skill covers xUnit as the default, with NUnit and MSTest as references for existing conventions. Lifecycle scope (configure / run / parameterize / fixtures / CI); test code hygiene is in test-code-conventions (qa-test-review).
Choosing a framework
Step 1 - Install
dotnet new xunit -n MyProjectTests
# Or in existing project:
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.SdkStep 2 - First test
using Xunit;
public class CalculatorTests
{
[Fact]
public void Adds_TwoNumbers()
{
Assert.Equal(3, Calculator.Add(1, 2));
}
}Run: dotnet test. Verify the run reports Passed! with the expected test count; if it discovers 0 tests, confirm the class is public, the method carries [Fact], and all three packages are installed.
Step 3 - Parametrized tests
Per xn-docs (opens in new window):
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Adds_VariousInputs(int a, int b, int expected)
{
Assert.Equal(expected, Calculator.Add(a, b));
}
// Method-based data source
public static IEnumerable<object[]> AddCases =>
new List<object[]> {
new object[] { 1, 2, 3 },
new object[] { 0, 0, 0 },
};
[Theory]
[MemberData(nameof(AddCases))]
public void Adds_FromMemberData(int a, int b, int expected) { ... }[ClassData(typeof(AddTestData))] covers class-based sources (IEnumerable<object[]> implementations). A [Theory] without a data attribute never runs.
Step 4 - Skip + traits
[Fact(Skip = "Requires staging DB")]
public void SkippedTest() { }
[Fact]
[Trait("Category", "Integration")]
public void IntegrationTest() { }
// Filter: dotnet test --filter "Category=Integration"Step 5 - Fixtures and parallelism
xUnit's lifecycle: constructor as setup, IDisposable.Dispose as teardown - a new test-class instance per test. Shared setup scales up through fixtures:
// Class fixture: shared across all tests in one class
public class DatabaseFixture : IDisposable {
public DbConnection Connection { get; }
public DatabaseFixture() { Connection = OpenConnection(); }
public void Dispose() { Connection.Close(); }
}
public class UserTests : IClassFixture<DatabaseFixture> {
private readonly DatabaseFixture _fixture;
public UserTests(DatabaseFixture fixture) { _fixture = fixture; }
}
// Collection fixture: shared across multiple test classes
[CollectionDefinition("DbCollection")]
public class DbCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("DbCollection")]
public class TestsA { ... }
[Collection("DbCollection")]
public class TestsB { ... } // shares the same DatabaseFixtureBy default xUnit runs collections in parallel; tests in the same collection run sequentially. Assembly-level tuning:
[assembly: CollectionBehavior(DisableTestParallelization = true)]
// or
[assembly: CollectionBehavior(MaxParallelThreads = 4)]Pattern: DB-backed classes share one fixture via [Collection] (runs sequentially against the shared connection) while pure-logic tests parallelize freely.
Step 6 - Output (ITestOutputHelper)
xUnit suppresses Console.WriteLine in tests:
public class TestsWithOutput {
private readonly ITestOutputHelper _output;
public TestsWithOutput(ITestOutputHelper output) { _output = output; }
[Fact]
public void LogsContext() {
_output.WriteLine("Test running at {0}", DateTime.UtcNow);
}
}Step 7 - CI integration
- run: dotnet test --logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- uses: codecov/codecov-action@v4
with: { files: ./coverage/coverage.opencover.xml }The same dotnet test --logger trx --collect shape works for NUnit and MSTest projects.
Authoring conventions
When authoring a new unit test in an existing project:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Console.WriteLine instead of ITestOutputHelper | Output suppressed | Step 6 |
[Theory] without a data attribute | Test never runs | Always include [InlineData] etc. (Step 3) |
Shared mutable state in IClassFixture | Test-order dependence | Per-test fresh state or [Collection] synchronization (Step 5) |
| Static fields for cross-test state | xUnit creates a new instance per test; statics leak | Constructor per test; fixtures for shared setup |
Conflating xUnit constructor-per-test with NUnit [OneTimeSetUp] | Constructor runs every test | IClassFixture<T> for per-fixture setup |
| Skip parallel tuning at scale | Slow CI | Assembly + collection config (Step 5) |
Limitations
References
FluentAssertions - fluent .NET assertions (reference)
View source (opens in new window)FluentAssertions - fluent .NET assertions (reference)
Companion reference for dotnet-unit-tests. Per fluentassertions.com (opens in new window), FluentAssertions is the de facto fluent-assertion library for .NET - it pairs with xUnit, NUnit, or MSTest (auto-detects the framework and throws framework-specific exceptions), so assertion code survives a move between frameworks.
Important license change note: from v8, "commercial use requires a paid license", while v8+ stays "free for open-source projects and non-commercial use"; v7 "will remain fully open-source indefinitely" (per fluentassertions.com/releases (opens in new window)). Commercial projects either buy a v8+ license or pin to v7; open-source and non-commercial projects can use v8+ free.
Install
dotnet add package FluentAssertions # current v8+ (see license note above)
dotnet add package FluentAssertions --version 7.0.0 # pin v7 for fully-OSS commercial useBasic syntax
Per fluentassertions.com/introduction (opens in new window) - .Should() is the fluent entry point:
using FluentAssertions;
result.Should().Be(42);
list.Should().HaveCount(3);Matcher catalog
// Equality
value.Should().Be(expected);
value.Should().NotBe(expected);
value.Should().BeNull();
value.Should().BeSameAs(other); // reference equality
// Numeric
n.Should().BeGreaterThan(0);
n.Should().BeLessThanOrEqualTo(100);
d.Should().BeApproximately(3.14, 0.01);
// String
s.Should().StartWith("prefix");
s.Should().Contain("substring");
s.Should().Match("*wildcard*");
s.Should().MatchRegex(@"\d+");
s.Should().NotBeNullOrEmpty();
// Collections
list.Should().HaveCount(3);
list.Should().Contain("alice");
list.Should().NotContain("eve");
list.Should().ContainInOrder("alice", "bob");
list.Should().AllSatisfy(x => x.Should().BePositive());
// Type checks
result.Should().BeOfType<Success>();
result.Should().BeAssignableTo<IResult>();
// Boolean
flag.Should().BeTrue();
// Custom predicates
user.Should().Satisfy(u => u.Email.Contains("@") && u.Age >= 18);Exceptions
Action act = () => DoSomething();
act.Should().Throw<ArgumentException>()
.WithMessage("*invalid*")
.Where(e => e.ParamName == "name");
// Async
Func<Task> asyncAct = async () => await DoSomethingAsync();
await asyncAct.Should().ThrowAsync<HttpRequestException>();
// Should NOT throw
act.Should().NotThrow();Always specify WithMessage - a type-only assertion passes for the wrong failure of the right type.
Chaining
.And chains assertions; .Which accesses the result for further assertion:
list.Should().HaveCount(3).And.Contain("alice").And.NotContain("eve");
result.Should().BeOfType<Success>()
.Which.Value.Should().Be(42);BeEquivalentTo deep equality
Structural comparison - the most powerful matcher (fluentassertions.com/objectgraphs):
actual.Should().BeEquivalentTo(expected); // deep equal, order-independent
// Across different types (record vs class), excluding fields:
user.Should().BeEquivalentTo(dto, opts => opts
.Excluding(u => u.PasswordHash));
// With options
actual.Should().BeEquivalentTo(expected, opts => opts
.Excluding(x => x.Timestamp)
.ComparingByMembers<MyType>()
.WithStrictOrdering()
);Options: Excluding, Including, ComparingByMembers, WithStrictOrdering, WithoutStrictOrdering, IgnoringCyclicReferences.
Migration from Assert.X
Mechanical, low-cost; the benefit is richer failure messages (object structure shown, e.g. Expected list to have 4 items, but found 3: ["alice", "bob", "charlie"]) plus chainability.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Mix Assert.X and .Should() in one suite | Reader confusion | Pick one + lint enforcement |
BeEquivalentTo without options on volatile fields | Compares fields you don't care about; brittle | Excluding(...) |
| Ship v8+ commercially without a paid license | License violation | Buy a v8+ license or pin v7 |
value.Should().Be(true) | Loses semantic clarity | BeTrue() / BeFalse() |
Limitations
References
MSTest - Microsoft first-party .NET testing (reference)
View source (opens in new window)MSTest - Microsoft first-party .NET testing (reference)
Companion reference for dotnet-unit-tests. Consult for existing MSTest projects (the Visual Studio default before ~2018) or Microsoft-toolchain shops standardized on first-party tooling. For new code, xUnit (SKILL.md) or NUnit (nunit.md (opens in new window)) are more mainstream.
Per learn.microsoft.com/dotnet/core/testing/unit-testing-with-mstest (opens in new window):
Install and first test
dotnet new mstest -n MyTests
# Or: dotnet add package MSTest.TestFramework + MSTest.TestAdapter + Microsoft.NET.Test.Sdkusing Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class CalculatorTests
{
[TestMethod]
public void Adds_TwoNumbers()
{
Assert.AreEqual(3, Calculator.Add(1, 2));
}
}[TestClass] is required - unlike NUnit, discovery fails without it. Assert.AreEqual(expected, actual) takes expected first. Run: dotnet test.
Lifecycle
Per ms-doc (opens in new window): [ClassInitialize] / [ClassCleanup] (static, once per class - ClassInitialize receives a TestContext), [TestInitialize] / [TestCleanup] (per test), and [AssemblyInitialize] / [AssemblyCleanup] at assembly level.
Parametrize
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0)]
[DataRow(-1, 1, 0)]
public void Adds_VariousInputs(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
// Dynamic data source
[TestMethod]
[DynamicData(nameof(AddCases), DynamicDataSourceType.Method)]
public void Adds_FromDynamic(int a, int b, int expected) { ... }
public static IEnumerable<object[]> AddCases()
{
yield return new object[] { 1, 2, 3 };
yield return new object[] { 0, 0, 0 };
}TestContext
Auto-injected per test instance - per-test metadata (test name, deployment dir, .runsettings properties) plus WriteLine output (the MSTest analog of xUnit's ITestOutputHelper):
[TestClass]
public class TestsWithContext
{
public TestContext TestContext { get; set; } // auto-populated by runner
[TestMethod]
public void LogsContext()
{
TestContext.WriteLine("Test name: {0}", TestContext.TestName);
}
}Skip patterns
[Ignore("Requires staging DB; tracked in JIRA-1234")] for permanent skips; Assert.Inconclusive("...") for runtime conditional skips (marks neither pass nor fail - don't overuse it, signals get lost).
Categories, parallelism, CI
[TestMethod]
[TestCategory("Integration")]
public void IntegrationTest() { }
// Filter: dotnet test --filter "TestCategory=Integration".runsettings parallelism:
<RunSettings>
<RunConfiguration>
<MaxCpuCount>4</MaxCpuCount>
</RunConfiguration>
<MSTest>
<Parallelize>
<Workers>4</Workers>
<Scope>MethodLevel</Scope>
</Parallelize>
</MSTest>
</RunSettings>Scope: MethodLevel (parallel within class) or ClassLevel (parallel across classes only).
- run: dotnet test --logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage" \
--settings test.runsettingsAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Assert.AreEqual(actual, expected) reversed | MSTest is (expected, actual); misleading diffs | Expected first, or FluentAssertions (fluentassertions.md (opens in new window)) |
Missing [TestClass] | Discovery fails | Always include |
Console.WriteLine for output | May not appear in the runner | TestContext.WriteLine |
Assert.Inconclusive overuse | Tests neither pass nor fail | [Ignore] for permanent skips |
Limitations
References
NUnit - attribute-driven .NET testing (reference)
View source (opens in new window)NUnit - attribute-driven .NET testing (reference)
Companion reference for dotnet-unit-tests. Consult for existing NUnit codebases, teams preferring constraint-model assertions, or legacy .NET Framework 4.x targets. For brand-new .NET code, xUnit (SKILL.md) is the more mainstream default.
Per docs.nunit.org (opens in new window):
NUnit's distinguishing properties vs xUnit: annotation-driven lifecycle, constraint-model assertions (Assert.That(actual, Is.EqualTo(expected))), and multiple parametrize attributes ([TestCase], [Values], [Random], [Range]).
Install and first test
dotnet new nunit -n MyTests
# Or: dotnet add package NUnit + NUnit3TestAdapter + Microsoft.NET.Test.Sdkusing NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
[Test]
public void Adds_TwoNumbers()
{
Assert.That(Calculator.Add(1, 2), Is.EqualTo(3));
}
}[TestFixture] is optional in NUnit 3+ (classes with [Test] methods are auto-discovered) - pick a convention and document it. Run: dotnet test.
Parametrize
[Test]
[TestCase(1, 2, 3)]
[TestCase(0, 0, 0)]
[TestCase(-1, 1, 0)]
public void Adds_VariousInputs(int a, int b, int expected)
{
Assert.That(Calculator.Add(a, b), Is.EqualTo(expected));
}
// Combinatorial: 3 × 2 = 6 runs
[Test]
public void Adds_FromValues([Values(1, 2, 3)] int a, [Values(0, 1)] int b)
{
Assert.That(Calculator.Add(a, b), Is.EqualTo(a + b));
}
[Test]
public void Adds_Range([Range(0, 10, 2)] int n) // n = 0, 2, 4, 6, 8, 10
{
Assert.That(Calculator.Add(n, n), Is.EqualTo(n * 2));
}
// Method-source
[Test]
[TestCaseSource(nameof(AddCases))]
public void Adds_FromSource(int a, int b, int expected) { ... }
public static IEnumerable<TestCaseData> AddCases()
{
yield return new TestCaseData(1, 2, 3);
yield return new TestCaseData(0, 0, 0);
}[Random(0, 100, 5)] generates random values - pin a seed ([Random(seed: 42, ...)]) for CI reproducibility.
Constraint-model assertions
Per nu-docs (opens in new window) - the constraint model composes (Is.Not.Null.And.Not.Empty) and produces detailed failure messages; classic Assert.AreEqual / Assert.IsTrue still work but are discouraged in NUnit 3+:
Assert.That(value, Is.EqualTo(expected));
Assert.That(value, Is.Not.EqualTo(expected));
Assert.That(value, Is.GreaterThan(0));
Assert.That(s, Does.Contain("substring"));
Assert.That(s, Does.Match("regex"));
Assert.That(list, Has.Count.EqualTo(3));
Assert.That(list, Has.Member("alice"));
Assert.That(list, Is.Ordered);
Assert.That(list, Has.All.GreaterThan(0));
Assert.That(opt, Is.Null);
Assert.That(value, Is.InstanceOf<MyClass>());
Assert.That(action, Throws.TypeOf<ArgumentException>());
Assert.That(actual, Is.EqualTo(0.0).Within(0.001)); // float toleranceLifecycle
[OneTimeSetUp] / [OneTimeTearDown] (once per fixture) and [SetUp] / [TearDown] (per test).
Categories and parameterized fixtures
[Test]
[Category("Integration")]
public void IntegrationTest() { }
// Filter: dotnet test --filter Category=Integration
[TestFixture("postgres")]
[TestFixture("mysql")]
public class DatabaseTests
{
private string _engine;
public DatabaseTests(string engine) { _engine = engine; }
[Test]
public void Connect() { /* runs against postgres AND mysql */ }
}CI
Same shape as xUnit: dotnet test --logger "trx;LogFileName=test-results.trx" --collect:"XPlat Code Coverage".
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Classic Assert.AreEqual style | Discouraged in NUnit 3+ | Constraint model Assert.That(...) |
Unseeded [Random] | Non-deterministic CI runs | [Random(seed: 42, ...)] |
| Mix NUnit + xUnit in one solution | Two runners | Pick one |