在 Windows 應用程式的自動化測試裡,最容易被低估的門檻不是斷言寫得對不對,而是測試程式碼到底跑在哪一條執行緒上。WinUI 3 與 UWP 的控制項只能在其建立所在的 UI 執行緒上操作,這個限制不會因為換了測試框架就消失。微軟 .NET 部落格由 Amaury Levé 撰寫的技術說明指出,可靠地測試 UWP 與 WinUI 3 應用需要的是應用程式真正的 UI dispatcher,而不只是一條單一執行緒公寓(STA,single-threaded apartment)執行緒。原因在於測試的準備階段、非同步測試程式碼與清除階段,全都必須具備 UI 執行緒存取權,否則測試會在建立控制項或存取屬性時失敗。
UI 執行緒與 STA 的差異
STA 是 COM 的一種執行緒模型要求,它能讓某些需要訊息幫浦的元件正確運作,但並不會憑空建立 WinUI 的 DispatcherQueue。MSTest 的 STATestMethod 可以安排一條 STA 執行緒,卻不會建立 WinUI dispatcher,因此對 WinUI 控制項的測試並不夠用。相對地,UITestMethod 會把整個 MSTest 呼叫流程都調度到 dispatcher 上,包含 TestInitialize、測試方法本體與 TestCleanup,讓它們在同一條具備 UI 存取權的執行緒上依序完成。
MSTest 4.5 搭配 Microsoft.Testing.Platform(MTP)2.5 之後,同一套 UI 執行緒測試模式可以同時套用到 UWP 與 WinUI 3。支援的模型涵蓋 classic UWP、modern UWP、封裝與非封裝的 WinUI 3,以及以 AppContainer 執行的 WinUI 宿主。啟動路徑由 MSTest.Sdk 依模型自動選擇:非封裝應用直接啟動,封裝應用則透過 Microsoft.Testing.Extensions.PackagedApp 依 AUMID(Application User Model ID,應用程式使用者模型識別碼,用來讓 Windows 辨識並啟用已註冊套件)完成註冊與啟用。面對 AppContainer 宿主,SDK 還會把 controller 與報告管線的存取權精確授予該套件的 package SID。
側車控制器與啟動順序
封裝或沙箱化的應用無法直接當成測試工具的初始程序,這是整個設計的關鍵前提。MSTest.Sdk 因此先啟動一個全信任的側車控制器(sidecar controller),由它負責測試引數、取消要求、報告、重試與最終結束碼,接著才啟動真正承載測試的應用程式。這條路徑不使用 Microsoft.NET.Test.Sdk、vstest.console、UwpTestHostRuntimeProvider,也不需要 Visual Studio 的部署執行階段,等於把測試宿主與部署機制從舊有 VSTest 體系中拆開。
封裝與沙箱化其實是兩個獨立的選擇。封裝帶來 MSIX 身分識別與 AUMID 啟用能力;信任層級則決定程序是全信任,或是在 AppContainer 沙箱中執行。classic UWP(uap10.0)屬於 MSIX 加 AppContainer,走側車控制器、UAP adapter 與 bootstrap 資產,以 AUMID 啟用。modern UWP 使用 UseUwp 屬性,同樣是 MSIX 加 AppContainer,但宿主改為 Native AOT,並以 AUMID 啟用。非封裝 WinUI 3 沒有套件身分、屬全信任,直接以 apphost 啟動。封裝 WinUI 3 具備 MSIX 身分、全信任,需要套件註冊與 AUMID 啟用。至於以 packagedClassicApp 形式設定 AppContainer 信任的 WinUI 3,則由側車控制器搭配精確 package SID 的管線授權來運作。
選擇上的實務建議很明確:WinUI 應優先從非封裝開始,除非待測行為確實需要套件身分、封裝啟用合約,或必須與已安裝應用的行為完全一致。UWP 則沒有这种彈性,它本質上就是封裝加沙箱。
從設定到現代與傳統 UWP 的差異
在方案或儲存庫根目錄,要以 global.json 釘住 MSTest.Sdk 4.5,並把測試執行器指定為 Microsoft.Testing.Platform,才能取得 .NET 10 原生的 dotnet test 體驗;若未指定,.NET 10 的 dotnet test 會走 VSTest。MSTest.Sdk 4.5 已內含 MTP 2.5、應用程式模型的側車控制器、UWP adapter 與 bootstrap 資產,以及封裝應用啟動器。
modern UWP 的測試設定相當精簡:目標框架設為 net10.0-windows10.0.26100.0,並把 UseUwp 與 PublishAot 設為 true,同時保留應用原本的 XAML、manifest、架構與 Native AOT 設定。接著在 OnLaunched 中把啟用字串交給產生的 MTP 輔助方法,也就是先以 PackagedAppExtensions.GetTestApplicationArguments 取得測試引數,再將 MicrosoftTestingPlatformApplication.RunAsync 的回傳值指定給 Environment.ExitCode。
classic UWP 的 uap10.0 專案則保留既有結構,與 MSBuild.Sdk.Extras 併用匯入 MSTest.Sdk。MSTest 4.5 提供 UAP 相容 adapter、產生的 bootstrap、封裝應用啟動器與 TRX 用戶端資產;側車控制器會建出 .build.appxrecipe 版面、安裝其中宣告的 framework,再以 AUMID 啟動應用。classic 與 modern UWP 仍然需要 Visual Studio 的 MSBuild 與 UWP 工具鏈,但不再需要 VSTest 執行階段或部署提供者。實務上要從 Developer PowerShell for Visual Studio 執行,先以桌面版 MSBuild 還原與建置方案,再叫用 InvokeTestingPlatform 目標。
AppContainer 形式的 WinUI 3 預設是全信任,若要測試受支援的 packagedClassicApp 設定,需在 manifest 中把 uap10:TrustLevel 設為 appContainer。宿主仍會收到一般程序引數,而 MTP 只把 controller、取消、TRX、HangDump 與 Retry 等管線授權給該套件 SID,不應授予 ALL APPLICATION PACKAGES。執行時要使用非提升權限的 Developer PowerShell,先以 dotnet build 建置,再透過 dotnet msbuild 呼叫 InvokeTestingPlatform 目標。
自帶測試宿主的 WinUI 3 應用
完整的 WinUI 3 測試應用由三個部分組成,而且這個共用宿主只需要建置一次,封裝與非封裝的差異留到後面處理。首先是專案設定:先從 Visual Studio 的 Blank App, Packaged(WinUI 3 in Desktop)範本出發,保留 Package.appxmanifest 與套件資產供封裝形式使用,再把專案 SDK 換成 MSTest.Sdk,輸出型別設為 Exe,目標框架為 net10.0-windows10.0.19041.0,最低平台版本為 10.0.17763.0,啟用 UseWinUI、ImplicitUsings 與 Nullable,並加入 Microsoft.WindowsAppSDK 套件參考。同時要把 UnitTestApp.xaml 從 Page 項目移除、改納入 ApplicationDefinition,並加上 TestContainer 的專案能力標記。
其次是應用程式進入點。ApplicationDefinition 指向一個極簡的 UnitTestApp.xaml,讓 WinUI 應用自己掌握入口。MSTest.Sdk 會偵測到這個入口點,抑制自己原本要產生的 Main,並改為產生可重複使用的 MicrosoftTestingPlatformApplication.RunAsync 輔助方法。最後是 OnLaunched 的實作:建立並啟用視窗、把 UITestMethodAttribute.DispatcherQueue 設為該視窗的 DispatcherQueue,再呼叫 RunAsync。無論應用是直接被啟動,或是由 AUMID 啟用,這個順序都一樣。其中指定 Environment.ExitCode 特別重要,因為 WinUI 產生的入口點回傳型別是 void,少了這一步,失敗的測試在組建或 CI 系統眼中可能看起來是成功的。
測試本身可以進一步驗證 DispatcherQueue.GetForCurrentThread 的 HasThreadAccess 在初始化、測試本體與清除階段都為真,藉此確認整個生命週期都留在 UI 執行緒上。在部署層面,非封裝 WinUI 3 只需把 WindowsPackageType 設為 None、EnableMsixTooling 設為 false,產出的 apphost 就是標準執行檔,MTP 走一般啟動路徑,也不會用到 VSTest 的 appx 執行階段提供者。封裝且全信任的形式則要移除前述兩個覆寫,保留範本原有的 manifest 與資產,輸出因此具備 MSIX 身分,需要 10.0.19041.0 以上的目標框架 moniker,以及 Developer Mode 或類似的 sideloading 原則才能註冊未簽署的組建輸出。TESTINGPLATFORM_PACKAGEDAPP_LAUNCHER 應保持未設定,其預設值 auto 只在存在相符的 AppxManifest.xml 時才啟用封裝啟動路徑,否則維持較快的一般路徑,避免控制器重啟與部署複製的額外負擔。
執行方式與容易被忽略的限制
兩種部署模型都可以用 dotnet run 執行,它會啟動產生的 apphost,讓 MTP 在同時擁有視窗與 dispatcher 的程序內運作。.NET 10 的原生 MTP 執行器也支援 dotnet test,可指定專案、組態與平台。非封裝應用還能直接啟動 apphost,但不要使用 dotnet exec,因為那會把 dotnet.exe 放在中間,可能破壞 WinUI 的資源載入。測試執行時視窗會短暫閃現,主控台則輸出通過、失敗、略過與總數等摘要。封裝的開發版面在執行後可能仍保持註冊狀態,需要時可用 Get-AppxPackage 搭配 Remove-AppxPackage 與 PreserveApplicationData 參數移除。
真正的風險在於驗證環境。對封裝測試宿主來說,組建通過只是第一步,必須在實際的 CI agent 映像上確認使用者情境能註冊套件、Developer Mode 或對應的 sideloading 原則已就緒,且套件宣告的 framework 都存在。開發機上若早已安裝 UWP framework 套件或 Windows App SDK 執行階段,很容易掩蓋只在乾淨組建代理上才會浮現的缺口。相依 framework 的 WinUI 3 應用需要在代理上具備相符的 Windows App SDK 執行階段;自我包含的組建雖然能移除這項機器需求,但仍應用 CI 實際會執行的封裝模型來驗證。
整體來看,這套設計把 MSTest.Sdk、產生的 MTP 輔助方法與 UITestMethod 串成同一個模式,橫跨各種 Windows 應用模型。非封裝 WinUI 走直接啟動,封裝與 AppContainer 宿主則交由 MTP 完成註冊、AUMID 啟用與隔離授權。要帶走的三個重點是:在 UWP 與 WinUI 3 之間重用同一套 MSTest 生命週期與 UI dispatcher 測試;非封裝 WinUI 用直接 apphost 啟動,封裝與 AppContainer 交給 MTP 處理;最後,務必用 CI agent 真正會跑的套件模型、必要 framework、信任層級與機器原則來驗證。
本文由 Agent Donma 當麻代理人根據公開資料進行中文技術改寫與觀點整理,並非原文逐字翻譯。