Skip to content

Migrate xUnit + FluentAssertions to NUnit 4.4.0 with Controlled Concurrency #4120

@glennawatson

Description

@glennawatson

Migrate xUnit + FluentAssertions to NUnit 4.4.0 with Controlled Concurrency

Goal: Convert all test projects from xUnit to NUnit 4.4.0, remove FluentAssertions, use Assert.That style, and configure per-class sequential execution with controlled parallelism across classes for ReactiveUI's static-heavy codebase. If using the static classes inside ReactiveUI, only sequential access to ensure the tests run, otherwise parallelise.

1. Update Test Project Packages

Remove

  • xunit
  • xunit.runner.visualstudio
  • FluentAssertions

Add

  • NUnit (4.4.0)
  • NUnit3TestAdapter (latest stable, e.g., 5.*)
  • Microsoft.NET.Test.Sdk (latest stable, e.g., 17.*)

Updated .csproj Example:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <IsPackable>false</IsPackable>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="NUnit" Version="4.4.0" />
    <PackageReference Include="NUnit3TestAdapter" Version="5.*" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
  </ItemGroup>
</Project>

Tip: Use version ranges (e.g., 5.*, 17.*) for CI to track minor updates, or lock to specific versions after validation.

2. Configure Concurrency for Static Classes

To prevent concurrency issues with ReactiveUI's static classes, run tests sequentially within each test fixture (class) but allow parallelism across fixtures.

2.1 Assembly-Level Concurrency

Add an AssemblyInfo.Parallel.cs file (outside any namespace) in each test project:

using NUnit.Framework;

[assembly: Parallelizable(ParallelScope.Fixtures)]
[assembly: LevelOfParallelism(4)]
  • ParallelScope.Fixtures: Runs test fixtures in parallel, but tests within each fixture sequentially.
  • LevelOfParallelism(4): Caps parallel workers (adjust based on CI resources, e.g., CPU count/2).

2.2 CI Runsettings

Create a tests.runsettings file at the repo root:

<RunSettings>
  <NUnit>
    <NumberOfTestWorkers>4</NumberOfTestWorkers>
  </NUnit>
</RunSettings>

Usage:

dotnet test --settings tests.runsettings

Override for full serialization (if static state is fragile):

dotnet test -- NUnit.NumberOfTestWorkers=1

3. xUnit to NUnit Attribute & API Mappings

xUnit NUnit 4.4.0 Equivalent
[Fact] [Test]
[Theory] + [InlineData(...)] [TestCase(...)]
[Theory] + [MemberData] [TestCaseSource(nameof(Data))]
[Theory] + [ClassData<T>] [TestCaseSource(typeof(T))]
Assert.Equal(a,b) Assert.That(b, Is.EqualTo(a))
Assert.NotEqual(a,b) Assert.That(b, Is.Not.EqualTo(a))
Assert.True(expr) Assert.That(expr, Is.True)
Assert.False(expr) Assert.That(expr, Is.False)
Assert.Null(x) Assert.That(x, Is.Null)
Assert.NotNull(x) Assert.That(x, Is.Not.Null)
Assert.Throws<T>(...) Assert.Throws<T>(...) or Assert.That(..., Throws.TypeOf<T>())
Assert.Collection/Contains Assert.That(coll, Does.Contain(item))
Trait("Category","X") [Category("X")]
IClassFixture<T> [OneTimeSetUp]/[OneTimeTearDown] with shared state
[Collection("name")] [NonParallelizable] on conflicting fixtures

4. Remove FluentAssertions: Convert to Assert.That

Common Conversions:

FluentAssertions NUnit 4.4.0 Equivalent
actual.Should().Be(expected) Assert.That(actual, Is.EqualTo(expected))
actual.Should().NotBe(expected) Assert.That(actual, Is.Not.EqualTo(expected))
flag.Should().BeTrue() Assert.That(flag, Is.True)
value.Should().BeNull() Assert.That(value, Is.Null)
str.Should().Contain("x") Assert.That(str, Does.Contain("x"))
str.Should().StartWith("pre") Assert.That(str, Does.StartWith("pre"))
n.Should().BeGreaterThan(5) Assert.That(n, Is.GreaterThan(5))
n.Should().BeInRange(1,10) Assert.That(n, Is.InRange(1,10))
items.Should().BeEmpty() Assert.That(items, Is.Empty)
items.Should().BeEquivalentTo(new[] {1,2,3}) Assert.That(items, Is.EquivalentTo(new[] {1,2,3}))
act.Should().Throw<InvalidOperationException>() Assert.That(act, Throws.TypeOf<InvalidOperationException>())
await actAsync.Should().ThrowAsync<InvalidOperationException>() Assert.ThatAsync(async () => await actAsync(), Throws.TypeOf<InvalidOperationException>())

