dotnet-uno-testing
Testing Uno Platform applications across target heads (WASM, Desktop, Mobile). Covers Playwright-based browser automation for Uno WASM apps, platform-specific testing patterns for different runtime heads, and test infrastructure for cross-platform Uno projects.
Version assumptions: .NET 8.0+ baseline, Uno Platform 5.x+, Playwright 1.40+ for WASM testing. Uno Platform uses single-project structure with multiple target frameworks.
Out of scope: Shared UI testing patterns (page object model, selectors, wait strategies) are in [skill:dotnet-ui-testing-core]. Playwright fundamentals (installation, CI caching, trace viewer) are covered by [skill:dotnet-playwright]. Test project scaffolding is owned by [skill:dotnet-add-testing].
Prerequisites: Uno Platform application with WASM head configured. For WASM testing: Playwright browsers installed (see [skill:dotnet-playwright]). For mobile testing: platform SDKs configured (Android SDK, Xcode).
Cross-references: [skill:dotnet-ui-testing-core] for page object model and selector strategies, [skill:dotnet-playwright] for Playwright installation, CI caching, and trace viewer, [skill:dotnet-uno-platform] for Uno Extensions, MVUX, Toolkit, and theme guidance, [skill:dotnet-uno-targets] for per-target deployment and platform-specific gotchas.
Uno Testing Strategy by Head
Uno Platform apps run on multiple heads (WASM, Desktop/Skia, iOS, Android, Windows). Each head has different testing tools and trade-offs.
| Head | Testing Approach | Tool | Speed | Fidelity |
|---|---|---|---|---|
| WASM | Browser automation | Playwright | Medium | High -- real browser rendering |
| Desktop (Skia/GTK, WPF) | UI automation | Appium / WinAppDriver | Medium | High -- real desktop rendering |
| iOS | Simulator automation | Appium + XCUITest | Slow | Highest -- real iOS rendering |
| Android | Emulator automation | Appium + UIAutomator2 | Slow | Highest -- real Android rendering |
| Unit (shared logic) | In-memory | xUnit (no UI) | Fast | N/A -- logic only |
Recommended priority: Test shared business logic with unit tests first. Use Playwright against the WASM head for UI verification -- it is the fastest UI testing path with the broadest coverage. Add platform-specific Appium tests only for behaviors that differ between heads.
Playwright for Uno WASM
The WASM head renders Uno apps in a browser, making Playwright the natural choice for UI testing.
Test Infrastructure
// NuGet: Microsoft.Playwright
public class UnoWasmFixture : IAsyncLifetime
{
public IPlaywright Playwright { get; private set; } = null!;
public IBrowser Browser { get; private set; } = null!;
public IPage Page { get; private set; } = null!;
private Process? _serverProcess;
public async ValueTask InitializeAsync()
{
// Start the WASM app (dotnet run or serve the published output)
_serverProcess = await StartWasmServerAsync();
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
Page = await Browser.NewPageAsync();
// Wait for Uno WASM app to fully load
await Page.GotoAsync("http://localhost:5000");
await WaitForUnoAppReadyAsync();
}
public async ValueTask DisposeAsync()
{
await Page.CloseAsync();
await Browser.CloseAsync();
Playwright.Dispose();
_serverProcess?.Kill(entireProcessTree: true);
_serverProcess?.Dispose();
}
private async Task WaitForUnoAppReadyAsync()
{
// Uno WASM apps show a loading splash; wait for the app root to appear
await Page.WaitForSelectorAsync(
"[data-testid='app-root'], #uno-body",
new() { State = WaitForSelectorState.Visible, Timeout = 30_000 });
// Additional wait for Uno runtime initialization
await Page.WaitForFunctionAsync(
"() => document.querySelector('#uno-body')?.children.length > 0",
null,
new() { Timeout = 15_000 });
}
private static async Task<Process> StartWasmServerAsync()
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "run --project src/MyApp/MyApp.Wasm.csproj --no-build",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
process.Start();
// Wait for server to be ready by probing the health endpoint
using var httpClient = new HttpClient();
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
try
{
var resp = await httpClient.GetAsync("http://localhost:5000");
if (resp.IsSuccessStatusCode) break;
}
catch (HttpRequestException)
{
// Server not ready yet
}
await Task.Delay(500);
}
return process;
}
}
WASM UI Tests
public class UnoNavigationTests : IClassFixture<UnoWasmFixture>
{
private readonly IPage _page;
public UnoNavigationTests(UnoWasmFixture fixture)
{
_page = fixture.Page;
}
[Fact]
public async Task MainPage_LoadsSuccessfully()
{
// Uno WASM renders XAML controls as HTML elements
// Use AutomationProperties.AutomationId for selectors
var title = _page.Locator("[data-testid='main-title']");
await Expect(title).ToBeVisibleAsync();
await Expect(title).ToHaveTextAsync("Welcome");
}
[Fact]
public async Task Navigation_ClickSettings_ShowsSettingsPage()
{
await _page.ClickAsync("[data-testid='nav-settings']");
var settingsHeader = _page.Locator("[data-testid='settings-header']");
await Expect(settingsHeader).ToBeVisibleAsync();
await Expect(settingsHeader).ToHaveTextAsync("Settings");
}
}
Form Interaction Tests
[Fact]
public async Task LoginForm_SubmitValid_NavigatesToDashboard()
{
await _page.FillAsync("[data-testid='username-input']", "testuser");
await _page.FillAsync("[data-testid='password-input']", "P@ssw0rd!");
await _page.ClickAsync("[data-testid='login-button']");
// Wait for navigation after login
var dashboard = _page.Locator("[data-testid='dashboard-title']");
await Expect(dashboard).ToBeVisibleAsync(
new() { Timeout = 10_000 });
}
[Fact]
public async Task TodoList_AddItem_AppearsInList()
{
await _page.FillAsync("[data-testid='todo-input']", "Buy groceries");
await _page.ClickAsync("[data-testid='add-todo-btn']");
var items = _page.Locator("[data-testid='todo-item']");
await Expect(items).ToHaveCountAsync(1);
await Expect(items.First).ToContainTextAsync("Buy groceries");
}
Platform-Specific Testing
AutomationProperties for Cross-Platform Selectors
Uno maps AutomationProperties.AutomationId to each platform's native identifier:
<!-- Uno XAML -- works across all heads -->
<Page x:Class="MyApp.Views.LoginPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBox AutomationProperties.AutomationId="username-input"
PlaceholderText="Username" />
<PasswordBox AutomationProperties.AutomationId="password-input"
PlaceholderText="Password" />
<Button AutomationProperties.AutomationId="login-button"
Content="Log In" />
<TextBlock AutomationProperties.AutomationId="error-message"
Foreground="Red" />
</StackPanel>
</Page>
Platform mapping:
- WASM: Rendered as
data-testidattribute (Playwright selector) - Android: Maps to
content-desc(AppiumAccessibilityId) - iOS: Maps to
accessibilityIdentifier(AppiumAccessibilityId) - Windows: Maps to
AutomationId(WinAppDriverAccessibilityId)
Testing Platform-Specific Code
For code that varies by platform, use conditional compilation and separate test classes:
// Shared test -- runs on all platforms
[Fact]
public async Task Settings_ChangeTheme_UpdatesUI()
{
await _page.ClickAsync("[data-testid='theme-toggle']");
var body = _page.Locator("[data-testid='app-root']");
await Expect(body).ToHaveAttributeAsync("data-theme", "dark");
}
// Platform-specific test
[Fact]
[Trait("Platform", "WASM")]
public async Task FileUpload_BrowserDialog_AcceptsFiles()
{
// WASM uses browser file picker -- test with Playwright file chooser
var fileChooserTask = _page.WaitForFileChooserAsync();
await _page.ClickAsync("[data-testid='upload-btn']");
var fileChooser = await fileChooserTask;
await fileChooser.SetFilesAsync("testdata/sample.pdf");
var fileName = _page.Locator("[data-testid='file-name']");
await Expect(fileName).ToHaveTextAsync("sample.pdf");
}
Runtime Head Validation
Validate that the same UI logic works correctly across different Uno runtime heads using shared test logic with platform-specific drivers.
Shared Test Logic Pattern
/// <summary>
/// Abstract base that defines UI tests once. Concrete subclasses provide
/// the driver for each platform (Playwright for WASM, Appium for mobile).
/// </summary>
public abstract class LoginTestsBase
{
protected abstract Task FillFieldAsync(string automationId, string value);
protected abstract Task ClickAsync(string automationId);
protected abstract Task<string> GetTextAsync(string automationId);
protected abstract Task WaitForElementAsync(string automationId, int timeoutMs = 5000);
[Fact]
public async Task Login_ValidCredentials_ShowsDashboard()
{
await FillFieldAsync("username-input", "alice");
await FillFieldAsync("password-input", "P@ssw0rd!");
await ClickAsync("login-button");
await WaitForElementAsync("dashboard-title");
var title = await GetTextAsync("dashboard-title");
Assert.Equal("Dashboard", title);
}
[Fact]
public async Task Login_EmptyPassword_ShowsValidationError()
{
await FillFieldAsync("username-input", "alice");
await ClickAsync("login-button");
await WaitForElementAsync("error-message");
var error = await GetTextAsync("error-message");
Assert.Contains("required", error, StringComparison.OrdinalIgnoreCase);
}
}
// WASM implementation
public class LoginTestsWasm : LoginTestsBase, IClassFixture<UnoWasmFixture>
{
private readonly IPage _page;
public LoginTestsWasm(UnoWasmFixture fixture) => _page = fixture.Page;
protected override async Task FillFieldAsync(string automationId, string value) =>
await _page.FillAsync($"[data-testid='{automationId}']", value);
protected override async Task ClickAsync(string automationId) =>
await _page.ClickAsync($"[data-testid='{automationId}']");
protected override async Task<string> GetTextAsync(string automationId) =>
await _page.Locator($"[data-testid='{automationId}']").TextContentAsync() ?? "";
protected override async Task WaitForElementAsync(string automationId, int timeoutMs = 5000) =>
await _page.WaitForSelectorAsync(
$"[data-testid='{automationId}']",
new() { Timeout = timeoutMs });
}
Key Principles
- Test shared logic with unit tests first. Uno's MVVM pattern means most business logic is testable without any UI framework.
- Use Playwright + WASM as the primary UI testing path. It is faster than mobile emulators and provides real rendering fidelity in a browser.
- Use
AutomationProperties.AutomationIdon all testable controls. It is the only selector strategy that works identically across all Uno heads. - Separate shared tests from platform-specific tests. Use abstract base classes for shared test logic, concrete subclasses per platform.
- Add platform-specific tests only for platform-divergent behavior. File pickers, hardware buttons, gestures, and notifications differ across platforms; test these separately.
Agent Gotchas
- Do not assume Uno WASM apps load instantly. The Uno runtime must initialize (mono WASM bootstrap), which takes several seconds. Always wait for the app root element before interacting.
- Do not use CSS selectors for Uno WASM elements. Uno generates its own DOM structure; CSS classes are internal and unstable. Use
data-testid(fromAutomationProperties.AutomationId) exclusively. - Do not forget to build the WASM head before running Playwright tests.
dotnet runbuilds on demand, butdotnet publishis needed for production-like testing. Stale builds cause confusing test failures. - Do not test mobile-specific features in the WASM head. File system access, push notifications, biometrics, and NFC are not available in the browser. Skip or mock these in WASM tests.
- Do not run all platform tests in a single CI job. Each platform requires its own SDK (Android SDK, Xcode, WinAppDriver). Use separate CI jobs per platform with appropriate runners.