Introduction

Moving a .NET test suite from MSTest or NUnit to xUnit is often treated as a one-time migration project: create a branch, convert the tests, fix the failures, and merge the result. The actual translation is usually not the hardest part. The difficult part is that a large migration is difficult to evaluate until most of it is complete.

A partially converted branch can quickly become stale against main, while developers continue adding tests using the existing framework. This creates pressure to finish the entire migration before the team can properly review the result.

A more practical approach is to treat test-framework migration as a repeatable and reversible process.

This article explores that approach using a Roslyn-based converter that translates MSTest and NUnit test sources to xUnit. The important idea is not to convert everything automatically. Instead, the conversion should make safe, deterministic changes, preserve constructs that require human decisions, and provide a clear compiler-driven worklist for anything that cannot be translated automatically.

The result is a migration process that can be repeated as the converter improves and as the test suite continues to evolve.

Prerequisites

The examples in this article assume the following environment:

The converter can be built with the following commands:

git clone https://github.com/pieroviano/NUnitToXunitConverter.git
cd NUnitToXunitConverter
dotnet build MsOrNUnitToXunitConverter.slnx

The repository name predates MSTest support. The solution file is MsOrNUnitToXunitConverter.slnx.

Why a Repeatable Migration Is Better Than a Big-Bang Migration

The most important property of the conversion process is that each run starts by restoring the project from its own backup.

That means a second conversion run does not build on top of the previous conversion. Instead, it starts from the original source and applies the current version of the conversion rules.

This makes the migration process repeatable.

A developer can:

  1. Run the conversion.

  2. Review the generated diff.

  3. Build the project.

  4. Identify constructs that require manual work.

  5. Restore the original project.

  6. Improve the conversion rules or make other changes.

  7. Run the conversion again.

The conversion therefore becomes an iterative engineering process rather than an irreversible migration.

A practical workflow consists of four moves:

Move 1: Survey the Test Projects Before Conversion

Before changing any source files, the list_test_projects operation can inspect a solution and identify projects that contain MSTest or NUnit tests.

The operation supports both traditional .sln files and the newer XML-based .slnx format.

For example:

list_test_projects Acme.slnx

Acme.Billing.Tests       NUnit     47 files
Acme.Identity.Tests      MSTest    23 files
Acme.Web.Tests           NUnit      9 files
Acme.Billing                 —      0 files

The survey identifies:

Projects that do not reference a supported test-framework package are not opened for conversion.

That project-level gate is important. A file-level text search alone can produce false positives. For example, an ordinary library could contain "[TestFixture]" inside a string literal without actually being an NUnit test project.

Starting with project-level framework detection prevents that type of accidental conversion.

Move 2: Convert a Solution or a Single Project

The conversion can be performed at either the solution or project level.

convert_solution walks through the test projects in a solution and converts them in place.

If one project cannot be converted, that project is rolled back and recorded as a failure while the remaining projects continue processing. A single unsupported construct therefore does not have to stop the conversion of every other test project.

For a smaller scope, convert_project can convert an individual .csproj file.

The same operation can also be performed from the command line:

dotnet run --project MsOrNUnitToXunitConverter/MsOrNUnitToXunitConverter.csproj -- Acme.Billing.Tests/Acme.Billing.Tests.csproj

Progress is written to standard output and failures are written to standard error. This makes the command suitable for use in automated environments.

The command returns:

Along with rewriting the source files, the conversion updates the test-framework PackageReference items so that the project can build against xUnit.

The xUnit package set includes:

xunit
xunit.runner.visualstudio
Microsoft.NET.Test.Sdk
coverlet.collector

This removes the need to manually edit the project file simply to replace the original test framework references.

Move 3: Build the Converted Project

The most important part of the process happens after conversion.

The converter deliberately follows this rule:

Anything without a safe xUnit equivalent is left unchanged.

The framework-specific using statements are changed to xUnit where appropriate. As a result, an unsupported construct can become a compiler error with a file name and line number.

That compiler error becomes the migration worklist.

This approach is preferable to making an uncertain transformation merely because it allows the project to compile.

Consider NUnit's Assert.Multiple.

A simplistic converter could attempt to replace it with a sequence of normal assertions. The resulting code might compile, but its behavior would change. With normal assertions, the first failure stops the test. Assert.Multiple is specifically intended to collect multiple assertion failures.

