Skip to content

Commit 53ab1d0

Browse files
EvangelinkCopilot
andauthored
Abstract VSTest test filtering in PlatformServices (Phase 2 of platform-agnostic effort) (#9567)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 939f782 commit 53ab1d0

8 files changed

Lines changed: 310 additions & 29 deletions

File tree

src/Adapter/MSTestAdapter.PlatformServices/Discovery/UnitTestDiscoverer.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,24 +110,22 @@ internal virtual void DiscoverTestsInSource(
110110

111111
internal void SendTestCases(IEnumerable<UnitTestElement> testElements, ITestCaseDiscoverySink discoverySink, IDiscoveryContext? discoveryContext, IAdapterMessageLogger logger)
112112
{
113-
// Get filter expression and skip discovery in case filter expression has parsing error.
114-
ITestCaseFilterExpression? filterExpression = _testMethodFilter.GetFilterExpression(discoveryContext, logger, out bool filterHasError);
113+
// Get filter and skip discovery in case filter expression has parsing error.
114+
ITestElementFilter? filter = _testMethodFilter.GetTestElementFilter(discoveryContext, logger, out bool filterHasError);
115115
if (filterHasError)
116116
{
117117
return;
118118
}
119119

120120
foreach (UnitTestElement testElement in testElements)
121121
{
122-
var testCase = testElement.ToTestCase();
123-
124-
// Filter tests based on test case filters
125-
if (filterExpression != null && !filterExpression.MatchTestCase(testCase, p => _testMethodFilter.PropertyValueProvider(testCase, p)))
122+
// Filter tests based on test case filters.
123+
if (filter is not null && !filter.Matches(testElement))
126124
{
127125
continue;
128126
}
129127

130-
discoverySink.SendTestCase(testCase);
128+
discoverySink.SendTestCase(testElement.ToTestCase());
131129
}
132130
}
133131
}

src/Adapter/MSTestAdapter.PlatformServices/Execution/TestExecutionManager.Parallelization.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ private async Task ExecuteTestsInSourceAsync(IEnumerable<TestCase> tests, IRunCo
7373
}
7474

7575
// Default test set is filtered tests based on user provided filter criteria
76-
ITestCaseFilterExpression? filterExpression = _testMethodFilter.GetFilterExpression(runContext, adapterMessageLogger, out bool filterHasError);
76+
ITestElementFilter? filter = _testMethodFilter.GetTestElementFilter(runContext, adapterMessageLogger, out bool filterHasError);
7777
if (filterHasError)
7878
{
7979
// Bail out without processing everything else below.
@@ -113,13 +113,19 @@ private async Task ExecuteTestsInSourceAsync(IEnumerable<TestCase> tests, IRunCo
113113

114114
int parallelWorkers = sourceSettings.Workers;
115115
ExecutionScope parallelScope = sourceSettings.Scope;
116-
TestCase[] testsToRun = [.. tests.Where(t => MatchTestFilter(filterExpression, t, _testMethodFilter))];
116+
// Convert each test to its UnitTestElement exactly once and carry the pair through filtering and
117+
// shuffling, so the surviving tests aren't converted a second time when building unitTestElements below.
118+
(TestCase TestCase, UnitTestElement Element)[] testsToRunPairs =
119+
[.. tests
120+
.Select(test => (TestCase: test, Element: test.ToUnitTestElementWithUpdatedSource(source)))
121+
.Where(pair => MatchTestFilter(filter, pair.Element))];
117122
if (_testOrderRandom is { } sourceRandom)
118123
{
119-
Shuffle(sourceRandom, testsToRun);
124+
Shuffle(sourceRandom, testsToRunPairs);
120125
}
121126

122-
UnitTestElement[] unitTestElements = [.. testsToRun.Select(e => e.ToUnitTestElementWithUpdatedSource(source))];
127+
TestCase[] testsToRun = [.. testsToRunPairs.Select(pair => pair.TestCase)];
128+
UnitTestElement[] unitTestElements = [.. testsToRunPairs.Select(pair => pair.Element)];
123129
// Create an instance of a type defined in adapter so that adapter gets loaded in the child app domain
124130
var testRunner = (UnitTestRunner)isolationHost.CreateInstanceForType(
125131
typeof(UnitTestRunner),

src/Adapter/MSTestAdapter.PlatformServices/Execution/TestExecutionManager.Runner.cs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,11 @@ internal void SendTestResults(
4141
}
4242
}
4343

44-
private static bool MatchTestFilter(ITestCaseFilterExpression? filterExpression, TestCase test, TestMethodFilter testMethodFilter)
45-
{
46-
if (filterExpression != null
47-
&& !filterExpression.MatchTestCase(test, p => testMethodFilter.PropertyValueProvider(test, p)))
48-
{
49-
// Skip test if not fitting filter criteria.
50-
return false;
51-
}
52-
53-
return true;
54-
}
44+
// Takes the already-converted UnitTestElement (rather than reconstructing one from the TestCase) so the
45+
// caller can convert each test exactly once and reuse that element both for filtering and downstream.
46+
private static bool MatchTestFilter(ITestElementFilter? filter, UnitTestElement testElement)
47+
// Keep the test when there is no filter or when the element satisfies the filter criteria.
48+
=> filter is null || filter.Matches(testElement);
5549

5650
private async Task ExecuteTestsWithTestRunnerAsync(
5751
IEnumerable<TestCase> tests,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel;
5+
6+
namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface;
7+
8+
/// <summary>
9+
/// Platform-agnostic predicate that decides whether a discovered or executed <see cref="UnitTestElement"/>
10+
/// should be included in the current test run, based on the user-provided test-case filter.
11+
/// </summary>
12+
/// <remarks>
13+
/// This abstraction lets the platform services discovery and execution pipelines apply filtering over the
14+
/// neutral <see cref="UnitTestElement"/> model without taking a dependency on a specific test platform's
15+
/// filter object model (for example the VSTest <c>ITestCaseFilterExpression</c>, <c>TestProperty</c> and
16+
/// <c>IRunContext.GetTestCaseFilter</c> types). The concrete filter is produced at the platform boundary by a
17+
/// wrapper that parses the host's filter (currently <c>TestMethodFilter</c>, which wraps the VSTest filter
18+
/// expression), and is expected to move fully out of the platform services layer in a later phase.
19+
/// A <see langword="null"/> filter means "no filter" and every element is included.
20+
/// </remarks>
21+
internal interface ITestElementFilter
22+
{
23+
/// <summary>
24+
/// Determines whether the given <paramref name="testElement"/> matches the filter and should be included.
25+
/// </summary>
26+
/// <param name="testElement">The test element to evaluate.</param>
27+
/// <returns><see langword="true"/> if the element matches the filter; otherwise, <see langword="false"/>.</returns>
28+
bool Matches(UnitTestElement testElement);
29+
}

src/Adapter/MSTestAdapter.PlatformServices/TestMethodFilter.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

4+
using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel;
45
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;
56
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface;
67
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
@@ -30,7 +31,7 @@ internal sealed class TestMethodFilter
3031
/// Returns ITestCaseFilterExpression for TestProperties supported by adapter.
3132
/// </summary>
3233
/// <param name="context">The current context of the run.</param>
33-
/// <param name="logger">Handler to report test messages/start/end and results.</param>
34+
/// <param name="logger">Logger used to report diagnostic messages (for example, filter parse errors).</param>
3435
/// <param name="filterHasError">Indicates that the filter is unsupported/has an error.</param>
3536
/// <returns>A filter expression.</returns>
3637
internal ITestCaseFilterExpression? GetFilterExpression(IDiscoveryContext? context, IAdapterMessageLogger logger, out bool filterHasError)
@@ -57,6 +58,24 @@ internal sealed class TestMethodFilter
5758
return filter;
5859
}
5960

61+
/// <summary>
62+
/// Builds a platform-agnostic <see cref="ITestElementFilter"/> for the properties supported by the adapter.
63+
/// </summary>
64+
/// <param name="context">The current context of the run.</param>
65+
/// <param name="logger">Logger used to report diagnostic messages (for example, filter parse errors).</param>
66+
/// <param name="filterHasError">Indicates that the filter is unsupported/has an error.</param>
67+
/// <returns>
68+
/// A filter that evaluates <see cref="UnitTestElement"/> instances, or <see langword="null"/> when no
69+
/// filter applies (in which case every element is included).
70+
/// </returns>
71+
internal ITestElementFilter? GetTestElementFilter(IDiscoveryContext? context, IAdapterMessageLogger logger, out bool filterHasError)
72+
{
73+
ITestCaseFilterExpression? filterExpression = GetFilterExpression(context, logger, out filterHasError);
74+
return filterExpression is null
75+
? null
76+
: new TestElementFilter(this, filterExpression);
77+
}
78+
6079
/// <summary>
6180
/// Provides TestProperty for property name 'propertyName' as used in filter.
6281
/// </summary>
@@ -139,4 +158,27 @@ internal TestProperty PropertyProvider(string propertyName)
139158

140159
return null;
141160
}
161+
162+
/// <summary>
163+
/// Platform-agnostic filter that evaluates a <see cref="UnitTestElement"/> against a VSTest
164+
/// <see cref="ITestCaseFilterExpression"/>. This is the single point where the neutral element model is
165+
/// translated to the VSTest test case in order to reuse the host's filter matching.
166+
/// </summary>
167+
private sealed class TestElementFilter : ITestElementFilter
168+
{
169+
private readonly TestMethodFilter _testMethodFilter;
170+
private readonly ITestCaseFilterExpression _filterExpression;
171+
172+
public TestElementFilter(TestMethodFilter testMethodFilter, ITestCaseFilterExpression filterExpression)
173+
{
174+
_testMethodFilter = testMethodFilter;
175+
_filterExpression = filterExpression;
176+
}
177+
178+
public bool Matches(UnitTestElement testElement)
179+
{
180+
var testCase = testElement.ToTestCase();
181+
return _filterExpression.MatchTestCase(testCase, propertyName => _testMethodFilter.PropertyValueProvider(testCase, propertyName));
182+
}
183+
}
142184
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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.Collections.Immutable;
5+
6+
using Microsoft.MSTestV2.CLIAutomation;
7+
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
8+
9+
using TestResult = Microsoft.VisualStudio.TestPlatform.ObjectModel.TestResult;
10+
11+
namespace MSTest.IntegrationTests;
12+
13+
/// <summary>
14+
/// Regression guard for the platform-agnostic filtering refactor (neutral <c>ITestElementFilter</c>).
15+
/// Filtering is now expressed over the neutral <c>UnitTestElement</c>: discovery matches
16+
/// <c>element.ToTestCase()</c>, and execution matches
17+
/// <c>incomingTestCase.ToUnitTestElementWithUpdatedSource(source).ToTestCase()</c>. These tests lock the
18+
/// end-to-end discover-then-run behavior for <c>FullyQualifiedName</c> and <c>Id</c> filters — the two
19+
/// properties most sensitive to the <c>TestCase</c> ↔ <c>UnitTestElement</c> round-trip that the refactor
20+
/// re-routes — so a future change cannot silently break <c>--filter</c>.
21+
/// </summary>
22+
[TestClass]
23+
public class TestCaseFilteringTests : CLITestBase
24+
{
25+
private const string TestAsset = "DiscoverInternalsProject";
26+
27+
// A non-parameterized test with a stable, filter-safe fully qualified name (no filter operator chars).
28+
private const string TargetDisplayName = "TopLevelInternalClass_TestMethod1";
29+
30+
[TestMethod]
31+
public void FilterByFullyQualifiedNameSelectsExactlyTheMatchingTestDuringDiscovery()
32+
{
33+
string assemblyPath = GetAssetFullPath(TestAsset);
34+
TestCase target = GetTargetTestCase(assemblyPath);
35+
36+
ImmutableArray<TestCase> filtered = DiscoverTests(assemblyPath, $"FullyQualifiedName={target.FullyQualifiedName}");
37+
38+
Assert.HasCount(1, filtered);
39+
Assert.AreEqual(target.FullyQualifiedName, filtered[0].FullyQualifiedName);
40+
Assert.AreEqual(target.Id, filtered[0].Id);
41+
}
42+
43+
[TestMethod]
44+
public void FilterByIdSelectsExactlyTheMatchingTestDuringDiscovery()
45+
{
46+
string assemblyPath = GetAssetFullPath(TestAsset);
47+
TestCase target = GetTargetTestCase(assemblyPath);
48+
49+
ImmutableArray<TestCase> filtered = DiscoverTests(assemblyPath, $"Id={target.Id}");
50+
51+
Assert.HasCount(1, filtered);
52+
Assert.AreEqual(target.FullyQualifiedName, filtered[0].FullyQualifiedName);
53+
Assert.AreEqual(target.Id, filtered[0].Id);
54+
}
55+
56+
[TestMethod]
57+
public async Task FilterByFullyQualifiedNameSelectsExactlyTheMatchingTestDuringExecution()
58+
{
59+
string assemblyPath = GetAssetFullPath(TestAsset);
60+
ImmutableArray<TestCase> allTests = DiscoverTests(assemblyPath);
61+
TestCase target = allTests.First(t => t.DisplayName == TargetDisplayName);
62+
63+
SimulateCrossProcessTestCases(allTests);
64+
65+
ImmutableArray<TestResult> results = await RunTestsAsync(allTests, $"FullyQualifiedName={target.FullyQualifiedName}");
66+
67+
// HasCount(1) is the load-bearing assertion: it fails if the filter is ignored (all tests run) or if
68+
// the FQN no longer matches after reconstruction (no test runs). The outcome check proves the target
69+
// was actually executed via the reconstructed element, not merely selected.
70+
Assert.HasCount(1, results);
71+
Assert.AreEqual(target.FullyQualifiedName, results[0].TestCase.FullyQualifiedName);
72+
Assert.AreEqual(TestOutcome.Passed, results[0].Outcome);
73+
}
74+
75+
[TestMethod]
76+
public async Task FilterByIdSelectsExactlyTheMatchingTestDuringExecution()
77+
{
78+
string assemblyPath = GetAssetFullPath(TestAsset);
79+
ImmutableArray<TestCase> allTests = DiscoverTests(assemblyPath);
80+
Guid targetId = allTests.First(t => t.DisplayName == TargetDisplayName).Id;
81+
82+
SimulateCrossProcessTestCases(allTests);
83+
84+
ImmutableArray<TestResult> results = await RunTestsAsync(allTests, $"Id={targetId}");
85+
86+
// HasCount(1) is the load-bearing assertion: filtering by Id after the TestCase -> UnitTestElement ->
87+
// TestCase reconstruction must still recompute the same Id and select exactly the target. The outcome
88+
// check proves the target was actually executed via the reconstructed element.
89+
Assert.HasCount(1, results);
90+
Assert.AreEqual(targetId, results[0].TestCase.Id);
91+
Assert.AreEqual(TestOutcome.Passed, results[0].Outcome);
92+
}
93+
94+
private static TestCase GetTargetTestCase(string assemblyPath)
95+
=> DiscoverTests(assemblyPath).First(t => t.DisplayName == TargetDisplayName);
96+
97+
// Clears LocalExtensionData so the execution pipeline rebuilds the UnitTestElement (and its regenerated
98+
// TestCase/Id) from the test-case properties, mirroring the out-of-proc VSTest path where deserialized
99+
// test cases carry no LocalExtensionData. This is the round-trip the filtering refactor must preserve.
100+
private static void SimulateCrossProcessTestCases(ImmutableArray<TestCase> testCases)
101+
{
102+
foreach (TestCase testCase in testCases)
103+
{
104+
testCase.LocalExtensionData = null;
105+
}
106+
}
107+
}

test/IntegrationTests/MSTest.IntegrationTests/Utilities/CLITestBase.discovery.cs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ internal static async Task<ImmutableArray<TestResult>> RunTestsAsync(IEnumerable
4141
return frameworkHandle.GetFlattenedTestResults();
4242
}
4343

44+
internal static async Task<ImmutableArray<TestResult>> RunTestsAsync(IEnumerable<TestCase> testCases, string? testCaseFilter)
45+
{
46+
var testExecutionManager = new TestExecutionManager();
47+
var frameworkHandle = new InternalFrameworkHandle();
48+
49+
string runSettingsXml = GetRunSettingsXml(string.Empty);
50+
var runContext = new InternalRunContext(runSettingsXml, testCaseFilter);
51+
52+
await testExecutionManager.ExecuteTestsAsync(testCases, runContext, frameworkHandle, false);
53+
return frameworkHandle.GetFlattenedTestResults();
54+
}
55+
4456
#region Helper classes
4557
private class InternalLogger : IMessageLogger
4658
{
@@ -73,15 +85,46 @@ public InternalDiscoveryContext(string runSettings, string? testCaseFilter)
7385
public IRunSettings? RunSettings { get; }
7486

7587
public ITestCaseFilterExpression? GetTestCaseFilter(IEnumerable<string> supportedProperties, Func<string, TestProperty> propertyProvider) => _filter;
88+
}
7689

77-
private class InternalRunSettings : IRunSettings
78-
{
79-
public InternalRunSettings(string runSettings) => SettingsXml = runSettings;
90+
private sealed class InternalRunContext : IRunContext
91+
{
92+
private readonly ITestCaseFilterExpression? _filter;
8093

81-
public string SettingsXml { get; }
94+
public InternalRunContext(string runSettings, string? testCaseFilter)
95+
{
96+
RunSettings = new InternalRunSettings(runSettings);
8297

83-
public ISettingsProvider? GetSettings(string? settingsName) => throw new NotImplementedException();
98+
if (testCaseFilter != null)
99+
{
100+
_filter = TestCaseFilterFactory.ParseTestFilter(testCaseFilter);
101+
}
84102
}
103+
104+
public IRunSettings? RunSettings { get; }
105+
106+
public bool KeepAlive => false;
107+
108+
public bool InIsolation => false;
109+
110+
public bool IsDataCollectionEnabled => false;
111+
112+
public bool IsBeingDebugged => false;
113+
114+
public string? TestRunDirectory => null;
115+
116+
public string? SolutionDirectory => null;
117+
118+
public ITestCaseFilterExpression? GetTestCaseFilter(IEnumerable<string>? supportedProperties, Func<string, TestProperty?> propertyProvider) => _filter;
119+
}
120+
121+
private sealed class InternalRunSettings : IRunSettings
122+
{
123+
public InternalRunSettings(string runSettings) => SettingsXml = runSettings;
124+
125+
public string SettingsXml { get; }
126+
127+
public ISettingsProvider? GetSettings(string? settingsName) => throw new NotImplementedException();
85128
}
86129

87130
private sealed class InternalFrameworkHandle : IFrameworkHandle

0 commit comments

Comments
 (0)