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:
.NET 10 SDK
Visual Studio 2026, JetBrains Rider, or the
dotnetCLIA solution containing MSTest or NUnit test projects
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:
Run the conversion.
Review the generated diff.
Build the project.
Identify constructs that require manual work.
Restore the original project.
Improve the conversion rules or make other changes.
Run the conversion again.
The conversion therefore becomes an iterative engineering process rather than an irreversible migration.
A practical workflow consists of four moves:
Survey the test projects.
Convert one project or solution.
Build the result and inspect what was not converted.
Restore the original sources or run the conversion again.
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:
Which projects contain tests.
Which test framework each project uses.
How many test files are involved.
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:
0for a successful conversion.1for a usage error or failed conversion.
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:
[Order][Repeat][MaxTime][DeploymentItem]Assert.InconclusiveAssert.WarnFileAssertParameterized
[TestFixture(...)]IComparerassertion overloads
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 |
|---|---|
| Read-only survey of a solution |
| Convert test projects in a |
| Convert a single |
| 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:
Start with the smallest test project.
Survey the project and identify the framework-specific constructs.
Run the conversion.
Build the converted project.
Resolve the compiler errors that represent unsupported constructs.
Run the tests.
Review the resulting diff.
Merge the project migration.
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:
Survey the solution before modifying files.
Convert one project or solution at a time.
Back up the original project before writing changes.
Translate only constructs with sufficiently clear xUnit equivalents.
Leave unsupported constructs visible instead of guessing at their behavior.
Use compiler errors as the worklist for manual migration.
Preserve source formatting so that code reviews remain focused.
Restore and rerun the conversion whenever the conversion rules improve.
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
Join the conversation! Your thoughts help the community grow.