A compiler error is therefore safer than a successful but semantically incorrect conversion.

The goal is not to make every conversion appear successful. The goal is to make every automatic conversion safe enough to trust.

Move 4: Restore or Run the Conversion Again

Before modifying a project, the converter creates a backup under:

../Old/<ProjectName>

The restore_backup operation can then restore both the source files and the project file.

This makes it possible to perform a conversion simply to understand its impact.

For example, a team can convert a project, inspect the resulting diff, identify the remaining manual work, and restore the original project without treating the conversion as a permanent change.

The same project can later be converted again after the converter has been improved.

What the Converter Can Translate

The conversion focuses on the parts of MSTest and NUnit that commonly appear in test suites, including test attributes, lifecycle hooks, assertions, and NUnit's constraint model.

The following examples illustrate some of the common transformations.

Test Attributes and Data Rows

NUnit test and data-row attributes can be translated to xUnit's [Fact], [Theory], and [InlineData] attributes.

One important detail is assertion argument order. NUnit commonly uses actual-then-expected ordering in its assertions, while xUnit uses expected-then-actual ordering.

For example:

// Before (NUnit)
[Test]
[TestCase(1, 2)]
public void Adds(int a, int b)
{
    Assert.That(a + b, Is.EqualTo(3));
}

// After (xUnit)
[Theory]
[InlineData(1, 2)]
public void Adds(int a, int b)
{
    Assert.Equal(3, a + b);
}

The conversion preserves the intent of the test while changing the framework-specific syntax.

Lifecycle Hooks

xUnit does not use setup and teardown attributes in the same way as MSTest and NUnit.

Instead, a test class constructor can be used for per-test initialization, while IDisposable.Dispose can be used for cleanup.

For example:

// Before (MSTest)
[TestInitialize]
public void Before() => Open();

[TestCleanup]
public void After() => Close();

// After (xUnit)
// The test class also implements System.IDisposable.
public Tests() => Open();

public void Dispose() => Close();

The important detail is that the hook bodies are moved rather than copied into unrelated methods.

For class-level initialization, [OneTimeSetUp] and [ClassInitialize] can be represented by a generated fixture type and IClassFixture<>.

Assembly-level initialization requires a different xUnit model. NUnit's [SetUpFixture] and MSTest's [AssemblyInitialize] can be represented using a [CollectionDefinition] with an ICollectionFixture<>, with the corresponding test classes associated with the collection.

Attribute and Assertion Mappings

Some MSTest and NUnit attributes do not have direct attribute equivalents in xUnit but can be represented through xUnit arguments or traits.

For example:

// Before                              // After
[Ignore("flaky")]                     [Fact(Skip = "flaky")]
[Category("slow")]                    [Trait("Category", "slow")]
[Timeout(500)]                        [Fact(Timeout = 500)]
Assert.AreEqual(1.0, x, 0.01);        Assert.Equal(1.0, x, 2);

The floating-point example requires particular care.

MSTest's:

Assert.AreEqual(expected, actual, delta);

uses the third parameter as a floating-point tolerance.

The corresponding xUnit overload uses the third argument as a number of decimal places rather than a tolerance.

A delta that represents a clean power of ten can be translated directly. Other values are intentionally left unchanged rather than being approximated, because an approximation could change the behavior of the test.

Replacing MSTest TestContext

MSTest's TestContext does not have a direct xUnit equivalent.

For test output, xUnit provides ITestOutputHelper.

The conversion therefore replaces the TestContext usage with an injected output helper and changes calls such as:

TestContext.WriteLine(...)

to:

_output.WriteLine(...)

The important distinction is that the conversion follows the target framework's model instead of attempting to reproduce the original framework's API exactly.

What the Conversion Deliberately Leaves Alone

Not every MSTest or NUnit feature has a safe one-to-one equivalent in xUnit.

Consider this NUnit example:

// Before (NUnit)
[Test, Retry(3)]
public void Flaky()
{
    Assert.Multiple(() =>
    {
        Assert.AreEqual(1, a);
        Assert.AreEqual(2, b);
    });
}

