diff --git a/docs/guides/flaky-tests-and-quarantine.md b/docs/guides/flaky-tests-and-quarantine.md
index 1b55216..c95eae2 100644
--- a/docs/guides/flaky-tests-and-quarantine.md
+++ b/docs/guides/flaky-tests-and-quarantine.md
@@ -60,7 +60,7 @@ Or supply a list file, which quarantines without touching the source. That suits
motus run bin/Release/net8.0/MyTests.dll --quarantine quarantine.txt
```
-One fully qualified test name per line. Blank lines are ignored, and a line whose first non-blank character is `#` is a comment. The whole line is the name, so a trailing `# reason` on the same line becomes part of it and the entry silently matches nothing; put the reason on its own line above.
+One fully qualified test name per line. A data-driven test is listed by the name of one row, such as `MyTests.CheckoutTests.PaysBy (card)`, or by its method, `MyTests.CheckoutTests.PaysBy`, which covers every row. Blank lines are ignored, and a line whose first non-blank character is `#` is a comment. The whole line is the name, so a trailing `# reason` on the same line becomes part of it and the entry silently matches nothing; put the reason on its own line above.
```
# Flaky since the payment provider sandbox migration (#412)
diff --git a/docs/guides/testing-frameworks.md b/docs/guides/testing-frameworks.md
index a7c23f4..c69f0f5 100644
--- a/docs/guides/testing-frameworks.md
+++ b/docs/guides/testing-frameworks.md
@@ -27,7 +27,7 @@ Inherit from `MotusTestBase` to receive a fully managed `IBrowserContext` and `I
|---|---|
| `protected IBrowserContext Context` | The context for the current test. Available after `[TestInitialize]`. |
| `protected IPage Page` | The page for the current test. Available after `[TestInitialize]`. |
-| `public TestContext TestContext` | Set by the MSTest runner. Used internally to detect test outcome for failure tracing. |
+| `public TestContext TestContext` | Set by MSTest, and by `motus run` and the visual runner. Used internally to detect test outcome for failure tracing. |
### Browser lifecycle
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index d226c95..ef90e01 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -63,6 +63,13 @@ motus run bin/Release/net8.0/MyTests.dll --shard 1/4 --retries 2
The output path for a reporter is part of its value, so it is `--reporter junit:results.xml` rather than a separate flag. Passing `--reporter junit` with no path writes nowhere.
+**How tests run.** `motus run` and the visual runner load the test assembly and call its tests by reflection, without going through the MSTest, NUnit or xUnit test adapter. They cover the parts of each framework that suites lean on:
+
+- MSTest tests get a `TestContext`, through their `TestContext` property or a constructor that takes one, and `[AssemblyInitialize]`, `[ClassInitialize]` and the matching cleanup methods receive one when they take it. It carries `TestName`, `FullyQualifiedTestClassName` and a `CancellationTokenSource`, and `CurrentTestOutcome` is the test's own outcome by the time `[TestCleanup]` runs. `WriteLine` goes to the console. Files passed to `AddResultFile` are left where they are rather than attached to a result.
+- `[DataRow]`, `[DynamicData]` and any other MSTest data source run as one test per row, named the way MSTest names them, such as `SignInTests.SignsIn (owner)`. `--filter` matches that name, so it can select a single row.
+- Setup methods (`[TestInitialize]`, NUnit `[SetUp]`) run base class first. Cleanup methods (`[TestCleanup]`, NUnit `[TearDown]`) run derived class first and base class last, and each one runs even when an earlier one throws.
+- Attributes derived from `[TestClass]` or `[TestMethod]`, such as `[DataTestMethod]`, `[MotusTestClass]` and `[MotusTestMethod]`, mark tests as the attributes they derive from do. NUnit `[TestCase]` and xUnit `[Theory]` tests are not run.
+
**Exit code.** `0` when everything passed. `1` when any test failed, when a coverage threshold was missed, or when `--fail-on-flaky` was given and any test was flaky.
See [Sharding](../guides/sharding.md), [Flaky Tests and Quarantine](../guides/flaky-tests-and-quarantine.md), and [Configuration](../guides/configuration.md) for the settings behind these.
diff --git a/src/Motus.Cli/Commands/RunCommand.cs b/src/Motus.Cli/Commands/RunCommand.cs
index 3d4ca17..3c82f9e 100644
--- a/src/Motus.Cli/Commands/RunCommand.cs
+++ b/src/Motus.Cli/Commands/RunCommand.cs
@@ -208,7 +208,7 @@ public static Command Build()
}
tests = tests
- .Select(t => t with { Quarantined = t.Quarantined || quarantineList.Contains(t.FullName) })
+ .Select(t => t with { Quarantined = t.Quarantined || IsListed(t, quarantineList) })
.ToList();
}
@@ -250,6 +250,14 @@ private static bool TryParseRetryPolicy(string spec, out RetryPolicy policy)
}
}
+ ///
+ /// True when the quarantine list names . A data-driven test's rows can be
+ /// listed one by one, or all at once by the method's name.
+ ///
+ internal static bool IsListed(DiscoveredTest test, IReadOnlySet quarantineList)
+ => quarantineList.Contains(test.FullName)
+ || quarantineList.Contains($"{test.TestClass.FullName}.{test.TestMethod.Name}");
+
private static HashSet LoadQuarantineList(string path)
{
var set = new HashSet(StringComparer.Ordinal);
diff --git a/src/Motus.Cli/Services/TestDiscovery.cs b/src/Motus.Cli/Services/TestDiscovery.cs
index 0531b28..bf5dc64 100644
--- a/src/Motus.Cli/Services/TestDiscovery.cs
+++ b/src/Motus.Cli/Services/TestDiscovery.cs
@@ -1,8 +1,13 @@
using System.Reflection;
+using Motus.Runner.Services.Execution;
namespace Motus.Cli.Services;
-public sealed record DiscoveredTest(Type TestClass, MethodInfo TestMethod, string FullName, bool IsIgnored, bool Quarantined = false);
+///
+/// A test case found in a test assembly. is the data row of one case of a
+/// data-driven test, or null for a test without one.
+///
+public sealed record DiscoveredTest(Type TestClass, MethodInfo TestMethod, string FullName, bool IsIgnored, bool Quarantined = false, object?[]? Arguments = null);
public sealed class TestDiscovery
{
@@ -19,6 +24,12 @@ public sealed class TestDiscovery
"FactAttribute",
};
+ // xUnit's [Theory] derives from [Fact] but needs data the runners don't supply.
+ private static readonly HashSet ExcludedMethodAttributes = new(StringComparer.Ordinal)
+ {
+ "TheoryAttribute",
+ };
+
private static readonly HashSet IgnoreAttributes = new(StringComparer.Ordinal)
{
"IgnoreAttribute", // MSTest + NUnit
@@ -63,14 +74,17 @@ public List Discover(string[] assemblyPaths, string? filter)
if (!IsTestMethod(method))
continue;
- var fullName = $"{type.FullName}.{method.Name}";
+ var methodIgnored = classIgnored || IsIgnoredMethod(method);
+ var quarantined = classQuarantined || IsQuarantinedMethod(method);
- if (filter is not null && !fullName.Contains(filter, StringComparison.OrdinalIgnoreCase))
- continue;
+ foreach (var testCase in TestCaseExpander.Expand(type, method, message => Console.Error.WriteLine(message)))
+ {
+ if (filter is not null && !testCase.FullName.Contains(filter, StringComparison.OrdinalIgnoreCase))
+ continue;
- var isIgnored = classIgnored || IsIgnoredMethod(method);
- var quarantined = classQuarantined || IsQuarantinedMethod(method);
- tests.Add(new DiscoveredTest(type, method, fullName, isIgnored, quarantined));
+ var isIgnored = methodIgnored || testCase.IgnoreReason is not null;
+ tests.Add(new DiscoveredTest(type, method, testCase.FullName, isIgnored, quarantined, testCase.Arguments));
+ }
}
}
}
@@ -84,13 +98,14 @@ private static bool IsTestClass(Type type)
return false;
return type.GetCustomAttributes(true)
- .Any(a => ClassAttributes.Contains(a.GetType().Name));
+ .Any(a => TestCaseExpander.IsOrDerivesFrom(a, ClassAttributes));
}
private static bool IsTestMethod(MethodInfo method)
{
return method.GetCustomAttributes(true)
- .Any(a => MethodAttributes.Contains(a.GetType().Name));
+ .Any(a => TestCaseExpander.IsOrDerivesFrom(a, MethodAttributes)
+ && !TestCaseExpander.IsOrDerivesFrom(a, ExcludedMethodAttributes));
}
private static bool IsIgnoredMethod(MethodInfo method)
diff --git a/src/Motus.Cli/Services/TestRunner.cs b/src/Motus.Cli/Services/TestRunner.cs
index 51ea6e1..ea71cb2 100644
--- a/src/Motus.Cli/Services/TestRunner.cs
+++ b/src/Motus.Cli/Services/TestRunner.cs
@@ -4,6 +4,7 @@
using Motus;
using Motus.Abstractions;
using Motus.Cli.Services.Reporters;
+using Motus.Runner.Services.Execution;
using CliTestResult = Motus.Cli.Services.TestResult;
namespace Motus.Cli.Services;
@@ -399,93 +400,32 @@ private static bool IsTransientCdpFailure(Exception? ex)
private async Task<(CliTestResult Result, Exception? Failure)> ExecuteTestAsync(DiscoveredTest test)
{
- var testSw = Stopwatch.StartNew();
- object? instance = null;
- try
- {
- instance = Activator.CreateInstance(test.TestClass)!;
-
- // Publish the test method name so MotusTestBase can resolve per-method attributes
- // (e.g. [PerformanceBudget]) and name failure artifacts without a TestContext
- TestMethodNameContext.Set(test.TestMethod.Name);
-
- // Run [ClassInitialize] once per type (static, takes TestContext)
- if (_initializedClasses.TryAdd(test.TestClass, true))
+ var run = await TestCaseInvoker.RunAsync(
+ test.TestClass,
+ test.TestMethod,
+ test.Arguments,
+ DisplayNameOf(test),
+ beforeInstance: async () =>
{
- await RunStaticLifecycleMethodAsync(test.TestClass, "ClassInitializeAttribute");
- await RunStaticLifecycleMethodAsync(test.TestClass, "OneTimeSetUpAttribute");
- }
-
- // Run [TestInitialize] / [SetUp]
- await ExecuteLifecycleMethodAsync(instance, test.TestClass, "TestInitializeAttribute");
- await ExecuteLifecycleMethodAsync(instance, test.TestClass, "SetUpAttribute");
-
- var result = test.TestMethod.Invoke(instance, null);
-
- if (result is Task task)
- await task;
-
- testSw.Stop();
- return (new CliTestResult(test.FullName, true, testSw.Elapsed, null, null), null);
- }
- catch (TargetInvocationException ex) when (ex.InnerException is not null)
- {
- testSw.Stop();
- return (
- new CliTestResult(test.FullName, false, testSw.Elapsed, ex.InnerException.Message, ex.InnerException.StackTrace),
- ex.InnerException);
- }
- catch (Exception ex)
- {
- testSw.Stop();
- return (
- new CliTestResult(test.FullName, false, testSw.Elapsed, ex.Message, ex.StackTrace),
- ex);
- }
- finally
- {
- if (instance is not null)
- {
- try
- {
- // Run [TestCleanup] / [TearDown]
- await ExecuteLifecycleMethodAsync(instance, test.TestClass, "TestCleanupAttribute");
- await ExecuteLifecycleMethodAsync(instance, test.TestClass, "TearDownAttribute");
- }
- catch (Exception ex)
+ // Run [ClassInitialize] once per type (static, takes TestContext)
+ if (_initializedClasses.TryAdd(test.TestClass, true))
{
- Console.Error.WriteLine($"TestCleanup failed for {test.FullName}: {ex.InnerException?.Message ?? ex.Message}");
+ await RunStaticLifecycleMethodAsync(test.TestClass, "ClassInitializeAttribute");
+ await RunStaticLifecycleMethodAsync(test.TestClass, "OneTimeSetUpAttribute");
}
+ },
+ onCleanupFailure: ex => Console.Error.WriteLine($"TestCleanup failed for {test.FullName}: {ex.Message}"));
- if (instance is IAsyncDisposable asyncDisposable)
- await asyncDisposable.DisposeAsync();
- else if (instance is IDisposable disposable)
- disposable.Dispose();
- }
-
- TestMethodNameContext.Clear();
- }
+ return run.Failure is null
+ ? (new CliTestResult(test.FullName, true, run.Duration, null, null), null)
+ : (new CliTestResult(test.FullName, false, run.Duration, run.Failure.Message, run.Failure.StackTrace), run.Failure);
}
- private static async Task ExecuteLifecycleMethodAsync(object instance, Type testClass, string attributeName)
+ /// The test case's name without its class, such as SignsIn ("owner").
+ private static string DisplayNameOf(DiscoveredTest test)
{
- // Walk the hierarchy bottom-up, then reverse so base-class methods run first
- var methods = new List();
- var type = testClass;
- while (type is not null && type != typeof(object))
- {
- var declared = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Where(m => m.GetCustomAttributes(true).Any(a => a.GetType().Name == attributeName));
- methods.InsertRange(0, declared);
- type = type.BaseType;
- }
-
- foreach (var method in methods)
- {
- var result = method.Invoke(instance, null);
- if (result is Task task)
- await task;
- }
+ var prefix = test.TestClass.FullName + ".";
+ return test.FullName.StartsWith(prefix, StringComparison.Ordinal) ? test.FullName[prefix.Length..] : test.TestMethod.Name;
}
private static async Task RunAssemblyCleanupAsync(Assembly assembly)
@@ -496,11 +436,7 @@ private static async Task RunAssemblyCleanupAsync(Assembly assembly)
.Where(m => m.GetCustomAttributes(true).Any(a => a.GetType().Name == "AssemblyCleanupAttribute"));
foreach (var method in methods)
- {
- var result = method.Invoke(null, null);
- if (result is Task task)
- await task;
- }
+ await TestCaseInvoker.InvokeStaticAsync(method, type);
}
}
@@ -512,13 +448,7 @@ private static async Task RunAssemblyInitializeAsync(Assembly assembly)
.Where(m => m.GetCustomAttributes(true).Any(a => a.GetType().Name == "AssemblyInitializeAttribute"));
foreach (var method in methods)
- {
- var parameters = method.GetParameters();
- object?[] args = parameters.Length > 0 ? [null] : [];
- var result = method.Invoke(null, args);
- if (result is Task task)
- await task;
- }
+ await TestCaseInvoker.InvokeStaticAsync(method, type);
}
}
@@ -528,12 +458,6 @@ private static async Task RunStaticLifecycleMethodAsync(Type type, string attrib
.Where(m => m.GetCustomAttributes(true).Any(a => a.GetType().Name == attributeName));
foreach (var method in methods)
- {
- var parameters = method.GetParameters();
- object?[] args = parameters.Length > 0 ? [null] : [];
- var result = method.Invoke(null, args);
- if (result is Task task)
- await task;
- }
+ await TestCaseInvoker.InvokeStaticAsync(method, type);
}
}
diff --git a/src/Motus.Runner/Components/Explorer/TestMethodNode.razor b/src/Motus.Runner/Components/Explorer/TestMethodNode.razor
index c10f899..aa1c619 100644
--- a/src/Motus.Runner/Components/Explorer/TestMethodNode.razor
+++ b/src/Motus.Runner/Components/Explorer/TestMethodNode.razor
@@ -30,7 +30,11 @@
private TestStatus CurrentStatus => CurrentState?.Status ?? TestStatus.Pending;
- private string MethodDisplayName => Test.TestMethod?.Name ?? GetMethodFromFullName(Test.FullName);
+ // A data row's name carries its arguments, e.g. SignsIn ("owner"), so rows of one method differ.
+ private string MethodDisplayName =>
+ Test.TestClass?.FullName is { } className && Test.FullName.StartsWith(className + ".", StringComparison.Ordinal)
+ ? Test.FullName[(className.Length + 1)..]
+ : Test.TestMethod?.Name ?? GetMethodFromFullName(Test.FullName);
private bool ShowRerunButton =>
Options.ViewerMode == ViewerMode.Trx && CurrentStatus != TestStatus.Passed;
diff --git a/src/Motus.Runner/Motus.Runner.csproj b/src/Motus.Runner/Motus.Runner.csproj
index 5069028..d098049 100644
--- a/src/Motus.Runner/Motus.Runner.csproj
+++ b/src/Motus.Runner/Motus.Runner.csproj
@@ -13,6 +13,11 @@
+
+
+
+
+
diff --git a/src/Motus.Runner/Services/Execution/MSTestContextFactory.cs b/src/Motus.Runner/Services/Execution/MSTestContextFactory.cs
new file mode 100644
index 0000000..97dc93d
--- /dev/null
+++ b/src/Motus.Runner/Services/Execution/MSTestContextFactory.cs
@@ -0,0 +1,361 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace Motus.Runner.Services.Execution;
+
+///
+/// Builds MSTest TestContext objects for tests run by reflection.
+///
+///
+/// The runners do not reference MSTest: a test assembly brings its own version, and the
+/// TestContext it expects is that version's type, which has moved between assemblies
+/// and gained abstract members from one major version to the next. So the subclass is generated
+/// at run time, once per TestContext type, by overriding whatever members that version
+/// declares abstract, plus CurrentTestOutcome. Every override forwards to
+/// . When a version cannot be subclassed this way, no context is made
+/// and the test runs without one, as it did before the runners made contexts at all.
+///
+internal static class MSTestContextFactory
+{
+ internal const string TestContextTypeName = "Microsoft.VisualStudio.TestTools.UnitTesting.TestContext";
+
+ private static readonly ConcurrentDictionary s_shapes = new();
+
+ /// True when is some version of MSTest's TestContext.
+ public static bool IsTestContextType(Type? type)
+ => type is { IsClass: true, IsAbstract: true, FullName: TestContextTypeName };
+
+ ///
+ /// Creates a describing , or returns
+ /// null when that is not an MSTest TestContext or this version of it can't be built.
+ ///
+ public static object? TryCreate(Type contextType, TestCaseContext testCase)
+ {
+ if (!IsTestContextType(contextType))
+ return null;
+
+ var shape = s_shapes.GetOrAdd(contextType, TryBuildShape);
+ if (shape is null)
+ return null;
+
+ try
+ {
+ return shape.Create(testCase);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Points MSTest's static TestContext.Current (MSTest 4.1 and later) at
+ /// . It is an async local, so this must be called from the async
+ /// method that goes on to run the test. Does nothing for versions without it.
+ ///
+ public static void SetCurrent(Type contextType, object? context)
+ {
+ if (s_shapes.TryGetValue(contextType, out var shape) && shape?.CurrentSetter is { } setter)
+ {
+ try { setter.Invoke(null, [context]); }
+ catch { /* the test can still use the instance it was given */ }
+ }
+ }
+
+ ///
+ /// Records the exception that failed the test on TestContext.TestException
+ /// (MSTest 4 and later), so the test's cleanup methods can read it.
+ ///
+ public static void SetException(object context, Exception exception)
+ {
+ if (context.GetType().BaseType is { } contextType
+ && s_shapes.TryGetValue(contextType, out var shape)
+ && shape?.ExceptionSetter is { } setter)
+ {
+ try { setter.Invoke(context, [exception]); }
+ catch { /* informational only */ }
+ }
+ }
+
+ private static object? Dispatch(ContextShape shape, TestCaseContext testCase, Dictionary properties, string member, object?[] args)
+ {
+ switch (member)
+ {
+ case "get_Properties":
+ return properties;
+
+ case "get_CurrentTestOutcome":
+ return shape.ToOutcome(testCase.Outcome);
+
+ case "Write":
+ Console.Out.Write(Format(args));
+ return null;
+
+ case "WriteLine":
+ Console.Out.WriteLine(Format(args));
+ return null;
+
+ case "DisplayMessage":
+ // (MessageLevel level, string message)
+ Console.Out.WriteLine(args.Length > 1 ? args[1] as string : null);
+ return null;
+
+ default:
+ // AddResultFile, and any member a later MSTest makes abstract: the runners have no
+ // result attachments, so there is nothing to do and the default is returned.
+ return null;
+ }
+ }
+
+ private static string? Format(object?[] args)
+ {
+ if (args.Length == 0)
+ return null;
+
+ var format = args[0] as string;
+ if (args.Length > 1 && args[1] is object?[] formatArgs && format is not null)
+ return string.Format(CultureInfo.CurrentCulture, format, formatArgs);
+
+ return format;
+ }
+
+ private static ContextShape? TryBuildShape(Type contextType)
+ {
+ try
+ {
+ var baseConstructor = contextType.GetConstructor(
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, Type.EmptyTypes);
+ if (baseConstructor is null || !(baseConstructor.IsPublic || baseConstructor.IsFamily || baseConstructor.IsFamilyOrAssembly))
+ return null;
+
+ var toOverride = new List();
+ foreach (var method in contextType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
+ {
+ if (!method.IsAbstract)
+ continue;
+
+ // A member this can't override would leave the generated type abstract.
+ if (!CanOverride(method))
+ return null;
+
+ toOverride.Add(method);
+ }
+
+ var outcomeGetter = contextType.GetProperty("CurrentTestOutcome", BindingFlags.Instance | BindingFlags.Public)?.GetGetMethod();
+ if (outcomeGetter is null || !outcomeGetter.ReturnType.IsEnum)
+ return null;
+ if (!outcomeGetter.IsAbstract)
+ {
+ if (!outcomeGetter.IsVirtual || outcomeGetter.IsFinal)
+ return null;
+ toOverride.Add(outcomeGetter);
+ }
+
+ var generated = Generate(contextType, baseConstructor, toOverride);
+ var constructor = generated.GetConstructor([typeof(Func)])!;
+
+ return new ContextShape(
+ constructor,
+ outcomeGetter.ReturnType,
+ ReadPropertyLabels(contextType),
+ NonPublicSetter(contextType, "CancellationTokenSource", BindingFlags.Instance),
+ NonPublicSetter(contextType, "TestDisplayName", BindingFlags.Instance),
+ NonPublicSetter(contextType, "TestData", BindingFlags.Instance),
+ NonPublicSetter(contextType, "TestException", BindingFlags.Instance),
+ NonPublicSetter(contextType, "Current", BindingFlags.Static));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool CanOverride(MethodInfo method)
+ => !method.IsGenericMethodDefinition
+ && (method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly)
+ && !method.ReturnType.IsByRef
+ && !method.ReturnType.IsPointer
+ && method.GetParameters().All(p => !p.ParameterType.IsByRef && !p.ParameterType.IsPointer);
+
+ private static MethodInfo? NonPublicSetter(Type contextType, string propertyName, BindingFlags kind)
+ => contextType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | kind)?.GetSetMethod(nonPublic: true);
+
+ ///
+ /// Reads the keys MSTest uses for Properties from its own ...Label fields, so the
+ /// base class's TestName and friends find what is stored, whatever this version calls them.
+ ///
+ private static Dictionary ReadPropertyLabels(Type contextType)
+ {
+ var labels = new Dictionary(StringComparer.Ordinal);
+ foreach (var field in contextType.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
+ {
+ if (field.FieldType != typeof(string) || !field.Name.EndsWith("Label", StringComparison.Ordinal))
+ continue;
+
+ var name = field.Name[..^"Label".Length];
+ labels[name] = (field.IsLiteral ? field.GetRawConstantValue() : field.GetValue(null)) as string ?? name;
+ }
+
+ return labels;
+ }
+
+ private static Type Generate(Type contextType, ConstructorInfo baseConstructor, List toOverride)
+ {
+ // Collectible, so that it can refer to a test assembly loaded into a collectible context.
+ var assemblyName = new AssemblyName($"Motus.Runner.TestContext.{Guid.NewGuid():N}");
+ var assembly = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndCollect);
+ var module = assembly.DefineDynamicModule(assemblyName.Name!);
+ var type = module.DefineType(
+ "Motus.Runner.MotusTestContext",
+ TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class,
+ contextType);
+
+ var dispatchType = typeof(Func);
+ var dispatchField = type.DefineField("_dispatch", dispatchType, FieldAttributes.Private | FieldAttributes.InitOnly);
+ var invoke = dispatchType.GetMethod(nameof(Func
+
+
+
+
+ <_Parameter1>NuGetPackageRoot
+ <_Parameter2>$(NuGetPackageRoot)
+
+
+
diff --git a/tests/Motus.Tests/Runner/TestCaseExecutionTests.cs b/tests/Motus.Tests/Runner/TestCaseExecutionTests.cs
new file mode 100644
index 0000000..2df1d9a
--- /dev/null
+++ b/tests/Motus.Tests/Runner/TestCaseExecutionTests.cs
@@ -0,0 +1,192 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using Motus.Runner.Services.Execution;
+
+namespace Motus.Tests.Runner;
+
+///
+/// The pieces both runners share for running MSTest tests by reflection: data row expansion,
+/// argument binding and the generated TestContext.
+///
+[TestClass]
+public class TestCaseExecutionTests
+{
+ public class Methods
+ {
+ public void Optional(int a, string b = "default") { }
+
+ public void Params(int first, params int[] rest) { }
+
+ public void Two(int a, int b) { }
+
+ [TestMethod]
+ [DataRow(1, DisplayName = "the first row")]
+ public void Named(int value) { }
+
+ [TestMethod]
+ [DataRow(1)]
+ [DataRow(1)]
+ public void Duplicated(int value) { }
+
+ [TestMethod]
+ [DynamicData("NoSuchMember")]
+ public void BrokenSource(int value) { }
+
+ [TestMethod]
+ public void Plain() { }
+ }
+
+ private static MethodInfo Method(string name) => typeof(Methods).GetMethod(name)!;
+
+ [TestMethod]
+ public void BindArguments_FillsOmittedOptionalParameters()
+ {
+ var args = TestCaseInvoker.BindArguments(Method(nameof(Methods.Optional)), [1])!;
+
+ Assert.AreEqual(2, args.Length);
+ Assert.AreEqual(1, args[0]);
+ Assert.AreSame(Type.Missing, args[1]);
+ }
+
+ [TestMethod]
+ public void BindArguments_GathersTrailingValuesIntoParams()
+ {
+ var args = TestCaseInvoker.BindArguments(Method(nameof(Methods.Params)), [1, 2, 3])!;
+
+ Assert.AreEqual(2, args.Length);
+ CollectionAssert.AreEqual(new[] { 2, 3 }, (int[])args[1]!);
+ }
+
+ [TestMethod]
+ public void BindArguments_ParamsWithNoTrailingValues_IsAnEmptyArray()
+ {
+ var args = TestCaseInvoker.BindArguments(Method(nameof(Methods.Params)), [1])!;
+
+ Assert.AreEqual(0, ((int[])args[1]!).Length);
+ }
+
+ [TestMethod]
+ public void BindArguments_MissingRequiredParameters_SaysHowToSupplyThem()
+ {
+ var ex = Assert.ThrowsException(
+ () => TestCaseInvoker.BindArguments(Method(nameof(Methods.Two)), null));
+
+ StringAssert.Contains(ex.Message, "[DataRow]");
+ }
+
+ [TestMethod]
+ public void Expand_UsesTheRowsDisplayName_KeepingTheMethodName()
+ {
+ var cases = TestCaseExpander.Expand(typeof(Methods), Method(nameof(Methods.Named)));
+
+ Assert.AreEqual($"{typeof(Methods).FullName}.Named (the first row)", cases.Single().FullName);
+ }
+
+ [TestMethod]
+ public void Expand_IdenticalRows_GetDistinctNames()
+ {
+ var cases = TestCaseExpander.Expand(typeof(Methods), Method(nameof(Methods.Duplicated)));
+
+ Assert.AreEqual(2, cases.Count);
+ Assert.AreNotEqual(cases[0].FullName, cases[1].FullName);
+ }
+
+ [TestMethod]
+ public void Expand_UnreadableDataSource_ReportsIt_AndLeavesOneCase()
+ {
+ var errors = new List();
+
+ var cases = TestCaseExpander.Expand(typeof(Methods), Method(nameof(Methods.BrokenSource)), errors.Add);
+
+ Assert.AreEqual(1, errors.Count);
+ Assert.AreEqual($"{typeof(Methods).FullName}.BrokenSource", cases.Single().FullName);
+ Assert.IsNull(cases.Single().Arguments);
+ }
+
+ [TestMethod]
+ public void Expand_MethodWithoutData_IsOneCaseWithoutArguments()
+ {
+ var cases = TestCaseExpander.Expand(typeof(Methods), Method(nameof(Methods.Plain)));
+
+ Assert.AreEqual($"{typeof(Methods).FullName}.Plain", cases.Single().FullName);
+ Assert.IsNull(cases.Single().Arguments);
+ }
+
+ [TestMethod]
+ public void TestContext_ReflectsTheOutcomeAsItChanges()
+ {
+ var testCase = new TestCaseContext(typeof(Methods), Method(nameof(Methods.Plain)));
+
+ var context = (TestContext)MSTestContextFactory.TryCreate(typeof(TestContext), testCase)!;
+
+ Assert.AreEqual(UnitTestOutcome.InProgress, context.CurrentTestOutcome);
+ testCase.Outcome = TestCaseOutcome.Passed;
+ Assert.AreEqual(UnitTestOutcome.Passed, context.CurrentTestOutcome);
+ testCase.Outcome = TestCaseOutcome.Failed;
+ Assert.AreEqual(UnitTestOutcome.Failed, context.CurrentTestOutcome);
+ }
+
+ [TestMethod]
+ public void TestContext_DescribesTheTest()
+ {
+ var testCase = new TestCaseContext(typeof(Methods), Method(nameof(Methods.Plain)));
+
+ var context = (TestContext)MSTestContextFactory.TryCreate(typeof(TestContext), testCase)!;
+
+ Assert.AreEqual(nameof(Methods.Plain), context.TestName);
+ Assert.AreEqual(typeof(Methods).FullName, context.FullyQualifiedTestClassName);
+ Assert.IsNotNull(context.CancellationTokenSource);
+ Assert.IsTrue(Directory.Exists(context.TestRunDirectory));
+ Assert.AreSame(context.Properties, context.Properties);
+
+ // Writes go to the console and must not throw, with or without format arguments.
+ context.WriteLine("plain");
+ context.WriteLine("formatted {0}", 1);
+ context.Write("partial");
+ context.AddResultFile("/nowhere/file.txt");
+ }
+
+ [TestMethod]
+ public void TestContext_FromMSTest4_IsBuiltToo()
+ {
+ // MSTest 4 moved TestContext to another assembly and made more of it abstract. Load it the
+ // way the runners meet it, from a test assembly's own folder, beside this project's MSTest 3.
+ var root = typeof(TestCaseExecutionTests).Assembly.GetCustomAttributes()
+ .FirstOrDefault(a => a.Key == "NuGetPackageRoot")?.Value ?? "";
+ var package = Path.Combine(root, "mstest.testframework", "4.0.1");
+ var framework = Path.Combine(package, "lib", "net8.0", "MSTest.TestFramework.dll");
+ var extensions = Path.Combine(package, "buildTransitive", "net8.0", "MSTest.TestFramework.Extensions.dll");
+ if (!File.Exists(framework) || !File.Exists(extensions))
+ Assert.Inconclusive($"MSTest 4 was not restored to {package}.");
+
+ var loadContext = new AssemblyLoadContext("mstest4", isCollectible: true);
+ loadContext.Resolving += (alc, name) => name.Name == "MSTest.TestFramework" ? alc.LoadFromAssemblyPath(framework) : null;
+ try
+ {
+ var contextType = loadContext.LoadFromAssemblyPath(extensions).GetType(MSTestContextFactory.TestContextTypeName, throwOnError: true)!;
+ Assert.AreNotEqual(typeof(TestContext).Assembly.GetName().Name, contextType.Assembly.GetName().Name);
+ var testCase = new TestCaseContext(typeof(Methods), Method(nameof(Methods.Plain)), "Plain (1)", [1]);
+
+ var context = MSTestContextFactory.TryCreate(contextType, testCase);
+
+ Assert.IsNotNull(context, "an MSTest 4 TestContext should be built");
+ Assert.AreEqual(nameof(Methods.Plain), contextType.GetProperty("TestName")!.GetValue(context));
+ Assert.AreEqual("Plain (1)", contextType.GetProperty("TestDisplayName")!.GetValue(context));
+ testCase.Outcome = TestCaseOutcome.Passed;
+ Assert.AreEqual("Passed", contextType.GetProperty("CurrentTestOutcome")!.GetValue(context)!.ToString());
+ contextType.GetMethod("WriteLine", [typeof(string)])!.Invoke(context, ["hello from MSTest 4"]);
+ }
+ finally
+ {
+ loadContext.Unload();
+ }
+ }
+
+ [TestMethod]
+ public void TryCreate_ForATypeThatIsNotTestContext_ReturnsNull()
+ {
+ var testCase = new TestCaseContext(typeof(Methods), null);
+
+ Assert.IsNull(MSTestContextFactory.TryCreate(typeof(Stream), testCase));
+ }
+}
diff --git a/tests/Motus.Tests/Runner/TestExecutionServiceTests.cs b/tests/Motus.Tests/Runner/TestExecutionServiceTests.cs
new file mode 100644
index 0000000..5cbad02
--- /dev/null
+++ b/tests/Motus.Tests/Runner/TestExecutionServiceTests.cs
@@ -0,0 +1,141 @@
+using System.Reflection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Motus.Runner.Services;
+using Motus.Runner.Services.Models;
+
+namespace Motus.Tests.Runner;
+
+///
+/// The visual runner gives MSTest tests what MSTest's own adapter does: a TestContext
+/// whose outcome is the test's own during cleanup, data rows, and cleanup derived class first.
+///
+[TestClass]
+public class TestExecutionServiceTests
+{
+ #region Fixtures (not [TestClass]: run only through TestExecutionService)
+
+ public abstract class BrowserTestBase
+ {
+ public static List Log { get; } = [];
+
+ public TestContext TestContext { get; set; } = null!;
+
+ [TestInitialize]
+ public void BaseInit() => Log.Add("base init");
+
+ // The check MotusTestBase makes before it decides whether to keep a failure trace.
+ [TestCleanup]
+ public void BaseCleanup() => Log.Add($"base cleanup saveTrace={TestContext?.CurrentTestOutcome != UnitTestOutcome.Passed}");
+ }
+
+ public class SignInFixture : BrowserTestBase
+ {
+ public static TestContext? ClassContext { get; set; }
+
+ [ClassInitialize]
+ public static void ClassSetup(TestContext context) => ClassContext = context;
+
+ [TestCleanup]
+ public void DerivedCleanup() => Log.Add("derived cleanup");
+
+ public void UsesContext() => TestContext.WriteLine("hi");
+
+ public void Fails() => throw new InvalidOperationException("intentional failure");
+
+ public void SignsIn(string role) => Log.Add($"signs in as {role}");
+ }
+
+ #endregion
+
+ private static TestExecutionService CreateService() => new(NullLogger.Instance);
+
+ private static DiscoveredTest MakeTest(string methodName, string? fullName = null, object?[]? arguments = null)
+ {
+ var type = typeof(SignInFixture);
+ var method = type.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)!;
+ return new DiscoveredTest(type, method, fullName ?? $"{type.FullName}.{methodName}", "Motus.Tests", false,
+ Arguments: arguments);
+ }
+
+ private static async Task> RunAsync(params DiscoveredTest[] tests)
+ {
+ var states = new List();
+ await CreateService().ExecuteAsync([.. tests], s => { lock (states) states.Add(s); }, CancellationToken.None);
+ return states.Where(s => s.Status != TestStatus.Running).ToList();
+ }
+
+ [TestMethod]
+ public async Task PassingTest_HasATestContext_AndCleanupSeesItPassed()
+ {
+ BrowserTestBase.Log.Clear();
+
+ var states = await RunAsync(MakeTest(nameof(SignInFixture.UsesContext)));
+
+ Assert.AreEqual(TestStatus.Passed, states.Single().Status, states.Single().ErrorMessage);
+ CollectionAssert.AreEqual(
+ new[] { "base init", "derived cleanup", "base cleanup saveTrace=False" },
+ BrowserTestBase.Log);
+ }
+
+ [TestMethod]
+ public async Task FailingTest_CleanupSeesItFailed()
+ {
+ BrowserTestBase.Log.Clear();
+
+ var states = await RunAsync(MakeTest(nameof(SignInFixture.Fails)));
+
+ Assert.AreEqual(TestStatus.Failed, states.Single().Status);
+ CollectionAssert.Contains(BrowserTestBase.Log, "base cleanup saveTrace=True");
+ }
+
+ [TestMethod]
+ public async Task DataRowTest_RunsWithItsArguments()
+ {
+ BrowserTestBase.Log.Clear();
+ var fullName = $"{typeof(SignInFixture).FullName}.SignsIn (owner)";
+
+ var states = await RunAsync(MakeTest(nameof(SignInFixture.SignsIn), fullName, ["owner"]));
+
+ Assert.AreEqual(TestStatus.Passed, states.Single().Status, states.Single().ErrorMessage);
+ Assert.AreEqual(fullName, states.Single().FullName);
+ CollectionAssert.Contains(BrowserTestBase.Log, "signs in as owner");
+ }
+
+ [TestMethod]
+ public async Task ClassInitialize_RunsWithATestContext()
+ {
+ SignInFixture.ClassContext = null;
+
+ await RunAsync(MakeTest(nameof(SignInFixture.UsesContext)));
+
+ Assert.IsNotNull(SignInFixture.ClassContext, "the visual runner should run [ClassInitialize]");
+ Assert.AreEqual(typeof(SignInFixture).FullName, SignInFixture.ClassContext.FullyQualifiedTestClassName);
+ }
+
+ [TestMethod]
+ public void Discovery_ExpandsDataRows()
+ {
+ var discovery = new TestDiscovery(NullLogger.Instance);
+
+ var tests = discovery.Discover([typeof(RunnerDataRowFixture).Assembly.Location], nameof(RunnerDataRowFixture));
+
+ CollectionAssert.AreEquivalent(
+ new[]
+ {
+ $"{typeof(RunnerDataRowFixture).FullName}.Doubles (1,2)",
+ $"{typeof(RunnerDataRowFixture).FullName}.Doubles (2,4)",
+ },
+ tests.Select(t => t.FullName).ToArray());
+ Assert.IsTrue(tests.All(t => t.Arguments is { Length: 2 }));
+ }
+}
+
+/// A data-driven test MSTest runs as well, discovered by the visual runner in the test above.
+[TestClass]
+public class RunnerDataRowFixture
+{
+ [DataTestMethod]
+ [DataRow(1, 2)]
+ [DataRow(2, 4)]
+ public void Doubles(int value, int doubled) => Assert.AreEqual(doubled, value * 2);
+}