Skip to content

Commit 17e322a

Browse files
authored
Support '--maximum-failed-tests' to abort test run when failure threshold is reached (#4238)
1 parent 19b63cd commit 17e322a

File tree

41 files changed

+1105
-45
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1105
-45
lines changed

Diff for: src/Adapter/MSTest.TestAdapter/Execution/ClassCleanupManager.cs

+15
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,19 @@ public void MarkTestComplete(TestMethodInfo testMethodInfo, TestMethod testMetho
6262
ShouldRunEndOfAssemblyCleanup = _remainingTestsByClass.IsEmpty;
6363
}
6464
}
65+
66+
internal static void ForceCleanup(TypeCache typeCache)
67+
{
68+
IEnumerable<TestClassInfo> classInfoCache = typeCache.ClassInfoListWithExecutableCleanupMethods;
69+
foreach (TestClassInfo classInfo in classInfoCache)
70+
{
71+
classInfo.ExecuteClassCleanup();
72+
}
73+
74+
IEnumerable<TestAssemblyInfo> assemblyInfoCache = typeCache.AssemblyInfoListWithExecutableCleanupMethods;
75+
foreach (TestAssemblyInfo assemblyInfo in assemblyInfoCache)
76+
{
77+
assemblyInfo.ExecuteAssemblyCleanup();
78+
}
79+
}
6580
}

Diff for: src/Adapter/MSTest.TestAdapter/Execution/TestClassInfo.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ public void RunClassInitialize(TestContext testContext)
252252
// If no class initialize and no base class initialize, return
253253
if (ClassInitializeMethod is null && BaseClassInitMethods.Count == 0)
254254
{
255+
IsClassInitializeExecuted = true;
255256
return;
256257
}
257258

@@ -558,8 +559,10 @@ internal void ExecuteClassCleanup()
558559
lock (_testClassExecuteSyncObject)
559560
{
560561
if (IsClassCleanupExecuted
561-
// If there is a ClassInitialize method and it has not been executed, then we should not execute ClassCleanup
562-
|| (!IsClassInitializeExecuted && ClassInitializeMethod is not null))
562+
// If ClassInitialize method has not been executed, then we should not execute ClassCleanup
563+
// Note that if there is no ClassInitialze method at all, we will still set
564+
// IsClassInitializeExecuted to true in RunClassInitialize
565+
|| !IsClassInitializeExecuted)
563566
{
564567
return;
565568
}

Diff for: src/Adapter/MSTest.TestAdapter/Execution/TestExecutionManager.cs

+9
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,11 @@ private void ExecuteTestsInSource(IEnumerable<TestCase> tests, IRunContext? runC
399399
ExecuteTestsWithTestRunner(testsToRun, frameworkHandle, source, sourceLevelParameters, testRunner);
400400
}
401401

402+
if (MSTestGracefulStopTestExecutionCapability.Instance.IsStopRequested)
403+
{
404+
testRunner.ForceCleanup();
405+
}
406+
402407
PlatformServiceProvider.Instance.AdapterTraceLogger.LogInfo("Executed tests belonging to source {0}", source);
403408
}
404409

@@ -419,6 +424,10 @@ private void ExecuteTestsWithTestRunner(
419424
foreach (TestCase currentTest in orderedTests)
420425
{
421426
_testRunCancellationToken?.ThrowIfCancellationRequested();
427+
if (MSTestGracefulStopTestExecutionCapability.Instance.IsStopRequested)
428+
{
429+
break;
430+
}
422431

423432
// If it is a fixture test, add it to the list of fixture tests and do not execute it.
424433
// It is executed by test itself.

Diff for: src/Adapter/MSTest.TestAdapter/Execution/UnitTestRunner.cs

+3
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ internal UnitTestResult[] RunSingleTest(TestMethod testMethod, IDictionary<strin
169169
else
170170
{
171171
UnitTestResult classInitializeResult = testMethodInfo.Parent.GetResultOrRunClassInitialize(testContext, assemblyInitializeResult.StandardOut!, assemblyInitializeResult.StandardError!, assemblyInitializeResult.DebugTrace!, assemblyInitializeResult.TestContextMessages!);
172+
DebugEx.Assert(testMethodInfo.Parent.IsClassInitializeExecuted, "IsClassInitializeExecuted should be true after attempting to run it.");
172173
if (classInitializeResult.Outcome != UnitTestOutcome.Passed)
173174
{
174175
result = [classInitializeResult];
@@ -361,4 +362,6 @@ private bool IsTestMethodRunnable(
361362
notRunnableResult = null;
362363
return true;
363364
}
365+
366+
internal void ForceCleanup() => ClassCleanupManager.ForceCleanup(_typeCache);
364367
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using Microsoft.Testing.Platform.Capabilities.TestFramework;
5+
6+
namespace Microsoft.VisualStudio.TestTools.UnitTesting;
7+
8+
#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
9+
internal sealed class MSTestGracefulStopTestExecutionCapability : IGracefulStopTestExecutionCapability
10+
#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
11+
{
12+
private MSTestGracefulStopTestExecutionCapability()
13+
{
14+
}
15+
16+
public static MSTestGracefulStopTestExecutionCapability Instance { get; } = new();
17+
18+
public bool IsStopRequested { get; private set; }
19+
20+
public Task StopTestExecutionAsync(CancellationToken cancellationToken)
21+
{
22+
IsStopRequested = true;
23+
return Task.CompletedTask;
24+
}
25+
}

Diff for: src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.Testing.Extensions.VSTestBridge.Helpers;
99
using Microsoft.Testing.Platform.Builder;
1010
using Microsoft.Testing.Platform.Capabilities.TestFramework;
11+
using Microsoft.Testing.Platform.Helpers;
1112
using Microsoft.Testing.Platform.Services;
1213

1314
namespace Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -20,12 +21,16 @@ public static void AddMSTest(this ITestApplicationBuilder testApplicationBuilder
2021
testApplicationBuilder.AddRunSettingsService(extension);
2122
testApplicationBuilder.AddTestCaseFilterService(extension);
2223
testApplicationBuilder.AddTestRunParametersService(extension);
24+
#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
25+
testApplicationBuilder.AddMaximumFailedTestsService(extension);
26+
#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
2327
testApplicationBuilder.AddRunSettingsEnvironmentVariableProvider(extension);
2428
testApplicationBuilder.RegisterTestFramework(
2529
serviceProvider => new TestFrameworkCapabilities(
2630
new VSTestBridgeExtensionBaseCapabilities(),
2731
#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
28-
new MSTestBannerCapability(serviceProvider.GetRequiredService<IPlatformInformation>())),
32+
new MSTestBannerCapability(serviceProvider.GetRequiredService<IPlatformInformation>()),
33+
MSTestGracefulStopTestExecutionCapability.Instance),
2934
#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
3035
(capabilities, serviceProvider) => new MSTestBridgedTestFramework(extension, getTestAssemblies, serviceProvider, capabilities));
3136
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
6+
namespace Microsoft.Testing.Platform.Capabilities.TestFramework;
7+
8+
/// <summary>
9+
/// A capability to support stopping test execution gracefully, without cancelling/aborting everything.
10+
/// This is used to support '--maximum-failed-tests'.
11+
/// </summary>
12+
/// <remarks>
13+
/// Test frameworks can choose to run any needed cleanup when cancellation is requested.
14+
/// </remarks>
15+
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
16+
public interface IGracefulStopTestExecutionCapability : ITestFrameworkCapability
17+
{
18+
Task StopTestExecutionAsync(CancellationToken cancellationToken);
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Globalization;
5+
6+
using Microsoft.Testing.Platform.Extensions;
7+
using Microsoft.Testing.Platform.Extensions.CommandLine;
8+
using Microsoft.Testing.Platform.Helpers;
9+
using Microsoft.Testing.Platform.Resources;
10+
11+
namespace Microsoft.Testing.Platform.CommandLine;
12+
13+
internal sealed class MaxFailedTestsCommandLineOptionsProvider(IExtension extension) : ICommandLineOptionsProvider
14+
{
15+
internal const string MaxFailedTestsOptionKey = "maximum-failed-tests";
16+
17+
private static readonly IReadOnlyCollection<CommandLineOption> OptionsCache =
18+
[
19+
new(MaxFailedTestsOptionKey, PlatformResources.PlatformCommandLineMaxFailedTestsOptionDescription, ArgumentArity.ExactlyOne, isHidden: false),
20+
];
21+
22+
public string Uid => extension.Uid;
23+
24+
public string Version => extension.Version;
25+
26+
public string DisplayName => extension.DisplayName;
27+
28+
public string Description => extension.Description;
29+
30+
public IReadOnlyCollection<CommandLineOption> GetCommandLineOptions()
31+
=> OptionsCache;
32+
33+
public Task<bool> IsEnabledAsync() => Task.FromResult(true);
34+
35+
public Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions)
36+
=> ValidationResult.ValidTask;
37+
38+
public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments)
39+
{
40+
if (commandOption.Name == MaxFailedTestsOptionKey)
41+
{
42+
string arg = arguments[0];
43+
// We consider --maximum-failed-tests 0 as invalid.
44+
// The idea is that we stop the execution when we *reach* the max failed tests, not when *exceed*.
45+
// So the value 1 means, stop execution on the first failure.
46+
return int.TryParse(arg, out int maxFailedTestsResult) && maxFailedTestsResult > 0
47+
? ValidationResult.ValidTask
48+
: ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, PlatformResources.MaxFailedTestsMustBePositive, arg));
49+
}
50+
51+
throw ApplicationStateGuard.Unreachable();
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using Microsoft.Testing.Platform.Capabilities.TestFramework;
5+
using Microsoft.Testing.Platform.CommandLine;
6+
using Microsoft.Testing.Platform.Extensions.Messages;
7+
using Microsoft.Testing.Platform.Extensions.TestHost;
8+
using Microsoft.Testing.Platform.Helpers;
9+
using Microsoft.Testing.Platform.Messages;
10+
using Microsoft.Testing.Platform.Resources;
11+
using Microsoft.Testing.Platform.Services;
12+
13+
namespace Microsoft.Testing.Platform.Extensions;
14+
15+
internal sealed class AbortForMaxFailedTestsExtension : IDataConsumer
16+
{
17+
private readonly int? _maxFailedTests;
18+
private readonly IGracefulStopTestExecutionCapability? _capability;
19+
private readonly IStopPoliciesService _policiesService;
20+
private readonly ITestApplicationCancellationTokenSource _testApplicationCancellationTokenSource;
21+
private int _failCount;
22+
23+
public AbortForMaxFailedTestsExtension(
24+
ICommandLineOptions commandLineOptions,
25+
IGracefulStopTestExecutionCapability? capability,
26+
IStopPoliciesService policiesService,
27+
ITestApplicationCancellationTokenSource testApplicationCancellationTokenSource)
28+
{
29+
if (commandLineOptions.TryGetOptionArgumentList(MaxFailedTestsCommandLineOptionsProvider.MaxFailedTestsOptionKey, out string[]? args) &&
30+
int.TryParse(args[0], out int maxFailedTests) &&
31+
maxFailedTests > 0)
32+
{
33+
_maxFailedTests = maxFailedTests;
34+
}
35+
36+
_capability = capability;
37+
_policiesService = policiesService;
38+
_testApplicationCancellationTokenSource = testApplicationCancellationTokenSource;
39+
}
40+
41+
public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)];
42+
43+
/// <inheritdoc />
44+
public string Uid { get; } = nameof(AbortForMaxFailedTestsExtension);
45+
46+
/// <inheritdoc />
47+
public string Version { get; } = AppVersion.DefaultSemVer;
48+
49+
/// <inheritdoc />
50+
public string DisplayName { get; } = nameof(AbortForMaxFailedTestsExtension);
51+
52+
/// <inheritdoc />
53+
public string Description { get; } = PlatformResources.AbortForMaxFailedTestsDescription;
54+
55+
/// <inheritdoc />
56+
public Task<bool> IsEnabledAsync() => Task.FromResult(_maxFailedTests.HasValue && _capability is not null);
57+
58+
public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken)
59+
{
60+
var node = (TestNodeUpdateMessage)value;
61+
62+
// If we are called, the extension is enabled, which means both _maxFailedTests and capability are not null.
63+
RoslynDebug.Assert(_maxFailedTests is not null);
64+
RoslynDebug.Assert(_capability is not null);
65+
66+
TestNodeStateProperty testNodeStateProperty = node.TestNode.Properties.Single<TestNodeStateProperty>();
67+
if (TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties.Any(t => t == testNodeStateProperty.GetType()) &&
68+
++_failCount >= _maxFailedTests.Value &&
69+
// If already triggered, don't do it again.
70+
!_policiesService.IsMaxFailedTestsTriggered)
71+
{
72+
await _capability.StopTestExecutionAsync(_testApplicationCancellationTokenSource.CancellationToken);
73+
await _policiesService.ExecuteMaxFailedTestsCallbacksAsync(_maxFailedTests.Value, _testApplicationCancellationTokenSource.CancellationToken);
74+
}
75+
}
76+
}

Diff for: src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs

+1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ internal static class ExitCodes
2222
public const int TestAdapterTestSessionFailure = 10;
2323
public const int DependentProcessExited = 11;
2424
public const int IncompatibleProtocolVersion = 12;
25+
public const int TestExecutionStoppedForMaxFailedTests = 13;
2526
}