// After (xUnit) — unsupported constructs remain visible
[Fact, Retry(3)]
public void Flaky()
{
    Assert.Multiple(() =>
    {
        Assert.Equal(1, a);
        Assert.Equal(2, b);
    });
}

The assertions inside Assert.Multiple can be translated because they have direct xUnit counterparts.

Retry and Assert.Multiple, however, are not automatically translated because xUnit does not provide equivalent semantics for those constructs.

The resulting code does not compile until a developer decides how the test should behave in xUnit.

That is intentional.

The same principle applies to constructs such as:

Unsupported features should become visible migration work rather than being silently transformed into behavior that only looks equivalent.

Using the Converter Through an MCP Server

The solution also provides an MCP server that exposes the conversion operations to compatible AI coding assistants.

After registering the server, the operations can be invoked from the development environment rather than manually executing every command.

For example:

claude mcp add --scope user msornunit-to-xunit \
  /path/to/MsOrNUnitToXunitConverter.Mcp.exe

The available operations are:

Tool

Purpose

list_test_projects

Read-only survey of a solution

convert_solution

Convert test projects in a .sln or .slnx

convert_project

Convert a single .csproj

restore_backup

Restore a previously backed-up project or solution

The separation between read-only and modifying operations is important when these tools are exposed to an AI assistant.

list_test_projects can safely be used to understand the repository before making changes, while conversion and restore operations modify files and should be invoked deliberately.

The MCP server communicates over standard input and output. Logging is routed to standard error so that standard output remains available for the JSON-RPC protocol.

Handling Mixed Test Frameworks

Real-world repositories are not always consistent.

A solution may contain projects using NUnit, MSTest, or both. A project may also contain files from different frameworks because a previous migration was started but never completed.

The conversion process therefore checks for both supported frameworks rather than stopping after detecting the first one.

For example, a project containing both MSTest and NUnit tests can be processed as a mixed project instead of being incorrectly classified as a single-framework project.

This makes the approach useful for repositories that are already partway through a migration.

Preserving Existing Formatting

A migration should ideally produce a diff that represents the framework conversion rather than a complete reformatting of the source tree.

The converter uses Roslyn's Formatter rather than NormalizeWhitespace().

This allows existing formatting to remain largely intact while the modified syntax is formatted correctly.

Comments, XML documentation comments, and #pragma directives are preserved, and unchanged code retains its existing layout as much as possible.

This matters during code review because reviewers can concentrate on the actual migration rather than sorting through hundreds of unrelated whitespace changes.

A Practical Migration Cadence

A complete migration does not have to happen in one release.

For a large codebase, a practical approach is to migrate one project at a time.

A possible cadence is:

  1. Start with the smallest test project.

  2. Survey the project and identify the framework-specific constructs.

  3. Run the conversion.

  4. Build the converted project.

  5. Resolve the compiler errors that represent unsupported constructs.

  6. Run the tests.

  7. Review the resulting diff.

  8. Merge the project migration.

  9. Continue with the next project.

This keeps each migration relatively small and makes failures easier to diagnose.

The repeatable nature of the conversion also means that a project converted earlier can be converted again after the converter gains support for additional constructs.

Because each run starts from the backed-up source, improvements to the converter do not accumulate changes from previous conversion runs.

Conclusion

Migrating a .NET test suite from MSTest or NUnit to xUnit does not have to be a large, irreversible project.

A safer approach is to make the conversion repeatable, reversible, and compiler-driven.

The key principles are:

The effectiveness of a test-framework converter should not be measured only by the percentage of syntax it can translate automatically. A better measure is whether the tool makes the migration safer to repeat, easier to review, and easier to undo.

When those properties are built into the conversion process, migrating a test suite becomes incremental maintenance rather than a single high-risk project.

Summary

A repeatable MSTest and NUnit to xUnit migration process reduces the risk of large-scale test-framework changes by combining project-level discovery, reversible conversion, compiler-driven validation, deliberate handling of unsupported features, and incremental project migration. Instead of trying to automatically translate every framework feature, the approach focuses on making safe transformations and leaving ambiguous behavior visible for developers to resolve. This allows teams to migrate test projects gradually while continuing normal development and provides a practical way to rerun the conversion as the migration tooling improves.

Source code: https://github.com/pieroviano/NUnitToXunitConverter