Testland
Browse all skills & agents

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-tests
View source

dotnet-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

  1. Match the existing convention first. Grep sibling test projects' .csproj for <PackageReference Include="...">: xunit / xunit.v3 xUnit; NUnit / NUnit3TestAdapter → NUnit; MSTest / MSTest.TestFramework → MSTest. If exactly one is present, match it - switching frameworks mid-solution forces a wholesale assertion rewrite for no quality gain.
  2. New project on modern .NET (net6.0+)xUnit: Microsoft's testing docs list it as the community-focused default and dotnet new xunit is a first-party template (learn.microsoft.com/dotnet/core/testing (opens in new window)).
  3. Legacy .NET Framework 4.x targetNUnit or MSTest (both span Framework 4.x and modern .NET) → references/nunit.md / references/mstest.md. MSTest also fits shops standardized on tight Visual Studio integration.
  4. FluentAssertions already in deps → retain it regardless of framework (it auto-detects xUnit, NUnit, and MSTest) → references/fluentassertions.md - including the v8 commercial-license change.

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.Sdk

Step 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 DatabaseFixture

By 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:

  1. Detect the framework + FluentAssertions from the .csproj (the PackageReference table in Choosing). Multiple framework signals in one solution → stop and ask which to use.
  2. Verify the target method signature in the production class (return type, parameters, async) - never fabricate method names the spec did not state.
  3. Map the spec to Arrange / Act / Assert - the AAA convention is shared across all three frameworks (learn.microsoft.com/dotnet/core/testing (opens in new window)). Assert observable post-conditions only (return value, collection count, thrown exception type) - not internal flags.
  4. One spec → one new test method at <TestProject>/Tests/<ClassUnderTest>Tests.cs; never modify existing test files.
  5. No smoke asserts (Assert.True(true), result.Should().NotBeNull() when a concrete value is named).
  6. Mind argument order: xUnit and MSTest take (expected, actual); NUnit's constraint model (Assert.That(actual, Is.EqualTo(expected))) and FluentAssertions (actual.Should().Be(expected)) sidestep the trap.
  7. Domain-shaped fixtures: when Bogus is in deps, use Faker<T> builders via synthetic-data-toolkit (qa-test-data); never install packages as a side effect.

Anti-patterns

Anti-patternWhy it failsFix
Console.WriteLine instead of ITestOutputHelperOutput suppressedStep 6
[Theory] without a data attributeTest never runsAlways include [InlineData] etc. (Step 3)
Shared mutable state in IClassFixtureTest-order dependencePer-test fresh state or [Collection] synchronization (Step 5)
Static fields for cross-test statexUnit creates a new instance per test; statics leakConstructor per test; fixtures for shared setup
Conflating xUnit constructor-per-test with NUnit [OneTimeSetUp]Constructor runs every testIClassFixture<T> for per-fixture setup
Skip parallel tuning at scaleSlow CIAssembly + collection config (Step 5)

Limitations

  • xUnit's "constructor as setup, IDisposable as teardown" is unintuitive vs annotation-driven frameworks.
  • Test discovery is slow on large solutions; use --filter.
  • v2 vs v3 API has minor breaking changes; pin the version per project.

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 use

Basic 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

  • Assert.AreEqual(expected, actual)actual.Should().Be(expected)
  • Assert.IsTrue(condition)condition.Should().BeTrue()
  • Assert.IsInstanceOfType(obj, typeof(MyClass))obj.Should().BeOfType<MyClass>()
  • Assert.ThrowsException<E>(action)action.Should().Throw<E>()

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-patternWhy it failsFix
Mix Assert.X and .Should() in one suiteReader confusionPick one + lint enforcement
BeEquivalentTo without options on volatile fieldsCompares fields you don't care about; brittleExcluding(...)
Ship v8+ commercially without a paid licenseLicense violationBuy a v8+ license or pin v7
value.Should().Be(true)Loses semantic clarityBeTrue() / BeFalse()

Limitations

  • BeEquivalentTo edge cases (cyclic refs, polymorphism) need explicit options.
  • .Should() can clash with other libraries' extension methods (rare).
  • C#-first; F# usage is less ergonomic.

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.Sdk
using 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.runsettings

Anti-patterns

Anti-patternWhy it failsFix
Assert.AreEqual(actual, expected) reversedMSTest is (expected, actual); misleading diffsExpected first, or FluentAssertions (fluentassertions.md (opens in new window))
Missing [TestClass]Discovery failsAlways include
Console.WriteLine for outputMay not appear in the runnerTestContext.WriteLine
Assert.Inconclusive overuseTests neither pass nor fail[Ignore] for permanent skips

Limitations

  • More verbose attributes than xUnit / NUnit.
  • Historically Visual Studio-centric; CLI integration improved but docs are still VS-flavored.
  • [DynamicData] is less ergonomic than xUnit's [MemberData].

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.Sdk
using 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 tolerance

Lifecycle

[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-patternWhy it failsFix
Classic Assert.AreEqual styleDiscouraged in NUnit 3+Constraint model Assert.That(...)
Unseeded [Random]Non-deterministic CI runs[Random(seed: 42, ...)]
Mix NUnit + xUnit in one solutionTwo runnersPick one

Limitations

  • Constraint model has a learning curve vs Assert.Equal() simplicity.
  • Parallelism is less aggressive than xUnit's parallel-by-default.

References

  • nu-docs (opens in new window) - NUnit documentation
  • docs.nunit.org/articles/nunit/writing-tests/assertions/assertion-models/constraint.html - constraint model