Skip to main content

MSTest

ProtoTest.MSTest has two static helpers for the assembly hooks and a [ProtoTest] attribute. The attribute wraps each invocation in one ProtoTest context. MSTest invokes the attribute once per data row, so each row is its own context and its own trace.

Install​

dotnet add package ProtoTest.MSTest

ProtoTest targets .NET 8, 9 and 10, and needs MSTest.TestFramework 4.0.2 or newer; the standard dotnet new mstest template already pins it. The dotnet new prototest template defaults to net10.0; pass --framework net8.0 or --framework net9.0 for an older runtime.

Register​

ProtoTestAssembly has no lifecycle attributes of its own. It gives you two protected static helpers to call from [AssemblyInitialize] and [AssemblyCleanup]:

using Microsoft.VisualStudio.TestTools.UnitTesting;
using ProtoTest.Core;
using ProtoTest.MSTest;

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

[AssemblyCleanup]
public static Task AssemblyCleanupAsync() => CleanupAsync();
}

InitializeAsync throws if the host has already been created, so call it exactly once.

The test attribute is [ProtoTest]. It derives from TestMethodAttribute, so it replaces [TestMethod]:

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

| is the host bar, [] is the context. Each data row is its own context:

|[AssemblyInit / AssemblyCleanup (host)]| [[row 1]] [[row 2]]

What the adapter changes​

ItemWhat the adapter does
The hostTwo static helpers, InitializeAsync and CleanupAsync, start and stop the one host for the assembly.
The test attribute[ProtoTest] replaces [TestMethod] and overrides ExecuteAsync to run the lifecycle around MSTest's invocation.
The lifecycleOne context per data row. ExecuteAsync is called once per row, starts a context, runs the body, and completes the context in a finally: one row, one trace.
AttachmentsEach row's published files are appended to that row's TestResult.ResultFiles, and they show up in the .trx output.
SchedulingExecuteAsync is awaited properly. There is no sync-over-async.
CancellationTestMethodAttribute.ExecuteAsync(ITestMethod) exposes no token, so the test starts with CancellationToken.None.
OutcomesPassed to Passed. Ignored, Inconclusive and NotRunnable to Skipped. A failure with an exception goes through the shared classifier, so a cancelled exception maps to Cancelled. A failure without one records MSTest.{Outcome}. Timeout and Aborted map to Cancelled. Anything else is Unknown.
SkipsA skip condition returns an ignored TestResult before the lifecycle starts. MSTest 4.4 has no public dynamic-skip API, so the reason travels on the public LogOutput and is prefixed to the display name: Orders_endpoint_responds (skipped: {reason}).
Test namesDeclaringType.MethodName[args] through ProtoTestName.ForRow, so parallel rows stay apart.
ParallelismThe usual switch works: [assembly: Parallelize(Scope = ExecutionScope.MethodLevel)].

The path a skip takes:

skip condition fails → ignored TestResult (lifecycle never starts, no trace)
→ reason prefixed to the display name: Orders_endpoint_responds (skipped: {reason})
→ reason also on LogOutput

Limits​

  • MSTest 4.4 has no public dynamic-skip API. The reason is not a first-class MSTest property; it only reaches the display name and LogOutput.
  • TestMethodAttribute.ExecuteAsync(ITestMethod) exposes no cancellation token, so a test starts with CancellationToken.None.
  • There is no assembly-wide auto-wrap. MSTest offers no assembly hook for one, so every test method carries [ProtoTest].
  • A skipped test is reported only by MSTest. ProtoTest records nothing for it.

Learn more​