From adfd88a7a7472c1dbff115504ff76ade9f4983db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:27:13 +0000 Subject: [PATCH 1/2] perf: add data-driven Scenario2 to perf runner; cache JsonSerializerOptions in PlainProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario2 generates 100 classes × 10 methods × 10 DataRow rows, giving 10 000 test executions — the same total as Scenario1 (100 × 100 × 1). This makes the two scenarios directly comparable: the difference in elapsed/CPU time isolates the overhead of the MSTest data-driven machinery (CloneForDataDrivenIteration, ReflectionTestMethodInfo reuse, ParameterTypes caching) from the base per-test cost. The three new pipelines (method-level, class-level, and dotnet-test) all use the *PlainProcess* naming suffix, so they are automatically picked up by the existing perf-timing-nightly CI workflow without any CI changes. Also caches the JsonSerializerOptions instance in PlainProcess to eliminate a redundant allocation on every timing run and suppress the CA1869 warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MSTest.Performance.Runner/Program.cs | 29 +++ .../Scenarios/Scenario2.cs | 172 ++++++++++++++++++ .../Steps/PlainProcess.cs | 6 +- 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs diff --git a/test/Performance/MSTest.Performance.Runner/Program.cs b/test/Performance/MSTest.Performance.Runner/Program.cs index 84c7308305..63b35658b5 100644 --- a/test/Performance/MSTest.Performance.Runner/Program.cs +++ b/test/Performance/MSTest.Performance.Runner/Program.cs @@ -112,6 +112,35 @@ private static int Pipelines(string pipelineNameFilter) .NextStep(() => new MoveFiles("*.zip", Path.Combine(Directory.GetCurrentDirectory(), "Results"))) .NextStep(() => new CleanupDisposable())); + // Scenario2: data-driven tests (100 classes × 10 methods × 10 DataRow rows = 10 000 executions). + // Matches Scenario1's total execution count for a direct overhead comparison between + // the plain and data-driven execution paths. + pipelineRunner.AddPipeline("Default", "Scenario2_DataDriven_PlainProcess", [OSPlatform.Windows, OSPlatform.Linux, OSPlatform.OSX], parametersBag => + Pipeline + .FirstStep(() => new Scenario2(numberOfClass: 100, methodsPerClass: 10, dataRowsPerMethod: 10, tfm: "net9.0", executionScope: ExecutionScope.MethodLevel), parametersBag) + .NextStep(() => new DotnetMuxer(BuildConfiguration.Debug)) + .NextStep(() => new PlainProcess("Scenario2_DataDriven_PlainProcess.zip")) + .NextStep(() => new MoveFiles("*.zip", Path.Combine(Directory.GetCurrentDirectory(), "Results"))) + .NextStep(() => new CleanupDisposable())); + + // Class-level parallelism variant of the data-driven scenario. + pipelineRunner.AddPipeline("Default", "Scenario2_DataDriven_ClassLevel_PlainProcess", [OSPlatform.Windows, OSPlatform.Linux, OSPlatform.OSX], parametersBag => + Pipeline + .FirstStep(() => new Scenario2(numberOfClass: 100, methodsPerClass: 10, dataRowsPerMethod: 10, tfm: "net9.0", executionScope: ExecutionScope.ClassLevel), parametersBag) + .NextStep(() => new DotnetMuxer(BuildConfiguration.Debug)) + .NextStep(() => new PlainProcess("Scenario2_DataDriven_ClassLevel_PlainProcess.zip")) + .NextStep(() => new MoveFiles("*.zip", Path.Combine(Directory.GetCurrentDirectory(), "Results"))) + .NextStep(() => new CleanupDisposable())); + + // dotnet-test path for the data-driven scenario. + pipelineRunner.AddPipeline("Default", "Scenario2_DataDriven_DotnetTest_PlainProcess", [OSPlatform.Windows, OSPlatform.Linux, OSPlatform.OSX], parametersBag => + Pipeline + .FirstStep(() => new Scenario2(numberOfClass: 100, methodsPerClass: 10, dataRowsPerMethod: 10, tfm: "net9.0", executionScope: ExecutionScope.MethodLevel), parametersBag) + .NextStep(() => new DotnetMuxer(BuildConfiguration.Debug)) + .NextStep(() => new DotnetTestProcess("Scenario2_DataDriven_DotnetTest_PlainProcess.zip", BuildConfiguration.Debug)) + .NextStep(() => new MoveFiles("*.zip", Path.Combine(Directory.GetCurrentDirectory(), "Results"))) + .NextStep(() => new CleanupDisposable())); + return pipelineRunner.Run(pipelineNameFilter); } } diff --git a/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs b/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs new file mode 100644 index 0000000000..5b05c97f4c --- /dev/null +++ b/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.TestInfrastructure; + +namespace MSTest.Performance.Runner.Steps; + +/// +/// Data-driven test scenario: each test method is decorated with multiple +/// [DataRow] attributes so the test runner exercises the data-driven hot path +/// (CloneForDataDrivenIteration, ReflectionTestMethodInfo reuse, ParameterTypes cache, …). +/// +/// Designed to pair with Scenario1 for direct overhead comparison: +/// Scenario1 → 100 classes × 100 methods × 1 row = 10 000 test executions +/// Scenario2 → 100 classes × 10 methods × 10 rows = 10 000 test executions +/// +internal class Scenario2 : IStep +{ + private const string NuGetPackageExtensionName = ".nupkg"; + private const string MSTestTestFrameworkPackageNamePrefix = "MSTest.TestFramework."; + + private readonly int _numberOfClass; + private readonly int _methodsPerClass; + private readonly int _dataRowsPerMethod; + private readonly string _tfm; + private readonly ExecutionScope _executionScope; + private readonly int _workers; + + public Scenario2(int numberOfClass, int methodsPerClass, int dataRowsPerMethod, string tfm, ExecutionScope executionScope, int workers = 0) + { + _numberOfClass = numberOfClass; + _methodsPerClass = methodsPerClass; + _dataRowsPerMethod = dataRowsPerMethod; + _tfm = tfm; + _executionScope = executionScope; + _workers = workers; + } + + public string Description => "create Scenario2 (data-driven)"; + + public async Task ExecuteAsync(NoInputOutput payload, IContext context) + { + Console.WriteLine($"Creating Scenario2 {_numberOfClass} classes, {_methodsPerClass} methods per class, {_dataRowsPerMethod} data rows per method, ExecutionScope {_executionScope} with {_workers} workers"); + + var cpmPropFileDoc = XDocument.Load(Path.Combine(RootFinder.Find(), "Directory.Packages.props")); + string microsoftNETTestSdkVersion = cpmPropFileDoc.Descendants("MicrosoftNETTestSdkVersion").Single().Value; + string msTestVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, MSTestTestFrameworkPackageNamePrefix); + + StringBuilder stringBuilder = new(); + for (int i = 0; i < _numberOfClass; i++) + { + stringBuilder.AppendLine( + CultureInfo.InvariantCulture, + $$""" + + [TestClass] + public class UnitTest{{i}} + { + """); + + for (int k = 1; k < _methodsPerClass + 1; k++) + { + // Emit [DataRow] attributes before [TestMethod] — same count for every method. + for (int d = 0; d < _dataRowsPerMethod; d++) + { + stringBuilder.AppendLine(CultureInfo.InvariantCulture, $" [DataRow({d})]"); + } + + if (k % 2 == 0) + { + stringBuilder.AppendLine( + CultureInfo.InvariantCulture, + $$""" + [TestMethod] + [System.Runtime.CompilerServices.MethodImplAttribute(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + public System.Threading.Tasks.Task TestMethod{{k}}(int data) + { + return System.Threading.Tasks.Task.CompletedTask; + } + + """); + } + else + { + stringBuilder.AppendLine( + CultureInfo.InvariantCulture, + $$""" + [TestMethod] + [System.Runtime.CompilerServices.MethodImplAttribute(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + public void TestMethod{{k}}(int data) + { + } + + """); + } + } + + stringBuilder.AppendLine("}"); + } + + TestAsset generator = await TestAsset.GenerateAssetAsync( + nameof(Scenario2), + CurrentMSTestSourceCode + .PatchCodeWithReplace("$TargetFramework$", $"{_tfm}") + .PatchCodeWithReplace("$MicrosoftNETTestSdkVersion$", microsoftNETTestSdkVersion) + .PatchCodeWithReplace("$MSTestVersion$", msTestVersion) + .PatchCodeWithReplace("$EnableMSTestRunner$", "true") + .PatchCodeWithReplace("$OutputType$", "Exe") + .PatchCodeWithReplace("$Extra$", string.Empty) + .PatchCodeWithReplace("$Tests$", stringBuilder.ToString()) + .PatchCodeWithReplace("$ExecutionScope$", _executionScope.ToString()) + .PatchCodeWithReplace("$Workers$", _workers.ToString(CultureInfo.InvariantCulture)), + addPublicFeeds: true); + + context.AddDisposable(generator); + return new SingleProject(["net9.0"], generator, nameof(Scenario2)); + } + + private static string ExtractVersionFromPackage(string rootFolder, string packagePrefixName) + { + string[] matches = Directory.GetFiles(rootFolder, packagePrefixName + "*" + NuGetPackageExtensionName, SearchOption.TopDirectoryOnly); + + if (matches.Length > 1) + { + // For some packages the find pattern will match multiple packages, for example: + // Microsoft.Testing.Platform.1.0.0.nupkg + // Microsoft.Testing.Platform.Extensions.1.0.0.nupkg + // Let's take shortest name which should be closest to the package we are looking for. + matches = [matches.OrderBy(x => x.Length).First()]; + } + + if (matches.Length != 1) + { + throw new InvalidOperationException($"Was expecting to find a single NuGet package named '{packagePrefixName}' in '{rootFolder}' but found {matches.Length}."); + } + + string packageFullName = Path.GetFileName(matches[0]); + return packageFullName.Substring(packagePrefixName.Length, packageFullName.Length - packagePrefixName.Length - NuGetPackageExtensionName.Length); + } + + protected const string CurrentMSTestSourceCode = """ +#file Scenario2.csproj + + + + x64 + false + true + $TargetFramework$ + $OutputType$ + $EnableMSTestRunner$ + $Extra$ + + + + + + + + + + + +#file UnitTest1.cs + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Workers = $Workers$, Scope = ExecutionScope.$ExecutionScope$)] + +$Tests$ +"""; +} diff --git a/test/Performance/MSTest.Performance.Runner/Steps/PlainProcess.cs b/test/Performance/MSTest.Performance.Runner/Steps/PlainProcess.cs index 691ffb6a74..fc0044eb8b 100644 --- a/test/Performance/MSTest.Performance.Runner/Steps/PlainProcess.cs +++ b/test/Performance/MSTest.Performance.Runner/Steps/PlainProcess.cs @@ -8,6 +8,8 @@ namespace MSTest.Performance.Runner.Steps; internal class PlainProcess : IStep { + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly string _reportFileName; private readonly int _numberOfRun; private readonly string _argument; @@ -52,11 +54,9 @@ public async Task ExecuteAsync(BuildArtifact payload, IContext context) results.Add(result); } -#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances await File.AppendAllTextAsync(Path.Combine(Path.GetDirectoryName(payload.TestHost.FullName)!, "Result.json"), JsonSerializer.Serialize( results, - new JsonSerializerOptions { WriteIndented = true })); -#pragma warning restore CA1869 // Cache and reuse 'JsonSerializerOptions' instances + JsonOptions)); string sample = Path.Combine(Path.GetTempPath(), _reportFileName); File.Delete(sample); From 49d67a4fb93e0a1d411f2b38287a3fc90035b1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 8 Jul 2026 13:20:10 +0200 Subject: [PATCH 2/2] Address review: use _tfm in SingleProject and add UTF-8 BOM to Scenario2.cs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MSTest.Performance.Runner/Scenarios/Scenario2.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs b/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs index 5b05c97f4c..913a06ae1d 100644 --- a/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs +++ b/test/Performance/MSTest.Performance.Runner/Scenarios/Scenario2.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.TestInfrastructure; @@ -113,7 +113,7 @@ public class UnitTest{{i}} addPublicFeeds: true); context.AddDisposable(generator); - return new SingleProject(["net9.0"], generator, nameof(Scenario2)); + return new SingleProject([_tfm], generator, nameof(Scenario2)); } private static string ExtractVersionFromPackage(string rootFolder, string packagePrefixName)