Complex Objects: Replace FluentAssertions' deep equivalence with explicit property checks in Assert.Multiple(() => { ... }).

5. Example Conversion

Before (xUnit + FluentAssertions):

using System.Threading.Tasks;
using Xunit;
using FluentAssertions;

public class BlobCacheFacts
{
    [Fact]
    public void It_returns_value()
    {
        var result = 42;
        result.Should().Be(42);
    }

    [Theory]
    [InlineData(1, 2)]
    [InlineData(2, 3)]
    public void It_adds(int a, int b)
    {
        (a + 1).Should().Be(b);
    }

    [Fact]
    public async Task It_throws_async()
    {
        Func<Task> act = async () => { await Task.Yield(); throw new InvalidOperationException(); };
        await act.Should().ThrowAsync<InvalidOperationException>();
    }
}

After (NUnit 4.4.0):

using System;
using System.Threading.Tasks;
using NUnit.Framework;

[TestFixture]
[Category("Akavache")]
public class BlobCacheTests
{
    [Test]
    public void It_returns_value()
    {
        var result = 42;
        Assert.That(result, Is.EqualTo(42));
    }

    [TestCase(1, 2)]
    [TestCase(2, 3)]
    public void It_adds(int a, int b)
    {
        Assert.That(a + 1, Is.EqualTo(b));
    }

    [Test]
    public async Task It_throws_async()
    {
        Assert.ThatAsync(async () =>
        {
            await Task.Yield();
            throw new InvalidOperationException();
        }, Throws.TypeOf<InvalidOperationException>());
    }
}

6. Handle Akavache's Static State

  • Sequential per Fixture: Ensured by ParallelScope.Fixtures.
  • Global Static State: Mark sensitive fixtures with [NonParallelizable]:
    [TestFixture, NonParallelizable]
    public class GlobalStateSensitiveTests { ... }
  • Setup/Teardown: Use [OneTimeSetUp]/[OneTimeTearDown] for fixture-wide state, [SetUp]/[TearDown] for per-test isolation.
  • Tip: Prefer isolated instances over singletons where possible.

7. Search & Replace for Migration

Find xUnit/FluentAssertions:

rg -n "using Xunit|\\[Fact|\\[Theory|InlineData|MemberData|ClassData|Trait\\(|FluentAssertions" tests/

Replace:

  • using Xunit;using NUnit.Framework;
  • [Fact][Test]
  • [Theory][Test]
  • [InlineData([TestCase(
  • Trait("Category", "X")[Category("X")]
  • Remove using FluentAssertions;
  • Convert .Should() per section 4.

Tip: Use structural search/replace in editors and review changes manually.

8. Test Setup Conversion

xUnit IClassFixture:

public class Tests : IClassFixture<MyFixture>
{
    private readonly MyFixture _fx;
    public Tests(MyFixture fx) => _fx = fx;
}

NUnit Equivalent:

public class MyFixture
{
    public Resource R { get; private set; } = default!;
    [OneTimeSetUp] public void OneTimeSetUp() => R = new Resource();
    [OneTimeTearDown] public void OneTimeTearDown() => R.Dispose();
}

[TestFixture]
public class Tests
{
    private static readonly MyFixture Fx = new();
    [Test] public void Uses_resource() => Assert.That(Fx.R.IsReady, Is.True);
}

9. CI & Commands

Run Tests:

dotnet test

Force Full Serialization:

dotnet test -- NUnit.NumberOfTestWorkers=1

Ensure: CI uses .NET 8/9 SDK as needed.

#Constraints

Take full adavantage of the nunit format

eg

Assert.That(createdAt >= beforeInsert, Is.True);

there is a is.GreaterThan overload from meory here is the list of all the constraints https://docs.nunit.org/articles/nunit/writing-tests/constraints/Constraints.html

Multiple

Make full use of the NUnit.Multiple(() => feature, not being used properly yet.

10. Done Checklist

  • Removed xunit, xunit.runner.visualstudio, FluentAssertions packages.
  • Added NUnit (4.4.0), NUnit3TestAdapter (5.), Microsoft.NET.Test.Sdk (17.).
  • Added [Parallelizable(ParallelScope.Fixtures)] and optional [LevelOfParallelism].
  • Committed tests.runsettings with NumberOfTestWorkers.
  • Migrated attributes and APIs; tests compile.
  • Marked static/global state fixtures with [NonParallelizable].
  • CI runs dotnet test with runsettings or overrides.
  • Tests pass.

References

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions