docs/UITesting-Guide.md
Comprehensive guidance for creating automated UI tests for .NET MAUI using Appium and NUnit. This document works in conjunction with the main Copilot Instructions.
Two-part requirement for any UI test:
src/Controls/tests/TestCases.HostApp/Issues/IssueXXXXX.xamlsrc/Controls/tests/TestCases.Shared.Tests/Tests/Issues/IssueXXXXX.csAppium Package: All UI tests use [email protected] (latest stable version)
# Restore tools (required)
dotnet tool restore
# Install Node.js LTS from https://nodejs.org
# Provision Appium
dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="true"
%USERPROFILE%\AppData\Roaming\npm is in PATHANDROID_HOME environment variableJAVA_HOME environment variableFile: src/Controls/tests/TestCases.HostApp/Issues/IssueXXXXX.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
Title="Issue XXXXX - Issue Title">
<VerticalStackLayout Padding="20" Spacing="10">
<!-- CRITICAL: Every interactive element needs AutomationId -->
<Label Text="Instructions or description"
AutomationId="InstructionLabel"/>
<Button Text="Click Me"
AutomationId="ClickButton"
Clicked="OnButtonClicked"/>
<Label Text="Result will appear here"
AutomationId="ResultLabel"
IsVisible="False"/>
</VerticalStackLayout>
</ContentPage>
File: src/Controls/tests/TestCases.HostApp/Issues/IssueXXXXX.xaml.cs
namespace Maui.Controls.Sample.Issues;
[Issue(IssueTracker.Github, XXXXX, "Issue description", PlatformAffected.All)]
public partial class IssueXXXXX : ContentPage
{
public IssueXXXXX()
{
InitializeComponent();
}
private void OnButtonClicked(object sender, EventArgs e)
{
ResultLabel.IsVisible = true;
ResultLabel.Text = "Expected behavior verified";
}
}
File: src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/IssueXXXXX.cs
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;
namespace Microsoft.Maui.TestCases.Tests.Issues;
public class IssueXXXXX : _IssuesUITest
{
public IssueXXXXX(TestDevice device) : base(device)
{
}
public override string Issue => "Clear description of what the issue is testing";
[Test]
[Category(UITestCategories.Button)] // Use most appropriate category
public void TestMethodName()
{
// Arrange - Setup initial state
App.WaitForElement("ClickButton");
// Act - Perform user interaction
App.Tap("ClickButton");
// Assert - Verify result
App.WaitForElement("ResultLabel");
// Optional: Screenshot verification
VerifyScreenshot();
}
}
// Single tap
App.Tap("ButtonAutomationId");
// Double tap
App.DoubleTap("ElementAutomationId");
// Long press
App.TouchAndHold("ElementAutomationId");
// Text entry
App.EnterText("EntryAutomationId", "Sample text");
App.ClearText("EntryAutomationId");
// Slider
App.SetSliderValue("SliderAutomationId", 0.5);
// Stepper
App.IncreaseStepper("StepperAutomationId");
App.DecreaseStepper("StepperAutomationId");
// Swipe left to right
App.SwipeLeftToRight();
// Swipe right to left
App.SwipeRightToLeft();
// Drag and drop
App.DragAndDrop("SourceId", "TargetId");
// Pinch to zoom
App.PinchToZoomIn("ImageId");
App.PinchToZoomOut("ImageId");
// Tap coordinates
App.TapCoordinates(100, 100);
// Scroll down
App.ScrollDown("CollectionViewId");
// Scroll up
App.ScrollUp("CollectionViewId");
// Scroll to element
App.ScrollTo("TargetElementId", down: true);
// With custom strategy
App.ScrollDown("CollectionViewId",
ScrollStrategy.Gesture,
swipePercentage: 0.5);
// Wait for element
App.WaitForElement("ElementAutomationId",
timeout: TimeSpan.FromSeconds(10));
// Wait until present (with retries)
App.QueryUntilPresent(() => App.WaitForElement("ElementId"));
// Wait until not present
App.QueryUntilNotPresent(() => App.WaitForElement("ElementId"));
// Navigate back
App.Back();
// App background/foreground
App.BackgroundApp();
App.ForegroundApp();
// Keyboard
App.DismissKeyboard();
bool isShown = App.IsKeyboardShown();
App.PressEnter();
// Orientation
App.SetOrientationLandscape();
App.SetOrientationPortrait();
// Theme (Android/iOS)
App.SetLightMode();
App.SetDarkMode();
// Screen recording (Android/iOS/Windows)
App.StartRecording();
// ... perform actions
App.StopRecording();
// Screenshots
App.Screenshot("TestName_Step1");
VerifyScreenshot(); // Automated screenshot comparison
Use the most appropriate category per test:
[Category(UITestCategories.Button)]
[Category(UITestCategories.Label)]
[Category(UITestCategories.Entry)]
[Category(UITestCategories.CollectionView)]
[Category(UITestCategories.ListView)]
[Category(UITestCategories.Navigation)]
[Category(UITestCategories.Layout)]
[Category(UITestCategories.Gestures)]
[Category(UITestCategories.Shell)]
[Category(UITestCategories.Border)]
[Category(UITestCategories.Image)]
[Category(UITestCategories.Slider)]
[Category(UITestCategories.Stepper)]
Apply the category that match the primary control being tested. For tests involving multiple controls, use the most relevant category.
Only ONE category per test method.
Each platform can have unique tests:
Android Tests (src/Controls/tests/TestCases.Android.Tests/IssueXXXXX.cs):
[Test]
[Category(UITestCategories.Android)]
public void AndroidOnlyFeature()
{
App.ToggleWifi();
// Android-specific test logic
}
iOS Tests (src/Controls/tests/TestCases.iOS.Tests/IssueXXXXX.cs):
[Test]
[Category(UITestCategories.iOS)]
public void iOSOnlyFeature()
{
App.Shake();
// iOS-specific test logic
}
For rapid development and debugging, you can run specific tests directly:
Android:
Deploy TestCases.HostApp to Android emulator/device:
# Use local dotnet if available, otherwise use global dotnet
./bin/dotnet/dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-android -t:Run
# OR:
dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-android -t:Run
Run specific test:
dotnet test src/Controls/tests/TestCases.Android.Tests/Controls.TestCases.Android.Tests.csproj --filter "FullyQualifiedName~Issue11311"
iOS (3-step process):
Find iPhone Xs with highest API level:
# Extract UDID of iPhone Xs with highest iOS version
UDID=$(xcrun simctl list devices available --json | jq -r '.devices | to_entries | map(select(.key | startswith("com.apple.CoreSimulator.SimRuntime.iOS"))) | map({key: .key, version: (.key | sub("com.apple.CoreSimulator.SimRuntime.iOS-"; "") | split("-") | map(tonumber)), devices: .value}) | sort_by(.version) | reverse | map(select(.devices | any(.name == "iPhone Xs"))) | first | .devices[] | select(.name == "iPhone Xs") | .udid')
# Verify UDID was found
if [ -z "$UDID" ]; then
echo "ERROR: No iPhone Xs simulator found. Please create an iPhone Xs simulator before running iOS tests."
exit 1
fi
echo "Using iPhone Xs with UDID: $UDID"
Build the iOS app:
# Use local dotnet if available, otherwise use global dotnet
./bin/dotnet/dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-ios
# OR:
dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-ios
Boot simulator and install app (non-blocking):
# Boot the simulator (will error if already booted, which is fine)
xcrun simctl boot $UDID 2>/dev/null || true
# Install the app to the simulator
xcrun simctl install $UDID artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-ios/iossimulator-arm64/Controls.TestCases.HostApp.app
# Verify simulator is booted
xcrun simctl list devices | grep "$UDID"
Run specific test:
dotnet test src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj --filter "FullyQualifiedName~Issue11311"
MacCatalyst:
Deploy TestCases.HostApp to MacCatalyst:
# Use local dotnet if available, otherwise use global dotnet
./bin/dotnet/dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-maccatalyst -t:Run
# OR:
dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -f net10.0-maccatalyst -t:Run
Run specific test:
dotnet test src/Controls/tests/TestCases.Mac.Tests/Controls.TestCases.Mac.Tests.csproj --filter "FullyQualifiedName~Issue11311"
For comprehensive CI-like test runs:
Android:
./build.ps1 -Script eng/devices/android.cake --target=uitest-build
./build.ps1 -Script eng/devices/android.cake --target=uitest
iOS:
./build.ps1 -Script eng/devices/ios.cake --target=uitest-build
./build.ps1 -Script eng/devices/ios.cake --target=uitest
Windows:
./build.ps1 -Script eng/devices/windows.cake --target=uitest-build
./build.ps1 -Script eng/devices/windows.cake --target=uitest
MacCatalyst:
./build.ps1 -Script eng/devices/catalyst.cake --target=uitest-build
./build.ps1 -Script eng/devices/catalyst.cake --target=uitest
# Single category
dotnet cake eng/devices/android.cake --target=uitest --test-filter="TestCategory=Button"
# Multiple categories
dotnet cake eng/devices/android.cake --target=uitest --test-filter="TestCategory=Button|TestCategory=Navigation"
# Specific test name
dotnet cake eng/devices/android.cake --target=uitest --test-filter="FullyQualifiedName~IssueXXXXX"
// BAD - Generic names
<Button AutomationId="Button1"/>
<Label AutomationId="Label2"/>
// GOOD - Descriptive names
<Button AutomationId="SubmitButton"/>
<Label AutomationId="StatusLabel"/>
Do:
Don't:
// BAD - May fail
App.Tap("ButtonId");
// GOOD - Wait first
App.WaitForElement("ButtonId");
App.Tap("ButtonId");
[Test]
public void ResponsiveLayoutTest()
{
App.WaitForElement("MainLayout");
VerifyScreenshot("Portrait");
App.SetOrientationLandscape();
App.WaitForElement("MainLayout");
VerifyScreenshot("Landscape");
}
Do:
VerifyScreenshot() as primary validation.Don't:
Thread.Sleep() instead of proper waits.If you encounter navigation fragment errors or resource ID issues when launching the Android HostApp:
java.lang.IllegalArgumentException: No view found for id 0x7f0800f8 (com.microsoft.maui.uitests:id/inward) for fragment NavigationRootManager_ElementBasedFragment
Solution: Read the crash logs to find the full exception and investigate the root cause:
# Monitor logcat for the full crash details
adb logcat -c # Clear logcat buffer
adb logcat | grep -E "(FATAL|AndroidRuntime|Exception|Error|Crash)"
Debugging Steps:
Find the full exception in logcat - look for the complete stack trace
Investigate the root cause:
0x7f0800f8) actually exist in the APK?If you can't determine the fix, ask for guidance with:
Check Android emulator is running:
adb devices
Before committing UI tests:
AutomationId values[Category] attribute per testusing NUnit.Framework;
using UITest.Appium;
using UITest.Core;
namespace Microsoft.Maui.TestCases.Tests.Issues;
public class Issue22769 : _IssuesUITest
{
public Issue22769(TestDevice device) : base(device)
{
}
public override string Issue => "Background set to Transparent doesn't have the same behavior as BackgroundColor Transparent";
[Test]
[Category(UITestCategories.Navigation)]
public void ModalPageBackgroundShouldBeTransparent()
{
// Wait for initial button
App.WaitForElement("NavigateToModalButton");
// Tap to navigate to modal
App.Tap("NavigateToModalButton");
// Verify modal page loaded
App.WaitForElement("ModalPageLabel");
// Verify transparent background allows seeing underlying content
VerifyScreenshot();
}
[SetUp]
public void Setup()
{
App.SetOrientationPortrait();
}
}
If migrating from Xamarin.UITest:
| Xamarin.UITest | .NET MAUI Appium |
|---|---|
App.Tap(c => c.Marked("Id")) | App.Tap("Id") |
App.WaitForElement(c => c.Marked("Id")) | App.WaitForElement("Id") |
App.ScrollDownTo(...) | App.ScrollTo("ElementId", down: true) |
App.SetOrientation(...) | App.SetOrientationPortrait() |
App.Screenshot(...) | App.Screenshot(...)/VerifyScreenshot() |
Last Updated: October 2025