Skip to content

Commit 4fd64bc

Browse files
EvangelinkCopilot
andauthored
Add C++/CLI + MSTest acceptance tests (VSTest and MTP) (#9509)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 436d9b1 commit 4fd64bc

3 files changed

Lines changed: 590 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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.Acceptance.IntegrationTests;
5+
using Microsoft.Testing.Platform.Acceptance.IntegrationTests.Helpers;
6+
using Microsoft.Testing.Platform.Helpers;
7+
8+
namespace MSTest.Acceptance.IntegrationTests;
9+
10+
/// <summary>
11+
/// Guards that MSTest test classes authored in a <b>C++/CLI managed assembly</b> can be hosted on
12+
/// <b>Microsoft.Testing.Platform (MTP)</b> as a self-contained test executable (no <c>vstest.console.exe</c>).
13+
/// A C++/CLI <c>Application</c> with a managed <c>main</c> calls the same MTP hosting API the generated C#
14+
/// entry point uses (<c>TestApplication.CreateBuilderAsync</c> / <c>AddMSTest</c> / <c>BuildAsync</c> /
15+
/// <c>RunAsync</c>), and the test asserts the produced exe discovers and runs the tests.
16+
/// </summary>
17+
/// <remarks>
18+
/// C++/CLI <c>.vcxproj</c> projects do not resolve NuGet assets, so the MTP + MSTest .NET Framework runtime
19+
/// closure is harvested by building a tiny C# <c>net472</c> <c>EnableMSTestRunner</c> project (which lets NuGet
20+
/// resolve the full dependency closure into its output), then deployed next to the C++/CLI exe.
21+
/// </remarks>
22+
[TestClass]
23+
[OSCondition(OperatingSystems.Windows, IgnoreMessage = "C++/CLI requires the MSVC '/clr' toolset and is Windows-only.")]
24+
public sealed class CppCliMtpTests : AcceptanceTestBase<NopAssetFixture>
25+
{
26+
private const string AssetName = "CppCliMtp";
27+
private const string HarvestClosureRelativePath = @"harvest\bin\Release\net472";
28+
29+
public TestContext TestContext { get; set; } = null!;
30+
31+
[TestMethod]
32+
public async Task MSTestTestsInCppCliAssembly_AreHostedAndRun_ByMtp()
33+
{
34+
CancellationToken cancellationToken = TestContext.CancellationToken;
35+
36+
// Classic C++/CLI (.NET Framework '/clr') only needs the MSVC toolset; absent on SDK-only build legs.
37+
string? vsInstallPath = await CppCliTestSupport.TryFindVsInstallWithCppToolsetAsync(cancellationToken);
38+
if (vsInstallPath is null)
39+
{
40+
Assert.Inconclusive("Skipping: no Visual Studio install with the MSVC C++ toolset (Microsoft.VisualStudio.Component.VC.Tools.x86.x64) was found.");
41+
return;
42+
}
43+
44+
// Derive MSBuild from the same VS install we validated above, so the C++ targets/toolset are
45+
// guaranteed available (locating MSBuild independently could pick a different install on multi-VS machines).
46+
string? msbuildExe = CppCliTestSupport.TryGetMSBuildPathFromVsInstall(vsInstallPath);
47+
if (msbuildExe is null)
48+
{
49+
Assert.Inconclusive($"Skipping: MSBuild.exe was not found under the located Visual Studio install '{vsInstallPath}'.");
50+
return;
51+
}
52+
53+
using TestAsset testAsset = await TestAsset.GenerateAssetAsync(
54+
AssetName,
55+
SourceCode.PatchCodeWithReplace("$MSTestVersion$", MSTestVersion));
56+
57+
Dictionary<string, string?> cleanEnvironment = CppCliTestSupport.BuildEnvironmentWithoutCodeCoverage();
58+
59+
// 1) Harvest the MTP + MSTest net472 runtime closure by building the C# project with the dotnet SDK,
60+
// letting NuGet resolve the full dependency closure into harvest\bin\Release\net472.
61+
DotnetMuxerResult harvestResult = await DotnetCli.RunAsync(
62+
$"build \"{Path.Combine(testAsset.TargetAssetPath, "harvest", "Harvest.csproj")}\" -c Release",
63+
workingDirectory: testAsset.TargetAssetPath,
64+
failIfReturnValueIsNotZero: false,
65+
cancellationToken: cancellationToken);
66+
Assert.AreEqual(0, harvestResult.ExitCode, $"Harvesting the MTP closure failed.{Environment.NewLine}{harvestResult}");
67+
68+
string closureDir = Path.Combine(testAsset.TargetAssetPath, HarvestClosureRelativePath);
69+
Assert.IsTrue(
70+
File.Exists(Path.Combine(closureDir, "Microsoft.Testing.Platform.dll")),
71+
$"Expected the harvested MTP closure under '{closureDir}'.");
72+
73+
// 2) Build the C++/CLI MTP host exe with full Visual Studio MSBuild (the dotnet muxer cannot build a vcxproj).
74+
// The vcxproj references the harvested closure assemblies via <HintPath>.
75+
string vcxproj = Path.Combine(testAsset.TargetAssetPath, $"{AssetName}.vcxproj");
76+
string binlogFile = Path.Combine(TempDirectory.TestSuiteDirectory, $"{nameof(MSTestTestsInCppCliAssembly_AreHostedAndRun_ByMtp)}.binlog");
77+
using var buildCommandLine = new CommandLine();
78+
int buildExitCode = await buildCommandLine.RunAsyncAndReturnExitCodeAsync(
79+
$"\"{msbuildExe}\" \"{vcxproj}\" /p:Configuration=Debug /p:Platform=x64 /restore:false /bl:\"{binlogFile}\"",
80+
cleanEnvironment,
81+
cleanDefaultEnvironmentVariableIfCustomAreProvided: true,
82+
cancellationToken: cancellationToken);
83+
Assert.AreEqual(
84+
0,
85+
buildExitCode,
86+
$"C++/CLI build failed.{Environment.NewLine}{buildCommandLine.StandardOutput}{Environment.NewLine}{buildCommandLine.ErrorOutput}");
87+
88+
string exeDir = Path.Combine(testAsset.TargetAssetPath, "x64", "Debug");
89+
string testExe = Path.Combine(exeDir, $"{AssetName}.exe");
90+
Assert.IsTrue(File.Exists(testExe), $"Built C++/CLI MTP host was not found at '{testExe}'.");
91+
92+
// 3) Deploy the runtime closure (and the binding-redirect app.config) next to the C++/CLI exe.
93+
foreach (string dll in Directory.GetFiles(closureDir, "*.dll"))
94+
{
95+
File.Copy(dll, Path.Combine(exeDir, Path.GetFileName(dll)), overwrite: true);
96+
}
97+
98+
string harvestConfig = Path.Combine(closureDir, "Harvest.exe.config");
99+
if (File.Exists(harvestConfig))
100+
{
101+
File.Copy(harvestConfig, Path.Combine(exeDir, $"{AssetName}.exe.config"), overwrite: true);
102+
}
103+
104+
// 4) Run the C++/CLI exe as an MTP test host and assert discovery + execution.
105+
using var runCommandLine = new CommandLine();
106+
int runExitCode = await runCommandLine.RunAsyncAndReturnExitCodeAsync(
107+
$"\"{testExe}\" --output Detailed",
108+
cleanEnvironment,
109+
workingDirectory: exeDir,
110+
cleanDefaultEnvironmentVariableIfCustomAreProvided: true,
111+
cancellationToken: cancellationToken);
112+
113+
string output = runCommandLine.StandardOutput;
114+
Assert.AreEqual(
115+
0,
116+
runExitCode,
117+
$"MTP run did not succeed.{Environment.NewLine}{output}{Environment.NewLine}{runCommandLine.ErrorOutput}");
118+
119+
Assert.Contains("Add_TwoPlusTwo_IsFour", output);
120+
Assert.Contains("Strings_AreEqual", output);
121+
Assert.Contains("Booleans_Work", output);
122+
// The MTP summary line is emitted by the platform host, proving the exe really hosted MTP.
123+
Assert.Contains("Passed!", output);
124+
}
125+
126+
private const string SourceCode = """
127+
#file harvest/Harvest.csproj
128+
<Project Sdk="Microsoft.NET.Sdk">
129+
130+
<PropertyGroup>
131+
<OutputType>Exe</OutputType>
132+
<TargetFramework>net472</TargetFramework>
133+
<EnableMSTestRunner>true</EnableMSTestRunner>
134+
<GenerateTestingPlatformEntryPoint>true</GenerateTestingPlatformEntryPoint>
135+
<PlatformTarget>x64</PlatformTarget>
136+
<LangVersion>latest</LangVersion>
137+
<!-- This project only exists to let NuGet resolve the MTP + MSTest runtime closure for the C++/CLI host. -->
138+
<NoWarn>$(NoWarn);MSTEST0032</NoWarn>
139+
</PropertyGroup>
140+
141+
<ItemGroup>
142+
<PackageReference Include="MSTest.TestAdapter" Version="$MSTestVersion$" />
143+
<PackageReference Include="MSTest.TestFramework" Version="$MSTestVersion$" />
144+
</ItemGroup>
145+
146+
</Project>
147+
148+
#file harvest/HarvestPlaceholder.cs
149+
using Microsoft.VisualStudio.TestTools.UnitTesting;
150+
151+
[TestClass]
152+
public class HarvestPlaceholder
153+
{
154+
// Present only so the harvester is a valid test project; the C++/CLI exe carries the real tests.
155+
[TestMethod]
156+
public void Placeholder()
157+
{
158+
}
159+
}
160+
161+
#file CppCliMtp.vcxproj
162+
<?xml version="1.0" encoding="utf-8"?>
163+
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
164+
<ItemGroup Label="ProjectConfigurations">
165+
<ProjectConfiguration Include="Debug|x64">
166+
<Configuration>Debug</Configuration>
167+
<Platform>x64</Platform>
168+
</ProjectConfiguration>
169+
</ItemGroup>
170+
<PropertyGroup Label="Globals">
171+
<ProjectGuid>{C1D2E3F4-5A6B-4C7D-8E9F-0A1B2C3D4E5F}</ProjectGuid>
172+
<RootNamespace>CppCliMtp</RootNamespace>
173+
<Keyword>ManagedCProj</Keyword>
174+
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
175+
</PropertyGroup>
176+
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
177+
<PropertyGroup Label="Configuration">
178+
<ConfigurationType>Application</ConfigurationType>
179+
<UseDebugLibraries>true</UseDebugLibraries>
180+
<PlatformToolset>$(DefaultPlatformToolset)</PlatformToolset>
181+
<CLRSupport>true</CLRSupport>
182+
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
183+
<CharacterSet>Unicode</CharacterSet>
184+
</PropertyGroup>
185+
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
186+
<PropertyGroup>
187+
<ClosureDir>$(MSBuildThisFileDirectory)harvest\bin\Release\net472</ClosureDir>
188+
</PropertyGroup>
189+
<ItemDefinitionGroup>
190+
<ClCompile>
191+
<WarningLevel>Level3</WarningLevel>
192+
<Optimization>Disabled</Optimization>
193+
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
194+
</ClCompile>
195+
<Link>
196+
<SubSystem>Console</SubSystem>
197+
</Link>
198+
</ItemDefinitionGroup>
199+
<ItemGroup>
200+
<ClCompile Include="MtpHost.cpp" />
201+
</ItemGroup>
202+
<ItemGroup>
203+
<Reference Include="Microsoft.Testing.Platform">
204+
<HintPath>$(ClosureDir)\Microsoft.Testing.Platform.dll</HintPath>
205+
</Reference>
206+
<Reference Include="MSTest.TestAdapter">
207+
<HintPath>$(ClosureDir)\MSTest.TestAdapter.dll</HintPath>
208+
</Reference>
209+
<Reference Include="MSTest.TestFramework">
210+
<HintPath>$(ClosureDir)\MSTest.TestFramework.dll</HintPath>
211+
</Reference>
212+
<Reference Include="MSTest.TestFramework.Extensions">
213+
<HintPath>$(ClosureDir)\MSTest.TestFramework.Extensions.dll</HintPath>
214+
</Reference>
215+
</ItemGroup>
216+
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
217+
</Project>
218+
219+
#file MtpHost.cpp
220+
using namespace System;
221+
using namespace System::Collections::Generic;
222+
using namespace System::Reflection;
223+
using namespace Microsoft::VisualStudio::TestTools::UnitTesting;
224+
using namespace Microsoft::Testing::Platform::Builder;
225+
226+
[TestClass]
227+
public ref class MtpCalculatorTests
228+
{
229+
public:
230+
[TestMethod]
231+
void Add_TwoPlusTwo_IsFour() { Assert::AreEqual(4, 2 + 2); }
232+
233+
[TestMethod]
234+
void Strings_AreEqual() { Assert::AreEqual(gcnew String("Hello"), gcnew String("Hello")); }
235+
236+
[TestMethod]
237+
void Booleans_Work() { Assert::IsTrue(1 == 1); Assert::IsFalse(1 == 2); }
238+
};
239+
240+
ref class AssemblyProvider
241+
{
242+
public:
243+
static IEnumerable<Assembly^>^ Get()
244+
{
245+
return gcnew array<Assembly^>{ Assembly::GetExecutingAssembly() };
246+
}
247+
};
248+
249+
// Managed entry point hosting MTP. C++/CLI has no 'await', so Tasks are driven via ->Result.
250+
int main(array<String^>^ args)
251+
{
252+
ITestApplicationBuilder^ builder = TestApplication::CreateBuilderAsync(args)->Result;
253+
254+
// AddMSTest is a C# extension method; call it as a static method from C++/CLI.
255+
Func<IEnumerable<Assembly^>^>^ getAssemblies = gcnew Func<IEnumerable<Assembly^>^>(&AssemblyProvider::Get);
256+
TestApplicationBuilderExtensions::AddMSTest(builder, getAssemblies);
257+
258+
ITestApplication^ app = builder->BuildAsync()->Result;
259+
try
260+
{
261+
return app->RunAsync()->Result;
262+
}
263+
finally
264+
{
265+
delete app;
266+
}
267+
}
268+
""";
269+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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;
5+
6+
namespace MSTest.Acceptance.IntegrationTests;
7+
8+
/// <summary>
9+
/// Shared helpers for the C++/CLI acceptance tests (<see cref="CppCliVSTestTests"/> and
10+
/// <see cref="CppCliMtpTests"/>): locating a Visual Studio install with the MSVC toolset and building a
11+
/// child-process environment without the code-coverage profiler variables.
12+
/// </summary>
13+
internal static class CppCliTestSupport
14+
{
15+
// The code-coverage profiler environment variables the acceptance host injects; inheriting them into a
16+
// nested .NET Framework test host breaks the run, so they are stripped from the child process environment.
17+
// IDE0028 (collection expression) is suppressed: a collection expression cannot carry the comparer.
18+
#pragma warning disable IDE0028 // Simplify collection initialization
19+
private static readonly HashSet<string> CodeCoverageEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase)
20+
{
21+
"MicrosoftInstrumentationEngine_ConfigPath32_VanguardInstrumentationProfiler",
22+
"MicrosoftInstrumentationEngine_ConfigPath64_VanguardInstrumentationProfiler",
23+
"CORECLR_PROFILER_PATH_32",
24+
"CORECLR_PROFILER_PATH_64",
25+
"CORECLR_ENABLE_PROFILING",
26+
"CORECLR_PROFILER",
27+
"COR_PROFILER_PATH_32",
28+
"COR_PROFILER_PATH_64",
29+
"COR_ENABLE_PROFILING",
30+
"COR_PROFILER",
31+
"CODE_COVERAGE_SESSION_NAME",
32+
"CODE_COVERAGE_PIPE_PATH",
33+
"MicrosoftInstrumentationEngine_LogLevel",
34+
"MicrosoftInstrumentationEngine_DisableCodeSignatureValidation",
35+
"MicrosoftInstrumentationEngine_FileLogPath",
36+
};
37+
#pragma warning restore IDE0028 // Simplify collection initialization
38+
39+
/// <summary>
40+
/// Returns the installation path of a Visual Studio install that has the MSVC C++ toolset (needed to
41+
/// compile C++/CLI with classic <c>/clr</c>), or <see langword="null"/> when none is found. Classic
42+
/// C++/CLI targets .NET Framework and only requires the toolset; the dedicated C++/CLI-support component
43+
/// is for <c>/clr:netcore</c>.
44+
/// </summary>
45+
public static async Task<string?> TryFindVsInstallWithCppToolsetAsync(CancellationToken cancellationToken)
46+
{
47+
string vswherePath = Path.Combine(
48+
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
49+
"Microsoft Visual Studio",
50+
"Installer",
51+
"vswhere.exe");
52+
if (!File.Exists(vswherePath))
53+
{
54+
return null;
55+
}
56+
57+
using var commandLine = new CommandLine();
58+
int exitCode = await commandLine.RunAsyncAndReturnExitCodeAsync(
59+
$"\"{vswherePath}\" -latest -prerelease -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath",
60+
cancellationToken: cancellationToken);
61+
if (exitCode != 0)
62+
{
63+
return null;
64+
}
65+
66+
string installPath = commandLine.StandardOutput.Trim();
67+
return string.IsNullOrEmpty(installPath) ? null : installPath;
68+
}
69+
70+
/// <summary>
71+
/// Returns the path to <c>MSBuild.exe</c> inside the given Visual Studio install. Deriving MSBuild from
72+
/// the same install that was validated to contain the VC toolset (rather than locating it independently)
73+
/// guarantees the C++ targets/toolset are available when building the <c>.vcxproj</c> on machines with
74+
/// multiple Visual Studio instances. Returns <see langword="null"/> when not found.
75+
/// </summary>
76+
public static string? TryGetMSBuildPathFromVsInstall(string vsInstallPath)
77+
{
78+
string msbuildPath = Path.Combine(vsInstallPath, "MSBuild", "Current", "Bin", "MSBuild.exe");
79+
return File.Exists(msbuildPath) ? msbuildPath : null;
80+
}
81+
82+
/// <summary>
83+
/// Builds a copy of the current process environment with the code-coverage profiler variables removed,
84+
/// for use as a child-process environment (pass with <c>cleanDefaultEnvironmentVariableIfCustomAreProvided: true</c>).
85+
/// </summary>
86+
public static Dictionary<string, string?> BuildEnvironmentWithoutCodeCoverage()
87+
{
88+
var environmentVariables = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
89+
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
90+
{
91+
string key = (string)entry.Key;
92+
if (CodeCoverageEnvironmentVariables.Contains(key))
93+
{
94+
continue;
95+
}
96+
97+
environmentVariables[key] = entry.Value?.ToString();
98+
}
99+
100+
return environmentVariables;
101+
}
102+
}

0 commit comments

Comments
 (0)