Bring an existing suite
Convert one class at a time. Plain tests keep running. The xUnit runbook below is the shape; NUnit, MSTest and TUnit follow it with their own host hook and attribute swap.
What stays
- Plain
[Fact]and[Theory]tests keep running unchanged, whether their class is converted or not. They stay outside the trace. - xUnit's discovery, ordering, parallelism and theory rows stay xUnit's. ProtoTest adds the per-test context and the trace around them.
- A converted test keeps its arrange/act/assert body. The composition moves into the host.
The order that stays green
- 1. Add the package (
ProtoTest.XunitorProtoTest.Xunit3) plus the integration packages the tests use. Gate: the solution builds. - 2. Write the host once: the fixture composes applications, clients and infrastructure. Gate: untouched tests still pass.
- 3. Convert one class: replace
[Fact]with[ProtoTestFact], add the collection on v2, and readProto.Contextin the body. Gate: converted tests get a trace. - 4. Run
dotnet test. Gate: green. - 5. Delete the per-class harness the converted tests no longer need. Gate: no test shares a fixed row, tenant or file (see Concurrency).
A converted v2 class without [Collection(ProtoTestCollection.Name)] that reaches Proto.Context crashes the test host (the recorded behavior) and aborts the whole run. Add it in the same change.
An xUnit v3 project on .NET SDK 10 needs the Microsoft.Testing.Platform opt-in in global.json before dotnet test runs it, and the command runs from that directory or names the project from at or under it; from outside the global.json folder, cd there first. xUnit v3 shows the file and the working commands.
Before and after, per version
[Fact]public async Task Orders_endpoint_responds(){using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };using var response = await client.GetAsync("/api/orders");Assert.Equal(HttpStatusCode.OK, response.StatusCode);}
What changed in both versions: the arrangement moves into the host, the port moves into configuration, and the assertion becomes a ProtoTest check. What differs: v2 needs the collection attribute on the class, and v3 moves context reads out of the constructor into the body.
| xUnit v2 | xUnit v3 | |
|---|---|---|
| The host | ProtoTestFixture : ProtoTestAssembly, registered with [CollectionDefinition] | Setup : ProtoTestAssembly, registered with [assembly: AssemblyFixture(typeof(Setup))] |
| A test | [ProtoTestFact] / [ProtoTestTheory] replace [Fact] / [Theory] | the same, or keep [Fact] and add [assembly: ProtoTestAutoWrap] |
| A converted class | [Collection(ProtoTestCollection.Name)] is mandatory; without it the test-host crash aborts the run | nothing extra; the assembly fixture covers every class |
| The constructor | Already runs inside the context, so Proto.Context works there | Runs before the context; move context reads into IAsyncLifetime.InitializeAsync or the test body |
| Attachments | Files land under %TEMP%\ProtoTest\attachments, the path prints to the console with --logger "console;verbosity=detailed" | TestContext.Current.AddAttachment(...), so artifacts appear with the test in xUnit's output |
The registration details, outcome mapping and limits are on xUnit v2 and xUnit v3.
NUnit: the order that stays green
The same five steps, with NUnit's host hook and attribute swap:
- 1. Add the package (
ProtoTest.NUnit) plus the integration packages the tests use. NUnit needs 4.6.1 or newer, so update it first when you started fromdotnet new nunit: it pins an older one, whiledotnet new prototestalready resolves 4.6.1. Gate: the solution builds. - 2. Write the host once: a
[SetUpFixture]class deriving fromProtoTestAssemblywith theConfigureoverride. Keep it outside any namespace, or it covers only that namespace's subtree. Thedotnet new prototesttemplate keepsSetupinside the project namespace by design, beside the tests it generates; hoist it out once tests span namespaces. Gate: untouched tests still pass. - 3. Convert one class: replace
[Test]with[ProtoTest](it derives from NUnit'sTestAttribute), keep[TestFixture], and readProto.Contextin[SetUp], the body or[TearDown]. Gate: converted tests get a trace. - 4. Run
dotnet test. Gate: green. - 5. Delete the per-class harness the converted tests no longer need. Gate: no test shares a fixed row, tenant or file (see Concurrency).
Per-class pitfalls: Proto.Context works in [SetUp], the body and [TearDown], but not in the fixture's [OneTimeSetUp], which builds the host. Or add [assembly: ProtoTestAutoWrap] and every plain [Test] runs through the same lifecycle without an attribute swap; an explicit [ProtoTest] still wins by NUnit's nearest-wrapper rule.
// Before: per-class harness with its own client.
[TestFixture]
public class OrderTests
{
private HttpClient _client = new() { BaseAddress = new Uri("http://localhost:5000") };
[Test]
public async Task Orders_endpoint_responds()
{
using var response = await _client.GetAsync("/api/orders");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}
// After: the arrangement lives in the host, the test reads the context.
[TestFixture]
[Application("Api")]
public class OrderTests
{
[ProtoTest]
public async Task Orders_endpoint_responds()
{
using var response = await Proto.Context.Rest().GetAsync("/api/orders");
response.Should.HaveHttpStatus(HttpStatusCode.OK);
}
}
The host hook, the context window and the namespace rule are on NUnit.
MSTest: the order that stays green
The same five steps, with MSTest's assembly hooks and per-row attribute swap:
- 1. Add the package (
ProtoTest.MSTest) plus the integration packages the tests use. Gate: the solution builds. - 2. Write the host once: a
[TestClass]deriving fromProtoTestAssemblywhose[AssemblyInitialize]callsInitializeAsyncand whose[AssemblyCleanup]callsCleanupAsync. CallInitializeAsyncexactly once. Gate: untouched tests still pass. - 3. Convert one class: replace
[TestMethod]with[ProtoTest](it derives fromTestMethodAttribute), keep[TestClass], and readProto.Contextin the body. Each data row is its own context and its own trace, so[DataRow]rows convert together. Gate: converted tests get a trace. - 4. Run
dotnet test. Gate: green. - 5. Delete the per-class harness the converted tests no longer need. Gate: no test shares a fixed row, tenant or file (see Concurrency).
Per-class pitfalls: there is no assembly-wide auto-wrap, so every converted method carries [ProtoTest]. Skips have no first-class MSTest property: the reason travels on the display name (Method (skipped: {reason})) and LogOutput. The attribute exposes no cancellation token, so a converted test starts from CancellationToken.None.
// Before: per-class harness with its own client.
[TestClass]
public class OrderTests
{
private readonly HttpClient _client = new() { BaseAddress = new Uri("http://localhost:5000") };
[TestMethod]
public async Task Orders_endpoint_responds()
{
using var response = await _client.GetAsync("/api/orders");
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
}
}
// After: the arrangement lives in the host, the test reads the context.
[TestClass]
[Application("Api")]
public class OrderTests
{
[ProtoTest]
public async Task Orders_endpoint_responds()
{
using var response = await Proto.Context.Rest().GetAsync("/api/orders");
response.Should.HaveHttpStatus(HttpStatusCode.OK);
}
}
The hooks, the per-row lifecycle and the skip path are on MSTest.
TUnit: the order that stays green
The same five steps, with TUnit's executor instead of an attribute swap:
- 1. Add the package (
ProtoTest.TUnit) plus the integration packages the tests use. Gate: the solution builds. - 2. Write the host once: register
[assembly: TestExecutor<ProtoTestExecutor>()]for the assembly, and initialize the host from a class deriving fromProtoTestAssemblywith[Before(Assembly)]callingInitializeAsyncand[After(Assembly)]callingCleanupAsync. Gate: plain tests still pass (wrapped). - 3. Convert one class: keep TUnit's
[Test]; the executor wraps every test in the assembly, so there is no attribute to swap. Move the arrangement into the host and readProto.Contextin the body. Gate: converted tests get a trace. - 4. Run
dotnet test. On .NET SDK 10 the project needs the Microsoft.Testing.Platform opt-in inglobal.jsonfirst, and the command runs from that directory or names the project from at or under it (see TUnit). Gate: green. - 5. Delete the per-class harness the converted tests no longer need. Gate: no test shares a fixed row, tenant or file (see Concurrency).
Per-class pitfalls: the executor applies to every test in the assembly, so plain tests run wrapped rather than untouched. A source-generated test with no reflection MethodInfo runs unwrapped, with no context. The live TestContext.CancellationToken feeds the lifecycle, so the test and its hooks observe TUnit's per-test token.
// Before: per-class harness with its own client.
[Application("Api")]
public class OrderTests
{
private readonly HttpClient _client = new() { BaseAddress = new Uri("http://localhost:5000") };
[Test]
public async Task Orders_endpoint_responds()
{
using var response = await _client.GetAsync("/api/orders");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
}
// After: the arrangement lives in the host, the test reads the context.
[Application("Api")]
public class OrderTests
{
[Test]
public async Task Orders_endpoint_responds()
{
using var response = await Proto.Context.Rest().GetAsync("/api/orders");
response.Should.HaveHttpStatus(HttpStatusCode.OK);
}
}
The executor, the hooks and the interception path are on TUnit.
Limits
- A v2 class without the collection attribute that reaches
Proto.Contextcrashes the test host and aborts the whole run, so convert the class in one change. - The host is one per test process, started by the collection or assembly hook. A second registration does not layer a second host.
- Conversion does not fix shared state between tests. A class that writes to a fixed row, tenant or file still needs its own isolation; see Concurrency.