// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Globalization; using Microsoft.Testing.Platform.Acceptance.IntegrationTests; using Microsoft.Testing.TestInfrastructure; namespace MSTest.Acceptance.IntegrationTests; /// /// Acceptance-style rewrite of the former CLITestBase-based parallel execution tests. The parallel /// test assets (method-level, class-level and do-not-parallelize) are generated inline, built, then /// executed out-of-process through the MSTest runner (Microsoft.Testing.Platform) host. /// /// /// The generated assets record their maximum concurrent test count. This deterministically verifies /// that method-level and class-level parallelization overlap tests while /// keeps execution serial, without relying on a flaky wall-clock threshold. /// [TestClass] public sealed class ParallelExecutionTests : AcceptanceTestBase { private const string MethodParallelProjectName = "ParallelMethodsTestProject"; private const string ClassParallelProjectName = "ParallelClassesTestProject"; private const string DoNotParallelizeProjectName = "DoNotParallelizeTestProject"; private const string DoNotParallelizeOrderingProjectName = "DoNotParallelizeOrderingTestProject"; public TestContext TestContext { get; set; } = default!; [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task AllMethodsShouldRunInParallel(string tfm) { TestHost testHost = AssetFixture.GetTestHost(MethodParallelProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); Assert.Contains("failed: 2", result.StandardOutput); Assert.Contains("succeeded: 4", result.StandardOutput); Assert.Contains("SimpleTest12", result.StandardOutput); Assert.Contains("SimpleTest22", result.StandardOutput); Assert.IsGreaterThanOrEqualTo(2, AssetFixture.ReadMaximumConcurrency(MethodParallelProjectName, tfm)); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task AllClassesShouldRunInParallel(string tfm) { TestHost testHost = AssetFixture.GetTestHost(ClassParallelProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); Assert.Contains("failed: 3", result.StandardOutput); Assert.Contains("succeeded: 4", result.StandardOutput); Assert.Contains("SimpleTest12", result.StandardOutput); Assert.Contains("SimpleTest22", result.StandardOutput); Assert.Contains("SimpleTest32", result.StandardOutput); Assert.IsGreaterThanOrEqualTo(2, AssetFixture.ReadMaximumConcurrency(ClassParallelProjectName, tfm)); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task NothingShouldRunInParallel(string tfm) { TestHost testHost = AssetFixture.GetTestHost(DoNotParallelizeProjectName, tfm); string settingsPath = AssetFixture.GetRunSettingsPath(DoNotParallelizeProjectName); TestHostResult result = await testHost.ExecuteAsync($"--settings \"{settingsPath}\" --output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); Assert.Contains("failed: 2", result.StandardOutput); Assert.Contains("succeeded: 3", result.StandardOutput); Assert.Contains("SimpleTest12", result.StandardOutput); Assert.Contains("SimpleTest22", result.StandardOutput); Assert.AreEqual(1, AssetFixture.ReadMaximumConcurrency(DoNotParallelizeProjectName, tfm)); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task DoNotParallelizeTestRunsAfterAllParallelizableTests(string tfm) { // Pins the defer-to-end scheduling of [DoNotParallelize]: the parallelizable tests each // increment a shared counter as they complete, and the single [DoNotParallelize] test // asserts the counter already equals the parallelizable count when it runs. The scheduler // awaits the whole parallelizable set before starting the non-parallelizable set, so this is // deterministic and does not rely on any wall-clock timing. TestHost testHost = AssetFixture.GetTestHost(DoNotParallelizeOrderingProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreEqual(0, result.ExitCode, result.StandardOutput); Assert.Contains("succeeded: 5", result.StandardOutput); } public sealed class TestAssetFixture : ITestAssetFixture { private readonly TempDirectory _tempDirectory = new(); private readonly Dictionary _assets = []; public TestHost GetTestHost(string projectName, string tfm) => TestHost.LocateFrom(_assets[projectName].TargetAssetPath, projectName, tfm); public string GetRunSettingsPath(string projectName) => Path.Combine(_assets[projectName].TargetAssetPath, "parallel.runsettings"); public int ReadMaximumConcurrency(string projectName, string tfm) { string probePath = Path.Combine(GetTestHost(projectName, tfm).DirectoryName, "ParallelProbe.txt"); return int.Parse(File.ReadAllText(probePath), CultureInfo.InvariantCulture); } public async Task InitializeAsync(CancellationToken cancellationToken) { foreach ((string projectName, string code) in new[] { (MethodParallelProjectName, MethodParallelSourceCode), (ClassParallelProjectName, ClassParallelSourceCode), (DoNotParallelizeProjectName, DoNotParallelizeSourceCode), (DoNotParallelizeOrderingProjectName, DoNotParallelizeOrderingSourceCode), }) { string patched = code .PatchTargetFrameworks(TargetFrameworks.All) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion); TestAsset asset = await TestAsset.GenerateAssetAsync(projectName, patched, _tempDirectory); await DotnetCli.RunAsync($"build \"{asset.TargetAssetPath}\" -c Release", callerMemberName: projectName, cancellationToken: cancellationToken); _assets.Add(projectName, asset); } } public void Dispose() { foreach (TestAsset asset in _assets.Values) { asset.Dispose(); } _tempDirectory.Dispose(); } private const string ProjectFile = """ #file $ProjectName$.csproj Exe true $TargetFrameworks$ latest """; private const string ParallelProbeSourceCode = """ #file ParallelProbe.cs using System; using System.Globalization; using System.IO; using System.Threading; internal static class ParallelProbe { private static int s_active; private static int s_maximum; public static void Run(Action assertion) { int active = Interlocked.Increment(ref s_active); int maximum; while (active > (maximum = Volatile.Read(ref s_maximum)) && Interlocked.CompareExchange(ref s_maximum, active, maximum) != maximum) { } try { Thread.Sleep(1000); } finally { Interlocked.Decrement(ref s_active); } assertion(); } public static void WriteResult() => File.WriteAllText( Path.Combine(AppContext.BaseDirectory, "ParallelProbe.txt"), Volatile.Read(ref s_maximum).ToString(CultureInfo.InvariantCulture)); } """; private static readonly string MethodParallelSourceCode = ProjectFile.Replace("$ProjectName$", MethodParallelProjectName) + ParallelProbeSourceCode + """ #file Tests.cs using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 2, Scope = ExecutionScope.MethodLevel)] namespace ParallelMethodsTestProject; internal static class Constants { internal const int WaitTimeInMS = 1000; } [TestClass] public class UnitTest1 { [TestMethod] public void SimpleTest11() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); [TestMethod] public void SimpleTest12() => ParallelProbe.Run(() => Assert.Fail()); [TestMethod] public void SimpleTest13() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); } [TestClass] public class UnitTest2 { private static bool s_assemblyInitCalled; private static bool s_assemblyCleanCalled; private static bool s_classInitCalled; private static bool s_classCleanCalled; [AssemblyInitialize] public static void AssemblyInit(TestContext context) { Assert.IsFalse(s_assemblyInitCalled); s_assemblyInitCalled = true; } [ClassInitialize] public static void ClassInit(TestContext context) { Assert.IsFalse(s_classInitCalled); s_classInitCalled = true; } [TestInitialize] public void Initialize() { } [TestCleanup] public void Cleanup() { } [ClassCleanup] public static void ClassCleanup() { Assert.IsFalse(s_classCleanCalled); s_classCleanCalled = true; } [AssemblyCleanup] public static void AssemblyCleanup() { Assert.IsFalse(s_assemblyCleanCalled); s_assemblyCleanCalled = true; ParallelProbe.WriteResult(); } [TestMethod] public void SimpleTest21() => ParallelProbe.Run(() => Assert.AreEqual(0, 0)); [TestMethod] public void SimpleTest22() => ParallelProbe.Run(() => Assert.Fail()); [TestMethod] [DoNotParallelize] public void IsolatedTest() => ParallelProbe.Run(() => Assert.IsTrue(true)); } """; private static readonly string ClassParallelSourceCode = ProjectFile.Replace("$ProjectName$", ClassParallelProjectName) + ParallelProbeSourceCode + """ #file Tests.cs using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 2, Scope = ExecutionScope.ClassLevel)] namespace ParallelClassesTestProject; internal static class Constants { internal const int WaitTimeInMS = 1000; } [TestClass] public class UnitTest1 { [TestMethod] public void SimpleTest11() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); [TestMethod] public void SimpleTest12() => ParallelProbe.Run(() => Assert.Fail()); } [TestClass] public class UnitTest2 { private static bool s_assemblyInitCalled; private static bool s_assemblyCleanCalled; private static bool s_classInitCalled; private static bool s_classCleanCalled; [AssemblyInitialize] public static void AssemblyInit(TestContext context) { Assert.IsFalse(s_assemblyInitCalled); s_assemblyInitCalled = true; } [ClassInitialize] public static void ClassInit(TestContext context) { Assert.IsFalse(s_classInitCalled); s_classInitCalled = true; } [TestInitialize] public void Initialize() { } [TestCleanup] public void Cleanup() { } [ClassCleanup] public static void ClassCleanup() { Assert.IsFalse(s_classCleanCalled); s_classCleanCalled = true; } [AssemblyCleanup] public static void AssemblyCleanup() { Assert.IsFalse(s_assemblyCleanCalled); s_assemblyCleanCalled = true; ParallelProbe.WriteResult(); } [TestMethod] public void SimpleTest21() => ParallelProbe.Run(() => Assert.AreEqual(0, 0)); [TestMethod] public void SimpleTest22() => ParallelProbe.Run(() => Assert.Fail()); [TestMethod] [DoNotParallelize] public void IsolatedTest() => ParallelProbe.Run(() => Assert.IsTrue(true)); } [TestClass] public class UnitTest3 { [TestMethod] public void SimpleTest31() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); [TestMethod] public void SimpleTest32() => ParallelProbe.Run(() => Assert.Fail()); } """; private static readonly string DoNotParallelizeSourceCode = ProjectFile.Replace("$ProjectName$", DoNotParallelizeProjectName) + ParallelProbeSourceCode + """ #file Tests.cs using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: DoNotParallelize] namespace DoNotParallelizeTestProject; internal static class Constants { internal const int WaitTimeInMS = 1000; } [TestClass] public class UnitTest1 { [TestMethod] public void SimpleTest11() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); [TestMethod] public void SimpleTest12() => ParallelProbe.Run(() => Assert.Fail()); [TestMethod] public void SimpleTest13() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); } [TestClass] public class UnitTest2 { [AssemblyCleanup] public static void AssemblyCleanup() => ParallelProbe.WriteResult(); [TestMethod] public void SimpleTest21() => ParallelProbe.Run(() => Assert.AreEqual(1, 1)); [TestMethod] public void SimpleTest22() => ParallelProbe.Run(() => Assert.Fail()); } #file parallel.runsettings 4 ClassLevel """; private static readonly string DoNotParallelizeOrderingSourceCode = ProjectFile.Replace("$ProjectName$", DoNotParallelizeOrderingProjectName) + """ #file Tests.cs using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 3, Scope = ExecutionScope.MethodLevel)] namespace DoNotParallelizeOrderingTestProject; internal static class OrderTracker { internal const int ParallelizableCount = 4; private static int s_completedParallelizable; internal static void MarkParallelizableCompleted() => Interlocked.Increment(ref s_completedParallelizable); internal static int CompletedParallelizable => Volatile.Read(ref s_completedParallelizable); } [TestClass] public class ParallelTests { [TestMethod] public void Parallel1() => OrderTracker.MarkParallelizableCompleted(); [TestMethod] public void Parallel2() => OrderTracker.MarkParallelizableCompleted(); [TestMethod] public void Parallel3() => OrderTracker.MarkParallelizableCompleted(); [TestMethod] public void Parallel4() => OrderTracker.MarkParallelizableCompleted(); } [TestClass] public class DeferredTests { // Marked [DoNotParallelize], so this test is deferred to run after every parallelizable test in // the source has completed; the shared counter must therefore already equal the full // parallelizable count by the time it runs. [TestMethod] [DoNotParallelize] public void DeferredRunsAfterAllParallelizableTests() => Assert.AreEqual( OrderTracker.ParallelizableCount, OrderTracker.CompletedParallelizable, "The [DoNotParallelize] test must run only after every parallelizable test has completed."); } """; } }