Add MSTest.DesktopTesting thin-layer for Windows desktop E2E testing#7810
Add MSTest.DesktopTesting thin-layer for Windows desktop E2E testing#7810Evangelink wants to merge 1 commit intomainfrom
Conversation
Introduce a new MSTest.DesktopTesting package that provides base classes (AutomationTest, ApplicationTest, WindowTest) for end-to-end testing of Windows desktop applications. Follows the same pattern as Playwright MSTest integration: a thin lifecycle adapter over System.Windows.Automation. - AutomationTest: hierarchy root (extensible) - ApplicationTest: process launch/teardown via Process.Start - WindowTest: exposes MainWindow as AutomationElement SDK integration: - EnableDesktopTesting MSBuild property in MSTest.Sdk - DesktopTesting.targets feature file (ClassicEngine, VSTest, NativeAOT guard) - Version wired through Sdk.props.template Also includes: - Sample project (ProjectUsingDesktopTesting with Calculator tests) - Acceptance tests for both MTP runner and VSTest modes
There was a problem hiding this comment.
Pull request overview
Adds a new Windows-desktop E2E testing thin layer (MSTest.DesktopTesting) and wires it into MSTest.Sdk behind an <EnableDesktopTesting> feature flag, plus samples and acceptance coverage.
Changes:
- Introduces
Microsoft.MSTest.DesktopTestingbase classes (AutomationTest→ApplicationTest→WindowTest) built onSystem.Windows.Automation. - Adds MSTest.Sdk feature wiring (
DesktopTesting.targetsimport + version plumbing + NativeAOT guard). - Adds a public sample and an acceptance test that exercises the SDK flag in both MSTest runner and VSTest modes.
Show a summary per file
| File | Description |
|---|---|
| test/IntegrationTests/MSTest.Acceptance.IntegrationTests/DesktopTestingSdkTests.cs | Adds acceptance tests that generate a project enabling the DesktopTesting SDK feature. |
| src/TestFramework/TestFramework.DesktopTesting/AutomationTest.cs | Adds hierarchy root base class for desktop automation tests. |
| src/TestFramework/TestFramework.DesktopTesting/ApplicationTest.cs | Adds process lifecycle management for desktop app E2E tests. |
| src/TestFramework/TestFramework.DesktopTesting/WindowTest.cs | Exposes main window as AutomationElement for tests. |
| src/TestFramework/TestFramework.DesktopTesting/GlobalUsings.cs | Adds MSTest global using for the new package. |
| src/TestFramework/TestFramework.DesktopTesting/TestFramework.DesktopTesting.csproj | Defines the new packable Windows-desktop testing project/package. |
| src/TestFramework/TestFramework.DesktopTesting/PublicAPI/PublicAPI.Shipped.txt | Initializes shipped API baseline for the new package. |
| src/TestFramework/TestFramework.DesktopTesting/PublicAPI/PublicAPI.Unshipped.txt | Declares new public API surface for DesktopTesting. |
| src/Package/MSTest.Sdk/Sdk/Features/DesktopTesting.targets | Implements the SDK feature target that injects the DesktopTesting package + implicit using. |
| src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets | Imports DesktopTesting feature targets in VSTest mode. |
| src/Package/MSTest.Sdk/Sdk/Runner/ClassicEngine.targets | Imports DesktopTesting feature targets in ClassicEngine mode. |
| src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets | Adds a NativeAOT validation error for the feature flag. |
| src/Package/MSTest.Sdk/Sdk/Sdk.props.template | Adds EnableDesktopTesting default + version property. |
| src/Package/MSTest.Sdk/MSTest.Sdk.csproj | Plumbs DesktopTesting version into SDK props templating. |
| samples/public/DemoMSTestSdk/ProjectUsingDesktopTesting/ProjectUsingDesktopTesting.csproj | Adds sample project demonstrating the SDK flag. |
| samples/public/DemoMSTestSdk/ProjectUsingDesktopTesting/CalculatorTests.cs | Adds sample Calculator tests using WindowTest + UIA. |
| Directory.Packages.props | Introduces MSTestDesktopTestingVersion and a declared PackageVersion entry. |
Copilot's findings
Comments suppressed due to low confidence (1)
samples/public/DemoMSTestSdk/ProjectUsingDesktopTesting/CalculatorTests.cs:45
- The sample locates buttons by
AutomationElement.NamePropertywith values "One"/"Two". These names are localized in many Windows locales, so the sample can be flaky or fail outside English. Prefer a locale-independent locator (e.g., AutomationId) or note the limitation explicitly in comments.
AutomationElement? button1 = MainWindow.FindFirst(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.NameProperty, "One"));
AutomationElement? button2 = MainWindow.FindFirst(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.NameProperty, "Two"));
- Files reviewed: 17/17 changed files
- Comments generated: 5
| while (AppProcess.MainWindowHandle == IntPtr.Zero && sw.Elapsed < ApplicationStartTimeout) | ||
| { | ||
| AppProcess.Refresh(); | ||
| Thread.Yield(); |
There was a problem hiding this comment.
ApplicationSetup uses a tight loop with Thread.Yield() while waiting for MainWindowHandle to become available. This can spin a core for up to ApplicationStartTimeout and cause noisy CPU usage in test runs. Consider replacing the busy-wait with a small delay (or a WaitForInputIdle/poll delay) while still respecting the timeout.
| Thread.Yield(); | |
| TimeSpan remainingTime = ApplicationStartTimeout - sw.Elapsed; | |
| if (remainingTime > TimeSpan.Zero) | |
| { | |
| Thread.Sleep(remainingTime < TimeSpan.FromMilliseconds(50) | |
| ? remainingTime | |
| : TimeSpan.FromMilliseconds(50)); | |
| } |
| if (AppProcess is not null && !AppProcess.HasExited) | ||
| { | ||
| AppProcess.CloseMainWindow(); | ||
| if (!AppProcess.WaitForExit(5000)) | ||
| { | ||
| AppProcess.Kill(); | ||
| } | ||
| } | ||
|
|
||
| AppProcess?.Dispose(); | ||
| AppProcess = null!; |
There was a problem hiding this comment.
ApplicationTearDown can throw during cleanup due to races (process exits between HasExited check and Kill()/CloseMainWindow()), which would fail the test even if assertions passed. Consider wrapping shutdown/kill in a try/finally and using a safer termination sequence (e.g., handle exceptions, and wait for exit after kill).
| if (AppProcess is not null && !AppProcess.HasExited) | |
| { | |
| AppProcess.CloseMainWindow(); | |
| if (!AppProcess.WaitForExit(5000)) | |
| { | |
| AppProcess.Kill(); | |
| } | |
| } | |
| AppProcess?.Dispose(); | |
| AppProcess = null!; | |
| Process? appProcess = AppProcess; | |
| try | |
| { | |
| if (appProcess is not null && !appProcess.HasExited) | |
| { | |
| try | |
| { | |
| _ = appProcess.CloseMainWindow(); | |
| if (!appProcess.WaitForExit(5000)) | |
| { | |
| appProcess.Kill(); | |
| _ = appProcess.WaitForExit(5000); | |
| } | |
| } | |
| catch (InvalidOperationException) | |
| { | |
| // The process exited between state checks and shutdown operations. | |
| } | |
| } | |
| } | |
| finally | |
| { | |
| appProcess?.Dispose(); | |
| AppProcess = null!; | |
| } |
| <TargetFrameworks Condition=" '$(OS)' == 'Windows_NT' ">net8.0-windows;net9.0-windows</TargetFrameworks> | ||
| <TargetFrameworks Condition=" '$(OS)' != 'Windows_NT' " /> |
There was a problem hiding this comment.
This project sets TargetFrameworks to empty on non-Windows (<TargetFrameworks ... />). MSBuild requires TargetFramework/TargetFrameworks to be non-empty, so evaluating/restoring this project on non-Windows hosts will fail (even if it’s not intended to build there). Consider always setting the Windows TFMs and using EnableWindowsTargeting (or another approach) to allow restore/build on non-Windows CI, or otherwise ensure the project is never evaluated outside Windows builds.
| <TargetFrameworks Condition=" '$(OS)' == 'Windows_NT' ">net8.0-windows;net9.0-windows</TargetFrameworks> | |
| <TargetFrameworks Condition=" '$(OS)' != 'Windows_NT' " /> | |
| <TargetFrameworks>net8.0-windows;net9.0-windows</TargetFrameworks> | |
| <EnableWindowsTargeting Condition=" '$(OS)' != 'Windows_NT' ">true</EnableWindowsTargeting> |
| <ItemGroup> | ||
| <PackageReference Include="MSTest.DesktopTesting" Sdk="MSTest"> | ||
| <Version Condition=" '$(ManagePackageVersionsCentrally)' != 'true' ">$(MSTestDesktopTestingVersion)</Version> | ||
| </PackageReference> | ||
| <PackageVersion Include="MSTest.DesktopTesting" Version="$(MSTestDesktopTestingVersion)" | ||
| Condition=" '$(ManagePackageVersionsCentrally)' == 'true' " /> | ||
| </ItemGroup> |
There was a problem hiding this comment.
DesktopTesting.targets unconditionally adds the MSTest.DesktopTesting package when EnableDesktopTesting=true, even for non-Windows OSes/TFMs. Since the feature is Windows-only, this is likely to produce confusing restore/build errors if enabled on unsupported platforms. Consider adding an explicit MSBuild <Error> (or condition the PackageReference) when the OS/TFM isn’t Windows / -windows to fail fast with a clear message.
| Assert.IsTrue( | ||
| title.Contains("Calculator", StringComparison.OrdinalIgnoreCase), | ||
| $"Expected window title to contain 'Calculator', but was '{title}'."); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Calculator_NumberButtons_AreVisible() | ||
| { | ||
| // Use System.Windows.Automation to locate UI elements | ||
| AutomationElement? button1 = MainWindow.FindFirst( | ||
| TreeScope.Descendants, | ||
| new PropertyCondition(AutomationElement.NameProperty, "One")); | ||
|
|
||
| AutomationElement? button2 = MainWindow.FindFirst( | ||
| TreeScope.Descendants, | ||
| new PropertyCondition(AutomationElement.NameProperty, "Two")); | ||
|
|
||
| Assert.IsNotNull(button1, "Could not find the 'One' button."); | ||
| Assert.IsNotNull(button2, "Could not find the 'Two' button."); |
There was a problem hiding this comment.
This sample asserts the window title contains the English string "Calculator". On non-English Windows installations the Calculator title is localized, so this sample test can fail even when the feature works. Consider using a non-localized UIA property (e.g., AutomationId) or loosening the assertion to something locale-agnostic (or documenting that the sample assumes an English UI).
This issue also appears on line 39 of the same file.
| Assert.IsTrue( | |
| title.Contains("Calculator", StringComparison.OrdinalIgnoreCase), | |
| $"Expected window title to contain 'Calculator', but was '{title}'."); | |
| } | |
| [TestMethod] | |
| public void Calculator_NumberButtons_AreVisible() | |
| { | |
| // Use System.Windows.Automation to locate UI elements | |
| AutomationElement? button1 = MainWindow.FindFirst( | |
| TreeScope.Descendants, | |
| new PropertyCondition(AutomationElement.NameProperty, "One")); | |
| AutomationElement? button2 = MainWindow.FindFirst( | |
| TreeScope.Descendants, | |
| new PropertyCondition(AutomationElement.NameProperty, "Two")); | |
| Assert.IsNotNull(button1, "Could not find the 'One' button."); | |
| Assert.IsNotNull(button2, "Could not find the 'Two' button."); | |
| Assert.IsFalse( | |
| string.IsNullOrWhiteSpace(title), | |
| "Expected the main window to have a non-empty title."); | |
| } | |
| [TestMethod] | |
| public void Calculator_NumberButtons_AreVisible() | |
| { | |
| // Use non-localized AutomationId values to locate UI elements. | |
| AutomationElement? button1 = MainWindow.FindFirst( | |
| TreeScope.Descendants, | |
| new PropertyCondition(AutomationElement.AutomationIdProperty, "num1Button")); | |
| AutomationElement? button2 = MainWindow.FindFirst( | |
| TreeScope.Descendants, | |
| new PropertyCondition(AutomationElement.AutomationIdProperty, "num2Button")); | |
| Assert.IsNotNull(button1, "Could not find the button with AutomationId 'num1Button'."); | |
| Assert.IsNotNull(button2, "Could not find the button with AutomationId 'num2Button'."); |
Summary
Introduces a new
MSTest.DesktopTestingNuGet package that provides base classes for end-to-end testing of Windows desktop applications (WinForms, WPF, Win32). Follows the same thin-layer pattern asMicrosoft.Playwright.MSTest— a lifecycle adapter over the built-inSystem.Windows.AutomationAPI with zero external dependencies.Base class hierarchy
AutomationTest— root of the hierarchy, extension point for future configurationApplicationTest— launches the app viaProcess.Start, waits for main window handle, manages teardown withCloseMainWindow/Kill. Uses[STATestClass]for COM/STA threading. Configurable via virtual properties (ApplicationPath,ApplicationArguments,ApplicationStartTimeout)WindowTest— exposesMainWindowasSystem.Windows.Automation.AutomationElement. Users can layer FlaUI or other libraries on top for richer element interactionSDK integration
Mirrors the Playwright/Aspire SDK pattern exactly:
<EnableDesktopTesting>true</EnableDesktopTesting>MSBuild property in MSTest.SdkDesktopTesting.targetsfeature file wired into ClassicEngine, VSTest, and NativeAOT targetsSdk.props.template<PackageReference>User experience
What's included
src/TestFramework/TestFramework.DesktopTesting/— the package sourcesamples/public/DemoMSTestSdk/ProjectUsingDesktopTesting/— Calculator E2E testDesktopTestingSdkTests.cs— tests both MTP runner and VSTest modes using notepad.exeDesign decisions
System.Windows.Automationover FlaUI: Zero external dependencies. Uses the managed UIA wrapper from the Windows Desktop runtime. Users who want a richer API can layer FlaUI on top.[STATestClass]: Required for COM interop with Windows UI Automation