Diff for: src/Platform/Microsoft.Testing.Platform/Helpers/TestApplicationBuilderExtensions.cs

+8
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ public static class TestApplicationBuilderExtensions
1515
{
1616
public static void AddTreeNodeFilterService(this ITestApplicationBuilder testApplicationBuilder, IExtension extension)
1717
=> testApplicationBuilder.CommandLine.AddProvider(() => new TreeNodeFilterCommandLineOptionsProvider(extension));
18+
19+
/// <summary>
20+
/// Registers the command-line options provider for '--maximum-failed-tests'.
21+
/// </summary>
22+
/// <param name="builder">The test application builder.</param>
23+
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
24+
public static void AddMaximumFailedTestsService(this ITestApplicationBuilder builder, IExtension extension)
25+
=> builder.CommandLine.AddProvider(() => new MaxFailedTestsCommandLineOptionsProvider(extension));
1826
}

Diff for: src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs

+23-2
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ public async Task<ITestHost> BuildAsync(
206206
// Set the concrete command line options to the proxy.
207207
commandLineOptionsProxy.SetCommandLineOptions(commandLineHandler);
208208

209+
// This is needed by output device.
210+
var policiesService = new StopPoliciesService(testApplicationCancellationTokenSource);
211+
serviceProvider.AddService(policiesService);
212+
209213
bool hasServerFlag = commandLineHandler.TryGetOptionArgumentList(PlatformCommandLineProvider.ServerOptionKey, out string[]? protocolName);
210214
bool isJsonRpcProtocol = protocolName is null || protocolName.Length == 0 || protocolName[0].Equals(PlatformCommandLineProvider.JsonRpcProtocolName, StringComparison.OrdinalIgnoreCase);
211215

@@ -313,9 +317,9 @@ public async Task<ITestHost> BuildAsync(
313317
// Register the ITestApplicationResult
314318
TestApplicationResult testApplicationResult = new(
315319
proxyOutputDevice,
316-
serviceProvider.GetTestApplicationCancellationTokenSource(),
317320
serviceProvider.GetCommandLineOptions(),
318-
serviceProvider.GetEnvironment());
321+
serviceProvider.GetEnvironment(),
322+
policiesService);
319323
serviceProvider.AddService(testApplicationResult);
320324

321325
// ============= SETUP COMMON SERVICE USED IN ALL MODES END ===============//
@@ -376,6 +380,8 @@ await LogTestHostCreatedAsync(
376380
TestHostOrchestratorConfiguration testHostOrchestratorConfiguration = await TestHostOrchestratorManager.BuildAsync(serviceProvider);
377381
if (testHostOrchestratorConfiguration.TestHostOrchestrators.Length > 0 && !commandLineHandler.IsOptionSet(PlatformCommandLineProvider.DiscoverTestsOptionKey))
378382
{
383+
policiesService.ProcessRole = TestProcessRole.TestHostOrchestrator;
384+
await proxyOutputDevice.HandleProcessRoleAsync(TestProcessRole.TestHostOrchestrator);
379385
return new TestHostOrchestratorHost(testHostOrchestratorConfiguration, serviceProvider);
380386
}
381387

@@ -411,6 +417,8 @@ await LogTestHostCreatedAsync(
411417
if (testHostControllers.RequireProcessRestart)
412418
{
413419
testHostControllerInfo.IsCurrentProcessTestHostController = true;
420+
policiesService.ProcessRole = TestProcessRole.TestHostController;
421+
await proxyOutputDevice.HandleProcessRoleAsync(TestProcessRole.TestHostController);
414422
TestHostControllersTestHost testHostControllersTestHost = new(testHostControllers, testHostControllersServiceProvider, passiveNode, systemEnvironment, loggerFactory, systemClock);
415423

416424
await LogTestHostCreatedAsync(
@@ -424,6 +432,8 @@ await LogTestHostCreatedAsync(
424432
}
425433

426434
// ======= TEST HOST MODE ======== //
435+
policiesService.ProcessRole = TestProcessRole.TestHost;
436+
await proxyOutputDevice.HandleProcessRoleAsync(TestProcessRole.TestHost);
427437

428438
// Setup the test host working folder.
429439
// Out of the test host controller extension the current working directory is the test host working directory.
@@ -724,6 +734,17 @@ private async Task<ITestFramework> BuildTestFrameworkAsync(TestFrameworkBuilderD
724734
dataConsumersBuilder.Add(pushOnlyProtocolDataConsumer);
725735
}
726736

737+
var abortForMaxFailedTestsExtension = new AbortForMaxFailedTestsExtension(
738+
serviceProvider.GetCommandLineOptions(),
739+
serviceProvider.GetTestFrameworkCapabilities().GetCapability<IGracefulStopTestExecutionCapability>(),
740+
serviceProvider.GetRequiredService<IStopPoliciesService>(),
741+
serviceProvider.GetTestApplicationCancellationTokenSource());
742+
743+
if (await abortForMaxFailedTestsExtension.IsEnabledAsync())
744+
{
745+
dataConsumersBuilder.Add(abortForMaxFailedTestsExtension);
746+
}
747+
727748
IDataConsumer[] dataConsumerServices = dataConsumersBuilder.ToArray();
728749

729750
// Build the message bus

Diff for: src/Platform/Microsoft.Testing.Platform/OutputDevice/IPlatformOutputDevice.cs

+2
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ internal interface IPlatformOutputDevice : IExtension
1515
Task DisplayAfterSessionEndRunAsync();
1616

1717
Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data);
18+
19+
Task HandleProcessRoleAsync(TestProcessRole processRole);
1820
}

0 commit comments

Comments
 (0)