Skip to main content

TUnit

TUnit is wired differently from the other four. There is no ProtoTest test attribute. You keep TUnit's own [Test]. ProtoTest runs through the TUnit executor.

Install​

dotnet add package ProtoTest.TUnit

ProtoTest targets .NET 8, 9 and 10, and needs TUnit 1.66.0 or newer. The dotnet new prototest template defaults to net10.0. Pass --framework net8.0 or --framework net9.0 for an older runtime.

Register​

Two things: register the executor for the assembly, and initialize the host from TUnit's assembly hooks.

using ProtoTest.Core;
using ProtoTest.TUnit;
using TUnit.Core;

[assembly: TestExecutor<ProtoTestExecutor>()]

public class Setup : ProtoTestAssembly
{
[Before(Assembly)]
public static Task AssemblyInitializeAsync(AssemblyHookContext context) =>
InitializeAsync(builder =>
builder.AddApplication("Api", app => app.AddRest(rest => rest.AddClient("Api"))));

[After(Assembly)]
public static Task AssemblyCleanupAsync(AssemblyHookContext context) => CleanupAsync();
}

The TUnit namespace carries TUnit's own [Test], [Before] and [After].

[assembly: TestExecutor<ProtoTestExecutor>()] applies the executor to every test in the assembly. Register it there. The class and method forms of [TestExecutor<T>] are not part of the supported surface.

On .NET SDK 10, a TUnit project runs on Microsoft.Testing.Platform. The dotnet new prototest template carries the opt-in:

global.json
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

With that file in the project or solution directory, run dotnet test from that directory. dotnet test --project Starter.Tests/Starter.Tests.csproj works when the working directory is at or under the global.json folder. From outside it, cd there first. Running from elsewhere fails before any test runs, because the old VSTest path reports that testing with the VSTest target is no longer supported.

dotnet test # right: run from the global.json directory
dotnet test --project Starter.Tests/Starter.Tests.csproj # right: name the project, from at or under the global.json directory

A test is a normal TUnit test. The executor wraps it. The interception path:

[Test] → ProtoTestExecutor.ExecuteTest → resolve MethodInfo + attributes
→ skip? → Skip.Test(reason), nothing starts, no trace
→ else start the context → await the body → complete with the outcome
→ body threw? → record, complete, rethrow via ExceptionDispatchInfo
using System.Net;
using ProtoTest.Core;
using ProtoTest.Rest;
using TUnit.Core;

namespace Orders.Tests;

[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 context window​

| is the host bar, [] is the context. The executor wraps the body:

|[Before(Assembly) / After(Assembly) (host)]| [[executor wraps body]]

Native TUnit [Before(Test)], [After(Test)] and test-class disposal are outside this window. Use ProtoTest hooks or attributes for setup and cleanup that need Proto.Context. The trace outcome describes the ProtoTest scope: a passing body and successful ProtoTest cleanup can leave it Succeeded even when a later native TUnit hook fails the runner. Use the runner's final result and exit code for the overall test verdict; do not infer that verdict from the body-scope trace alone.

An exception from a resource registered with ProtoTest follows CleanupFailures, including when the body calls Skip.Test: Fail makes the test fail; Report keeps it skipped. This policy does not intercept errors in native TUnit hooks or disposal outside the scope.

What the adapter changes​

ItemWhat the adapter does
The hostProtoTestAssembly gives you InitializeAsync and CleanupAsync for TUnit's [Before(Assembly)] and [After(Assembly)].
The test attributeNone. You keep TUnit's [Test], and ProtoTestExecutor intercepts the execution.
The lifecycleExecuteTest resolves the test's MethodInfo and attributes, starts the context, awaits the test action, then completes the context.
Failure handlingIf the body throws, the executor records the outcome, completes the context, and rethrows with ExceptionDispatchInfo, so TUnit still sees the exception. A teardown failure on a passing body is rethrown too, so TUnit fails that test. A body that already failed stays the message; the cleanup failures are attached to it.
SchedulingFully async, like xUnit v2 and MSTest.
CancellationThe live TestContext.CancellationToken is passed into the lifecycle, so the test, its hooks and the SQL connect wait observe TUnit's per-test token.
OutcomesA completed body is Passed. A thrown SkipTestException (a body-level Skip.Test) is Skipped. A thrown OperationCanceledException is Cancelled. Anything else is Failed with the exception, rethrown to TUnit.
SkipsSkip conditions are evaluated before the lifecycle starts and raised through TUnit.Core.Skip.Test(reason), so nothing starts and no trace entry is written.
AttachmentsThe live TestContext is passed to the attachment publisher, which calls context.Output.AttachArtifact(path, name, description). Per test and parallel-safe.
Test namesDeclaringType.MethodName[args] through ProtoTestName.ForRow when the test has arguments, so parallel rows stay apart.

Limits​

  • There is no ProtoTest attribute. Test discovery and [Test] are entirely TUnit's.
  • Register the executor for the assembly. [TestExecutor<T>] on a class or a method is not part of the supported surface.
  • Runs unwrapped. A source-generated test without a reflection MethodInfo runs unwrapped. The executor has no method data to build a context.
  • The per-test token comes from TestContext.CancellationToken. The executor has no token of its own, so a test with no live context (see the MethodInfo limit) starts from CancellationToken.None.
  • A teardown failure is recorded as a finding. By default it fails a test whose body passed. It does not replace a body failure. CleanupFailures set to Report leaves the body's outcome unchanged.

Learn more​