-
Couldn't load subscription status.
- Fork 1.9k
UITests
To run tests on iOS or Catalyst, you need a Mac. To run tests on Windows, you need a Windows machine. Android tests can be run from either platform.
The Controls project contains the following structure:
Controls
├── tests
│ ├── Controls.TestCases.HostApp (MAUI Sample app for automated UI tests)
│ ├── Controls.TestCases.Shared.Tests (MAUI library defining UI tests)
│ ├── Controls.TestCases.Android.Tests (Android-specific tests)
│ ├── Controls.TestCases.iOS.Tests (iOS-specific tests)
│ ├── Controls.TestCases.Mac.Tests (Mac-specific tests)
│ └── Controls.TestCases.WinUI.Tests (WinUI-specific tests)
Each platform has a specific UI tests library project where platform-specific tests can be added.
- Run
dotnet tool restoreon the repository home directory - Enable Developer Mode in Windows Settings
- Install LTS version of Node.js from https://nodejs.org
- Install Windows App Driver from https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1
- Verify
%USERPROFILE%\AppData\Roaming\npmis in your PATH (may require machine restart) - Run:
dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="true" -bl:"./artifacts/logs/provision-appium.binlog"Note: Ignore "apkanalyzer.bat could NOT be found" error from appium-doctor
Run dotnet tool restore on the repository home directory, then choose one of the following options:
Option 1: Direct Node.js Installation
- Install LTS version of Node.js from https://nodejs.org
- Run:
dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="true" -bl:"./artifacts/logs/provision-appium.binlog"
Option 2: Using Homebrew
- Run:
brew install node - Run:
dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="true" -bl:"./artifacts/logs/provision-appium.binlog"
Configure the ANDROID_HOME and JAVA_HOME environment variables so that Appium and android.cake can find them. Refer to https://developer.android.com/tools/variables for instructions. The Development guide at https://github.com/dotnet/maui/blob/main/.github/DEVELOPMENT.md contains instructions for setting these variables on Mac.
The following principles form the foundation of MAUI UI testing:
- Inherit from _IssuesUITest for issue-specific tests
- Use descriptive test and method names
- Always include AutomationId on testable elements
- Use appropriate wait strategies with WaitForElement
- Leverage VerifyScreenshot() for visual validation
- Add proper test categories
- Write maintainable, readable test code
HostApp/Issues/: Contains the actual issue reproduction page Shared.Tests/Tests/Issues/: Contains the UI test class Platform.Tests/snapshots/: Contains expected screenshot results
Every test requires the following attributes:
[Issue(IssueTracker.Github, issueNumber, "description", PlatformAffected)]
[Test]
[Category(UITestCategories.ControlName)]Host app pages should inherit from ContentPage or TestContentPage and must include the [Issue] attribute with tracker, number, description, and platform information. Use meaningful AutomationId properties on interactive elements. Keep the UI simple and focused on the specific issue being tested. For TestContentPage implementations, override the Init() method.
[Issue(IssueTracker.Github, 12345, "Button click issue", PlatformAffected.All)]
public class Issue12345 : TestContentPage
{
protected override void Init()
{
Content = new StackLayout
{
Children =
{
new Button
{
Text = "Click Me",
AutomationId = "TestButton"
}
}
};
}
}Test classes must inherit from _IssuesUITest and include a constructor with TestDevice parameter passed to the base class. Override the Issue property to provide a descriptive string. Mark test methods with the [Test] attribute and include appropriate [Category] attributes.
Constructor: Pass testDevice to base class Issue property: Return descriptive string identifying the issue Test methods: Focus on specific scenarios
public class Issue12345 : _IssuesUITest
{
public Issue12345(TestDevice testDevice) : base(testDevice) { }
public override string Issue => "Button click issue";
[Test]
[Category(UITestCategories.Button)]
public void ButtonClickTriggersExpectedBehavior()
{
App.WaitForElement("TestButton");
App.Tap("TestButton");
VerifyScreenshot();
}
}Host App: Issue{IssueNumber}.cs (e.g., Issue12345.cs) Test Class: Issue{IssueNumber}.cs (same name, different folder) Screenshot: {TestMethodName}.png
Both the host app class and test class should use Issue{IssueNumber} format, matching the filename.
Use descriptive, action-based names that clearly indicate what the test validates.
Good Examples: ButtonClickTriggersNavigation() CollectionViewDisplaysItems() EntryValidatesEmail() ImageRespectsAspectRatio() ModalPageShowsCorrectContent()
Bad Examples: Test1() ButtonTest() TestIssue() DoTest()
Use clear, descriptive identifiers that indicate the purpose of the element.
Good Examples: "LoginButton" "EmailEntry" "ProductList" "CloseModalButton" "TestImage"
Bad Examples: "btn1" "test" "element" "item"
Use descriptive names that explain purpose. Avoid abbreviations unless universally understood. Use PascalCase for constants and camelCase for local variables.
const string LoginButtonId = "LoginButton";
var emailEntryText = "[email protected]";
var productCollection = App.WaitForElement("ProductList");Use for GitHub issue reproductions. Provides App instance and common functionality. Handles platform-specific setup automatically.
Simpler than ContentPage for test scenarios. Override Init() instead of constructor. Better for test isolation.
Use when you need full page lifecycle. Provides more control over initialization timing.
_IssuesUITest handles platform detection, provides App property, manages common setup and teardown, and includes screenshot utilities.
TestContentPage provides simplified page creation with Init() override point, test-focused lifecycle, and better isolation.
// Correct pattern: always pass TestDevice to base
public Issue12345(TestDevice testDevice) : base(testDevice) { }
// Required property override
public override string Issue => "Descriptive issue title";
// Standard test method pattern
[Test]
[Category(UITestCategories.ControlName)]
public void TestMethodName()
{
// Test implementation
}VerifyScreenshot() is the most reliable cross-platform validation method. It catches visual regressions and works effectively for layout, styling, and content validation.
App.WaitForElement("TestElement");
VerifyScreenshot();App.WaitForElement("ElementId"); // Implicit assertionvar element = App.WaitForElement("Button");
Assert.IsTrue(element.IsEnabled());var label = App.WaitForElement("StatusLabel");
var text = label.GetText();
Assert.AreEqual("Expected Text", text);Do:
- Use
VerifyScreenshot()as primary validation. - Wait for elements before asserting.
- Use descriptive assertion messages.
- Test one concept per test method.
- Validate both positive and negative cases.
Don't:
- Assert immediately without waiting.
- Use
Thread.Sleep()instead of proper waits. - Test multiple unrelated concepts in one test.
- Skip assertions (tests should validate something).
- Rely solely on element existence without visual validation.
WaitForElement is the most commonly used wait strategy. It repeatedly queries until the element appears or timeout occurs.
App.WaitForElement("ButtonId");
App.WaitForElement("ButtonId", "Button not found", timeout: TimeSpan.FromSeconds(10));Use when verifying an element has disappeared.
App.WaitForNoElement("LoadingSpinner");
App.WaitForNoElement("ErrorMessage", timeout: TimeSpan.FromSeconds(5));Use for complex conditions that can't be expressed as simple element queries.
App.WaitFor(() =>
{
var element = App.Query("Counter").FirstOrDefault();
return element != null && element.Text == "10";
}, "Counter did not reach 10");Repeatedly executes a query until it returns a non-empty value.
App.QueryUntilPresent(() => App.WaitForElement("success"), retryCount: 10, delayInMs: 2000);Do:
- Always wait before interacting with elements.
- Use appropriate timeout values for your scenarios.
- Add descriptive timeout messages.
- Use WaitForElement as the default strategy.
- Chain waits for sequential UI updates.
Don't:
- Use
Thread.Sleep()for timing (it's not reliable). - Wait for fixed durations instead of conditions.
- Ignore timeout exceptions without investigation.
- Use excessively long timeout values.
- Wait unnecessarily when elements are already present.
Pattern 1: Wait Before Interaction
App.WaitForElement("Button");
App.Tap("Button");Pattern 2: Wait After Action
App.Tap("SubmitButton");
App.WaitForElement("SuccessMessage");Pattern 3: Wait for State Change
App.Tap("ToggleButton");
App.WaitFor(() =>
{
var element = App.Query("ToggleButton").FirstOrDefault();
return element?.Text == "On";
});Pattern 4: Complex Sequence
App.WaitForElement("InitialPage");
App.Tap("NavigateButton");
App.WaitForNoElement("InitialPage");
App.WaitForElement("SecondPage");- UITestCategories.Button: Button-related tests
- UITestCategories.CollectionView: CollectionView tests
- UITestCategories.Entry: Entry control tests
- UITestCategories.Label: Label control tests
- UITestCategories.Layout: Layout-related tests
- UITestCategories.Navigation: Navigation tests
- UITestCategories.Performance: Performance-focused tests
- UITestCategories.TabbedPage: TabbedPage tests
- UITestCategories.WebView: WebView tests
Apply categories that match the primary control being tested. For tests involving multiple controls, use the most relevant category. Add multiple categories only when the test explicitly validates multiple control types.
[Test]
[Category(UITestCategories.Button)]
[Category(UITestCategories.Navigation)]
public void ButtonNavigatesToNewPage()
{
// Test implementation
}AutomationId serves as the primary mechanism for identifying UI elements in tests. It provides a stable, platform-independent way to locate elements. Every interactive element that needs to be tested should have an AutomationId.
XAML:
<Button Text="Click Me" AutomationId="TestButton" />
<Entry Placeholder="Email" AutomationId="EmailEntry" />Code:
var button = new Button
{
Text = "Click Me",
AutomationId = "TestButton"
};Do:
- Always set AutomationId on testable elements.
- Use descriptive, meaningful identifiers.
- Keep IDs consistent across test runs.
- Use unique IDs within the same view.
- Document complex ID schemes.
Don't:
- Use generic IDs like "button1" or "test".
- Reuse the same ID for different elements.
- Include spaces or special characters.
- Change IDs between test runs.
- Rely on Text property for identification.
Pattern: Arrange-Act-Assert Structure
[Test]
public void ButtonClickUpdatesLabel()
{
// Arrange
App.WaitForElement("TestButton");
// Act
App.Tap("TestButton");
// Assert
VerifyScreenshot();
}Pattern: Page Object Helpers
private void NavigateToSettings()
{
App.WaitForElement("MenuButton");
App.Tap("MenuButton");
App.WaitForElement("SettingsOption");
App.Tap("SettingsOption");
}
[Test]
public void SettingsPageDisplaysCorrectly()
{
NavigateToSettings();
VerifyScreenshot();
}Pattern: Data-Driven Tests
[TestCase("portrait")]
[TestCase("landscape")]
public void LayoutWorksInOrientation(string orientation)
{
if (orientation == "landscape")
App.SetOrientationLandscape();
else
App.SetOrientationPortrait();
App.WaitForElement("ContentView");
VerifyScreenshot($"Layout_{orientation}");
}Anti-Pattern: Hard-Coded Delays
// Bad
App.Tap("Button");
Thread.Sleep(2000); // Don't do this
VerifyScreenshot();
// Good
App.Tap("Button");
App.WaitForElement("ResultLabel");
VerifyScreenshot();Anti-Pattern: Testing Multiple Concerns
// Bad - tests too many things
[Test]
public void TestEverything()
{
App.Tap("Button1");
VerifyScreenshot();
App.Tap("Button2");
VerifyScreenshot();
App.EnterText("Entry", "text");
VerifyScreenshot();
}
// Good - focused tests
[Test]
public void Button1TriggersExpectedBehavior()
{
App.Tap("Button1");
VerifyScreenshot();
}
[Test]
public void Button2TriggersExpectedBehavior()
{
App.Tap("Button2");
VerifyScreenshot();
}Anti-Pattern: Fragile Element Selection
// Bad - relying on text that might change
App.Tap("Click Me");
// Good - using stable AutomationId
App.Tap("SubmitButton");- Focus on UI responsiveness rather than absolute timing.
- Test with realistic data volumes.
- Measure relative performance between test runs.
- Identify performance regressions early.
[Test]
[Category(UITestCategories.Performance)]
public void LargeDataSetLoadsQuickly()
{
App.WaitForElement("LoadDataButton");
App.Tap("LoadDataButton");
// Verify UI remains responsive
App.WaitForElement("DataList", timeout: TimeSpan.FromSeconds(5));
VerifyScreenshot();
}- Minimize unnecessary waits.
- Reuse app instances when possible.
- Keep test data sets reasonable but realistic.
- Use categories to separate performance tests.
- Profile tests to identify bottlenecks.
Issue: Element Not Found Solution: Verify AutomationId is set correctly. Check if element is actually visible on screen. Increase timeout if element takes time to appear. Use App.Screenshot() to see current state.
Issue: Test Flakiness Solution: Replace Thread.Sleep with proper waits. Increase timeout values if needed. Verify test isolation (tests don't depend on each other). Check for race conditions in app code.
Issue: Screenshot Mismatch Solution: Run test locally to update baseline screenshot. Check for platform-specific rendering differences. Verify test runs in consistent environment. Look for animation timing issues.
Use App.Screenshot("debug") to capture intermediate states. Add Console.WriteLine for test flow visibility. Run tests individually to isolate failures. Check test logs for timeout and exception messages. Verify app is in expected state before assertions.
Tap: Performs a tap or touch gesture on the matched element.
App.Tap("AutomationId");TapCoordinates: Performs a tap or touch gesture on given coordinates.
App.TapCoordinates(100, 100);EnterText: Types text into the matched element.
App.EnterText("EmailEntry", "[email protected]");ClearText: Clears the text from the matched element.
App.ClearText("EmailEntry");DismissKeyboard: Dismisses the keyboard if it is visible.
App.DismissKeyboard();PressEnter: Presses the enter key in the app.
App.PressEnter();WaitForElement: Wait repeatedly until a matching element is found. Throws TimeoutException if not found within time limit.
App.WaitForElement("AutomationId");WaitForNoElement: Wait repeatedly until a matching element is no longer found. Throws TimeoutException if element is still visible at end of time limit.
App.WaitForNoElement("Selected: Item 1");WaitFor: Generic wait function that repeatedly calls the predicate function until it returns true.
App.WaitFor(() => condition, "Timed out waiting...");QueryUntilPresent: Repeatedly executes a query until it returns a non-empty value or the specified retry count is reached.
App.QueryUntilPresent(() => App.WaitForElement("success"), retryCount: 10, delayInMs: 2000);QueryUntilNotPresent: Repeatedly executes a query until it returns a null value or the specified retry count is reached.
App.QueryUntilNotPresent(() => condition, retryCount: 10, delayInMs: 2000);LongPress: Performs a long mouse click on the matched element.
App.LongPress("AutomationId");TouchAndHold: Performs a continuous touch gesture on the matched element.
App.TouchAndHold("AutomationId");TouchAndHoldCoordinates: Performs a continuous touch gesture on given coordinates.
App.TouchAndHoldCoordinates(100, 100);SwipeLeftToRight: Performs a left to right swipe gesture on the matching element.
App.SwipeLeftToRight();SwipeRightToLeft: Performs a right to left swipe gesture on the matching element.
App.SwipeRightToLeft();Pan: Performs a pan gesture between 2 points.
App.Pan(0, 0, 100, 100);ScrollDown: Scrolls down on the first element matching query.
App.ScrollDown("CollectionView", ScrollStrategy.Gesture, 0.5);ScrollUp: Scrolls up on the first element matching query.
App.ScrollUp("CollectionView", ScrollStrategy.Gesture, 0.5);ScrollTo: Scroll until an element that matches the toMarked is shown on the screen.
App.ScrollTo("Item10", true);PinchToZoomIn: Performs a pinch gesture on the matched element to zoom the view in.
App.PinchToZoomIn("AutomationId");PinchToZoomInCoordinates: Performs a pinch gesture to zoom the view in on given coordinates.
App.PinchToZoomInCoordinates(100, 100);PinchToZoomOut: Performs a pinch gesture on the matched element to zoom the view out.
App.PinchToZoomOut("AutomationId");PinchToZoomOutCoordinates: Performs a pinch gesture to zoom the view out on given coordinates.
App.PinchToZoomOutCoordinates(100, 100);SetSliderValue: Sets the value of a slider element that matches marked.
App.SetSliderValue("AutomationId", 4);IncreaseStepper: Increases the value of a Stepper control.
App.IncreaseStepper("AutomationId");DecreaseStepper: Decreases the value of a Stepper control.
App.DecreaseStepper("AutomationId");SetOrientationLandscape: Changes the device orientation to landscape mode.
App.SetOrientationLandscape();SetOrientationPortrait: Changes the device orientation to portrait mode.
App.SetOrientationPortrait();Lock: Lock the screen. Only available on Android and iOS.
App.Lock();Unlock: Unlock the screen. Only available on Android and iOS.
App.Unlock();PressVolumeDown: Presses the volume down button on the device.
App.PressVolumeDown();PressVolumeUp: Presses the volume up button on the device.
App.PressVolumeUp();Shake: Simulate the device shaking. Only available on iOS.
App.Shake();SetLightMode: Sets light device theme.
App.SetLightMode();SetDarkMode: Sets dark device theme.
App.SetDarkMode();Screenshot: Takes a screenshot of the app in its current state.
App.Screenshot("Sample");VerifyScreenshot: Takes a screenshot and compares it pixel by pixel with a reference one using the specified name or the test name.
VerifyScreenshot();StartRecording: Start recording screen. Only available on Android, iOS, and Windows.
App.StartRecording();StopRecording: Stop recording screen. Only available on Android, iOS, and Windows.
App.StopRecording();ToggleWifi: Switch the state of the wifi service. Only available on Android.
App.ToggleWifi();ToggleAirplane: Toggle airplane mode on device. Only available on Android.
App.ToggleAirplane();using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml;
namespace Microsoft.Maui.TestCases.Tests.Issues;
[Issue(IssueTracker.Github, 12345, "Button click does not work", PlatformAffected.All)]
public class Issue12345 : TestContentPage
{
protected override void Init()
{
Content = new StackLayout
{
Children =
{
new Label
{
Text = "Test Label",
AutomationId = "TestLabel"
},
new Button
{
Text = "Click Me",
AutomationId = "TestButton",
Clicked = (s, e) =>
{
((Label)((StackLayout)Content).Children[0]).Text = "Button Clicked!";
}
}
}
};
}
}using NUnit.Framework;
using UITest.Appium;
using UITest.Core;
namespace Microsoft.Maui.TestCases.Tests.Issues;
public class Issue12345 : _IssuesUITest
{
public Issue12345(TestDevice testDevice) : base(testDevice) { }
public override string Issue => "Button click does not work";
[Test]
[Category(UITestCategories.Button)]
public void ButtonClickUpdatesLabel()
{
App.WaitForElement("TestButton");
App.Tap("TestButton");
VerifyScreenshot();
}
}using System.Collections.ObjectModel;
using Microsoft.Maui.Controls;
namespace Microsoft.Maui.TestCases.Tests.Issues;
[Issue(IssueTracker.Github, 54321, "CollectionView selection issues", PlatformAffected.All)]
public class Issue54321 : ContentPage
{
public ObservableCollection<TestItem> Items { get; set; }
public Issue54321()
{
Items = new ObservableCollection<TestItem>
{
new TestItem { Name = "Item 1" },
new TestItem { Name = "Item 2" },
new TestItem { Name = "Item 3" }
};
var collectionView = new CollectionView
{
AutomationId = "TestCollectionView",
SelectionMode = SelectionMode.Multiple,
ItemsSource = Items,
ItemTemplate = new DataTemplate(() =>
{
var grid = new Grid
{
ColumnDefinitions =
{
new ColumnDefinition { Width = GridLength.Star },
new ColumnDefinition { Width = GridLength.Auto }
}
};
var label = new Label();
label.SetBinding(Label.TextProperty, "Name");
Grid.SetColumn(label, 0);
var button = new Button
{
Text = "Action",
AutomationId = "ItemActionButton"
};
Grid.SetColumn(button, 1);
grid.Children.Add(label);
grid.Children.Add(button);
return grid;
})
};
var refreshView = new RefreshView
{
AutomationId = "RefreshView",
Content = collectionView
};
Content = new StackLayout
{
Children =
{
new Label
{
Text = "Complex Test Scenario",
AutomationId = "HeaderLabel"
},
new Button
{
Text = "Add Item",
AutomationId = "AddItemButton",
Command = new Command(() =>
{
Items.Add(new TestItem { Name = $"Item {Items.Count + 1}" });
})
},
new Button
{
Text = "Clear Selection",
AutomationId = "ClearSelectionButton",
Command = new Command(() =>
{
collectionView.SelectedItems?.Clear();
})
},
refreshView
}
};
}
public class TestItem
{
public string Name { get; set; }
public bool IsSelected { get; set; }
}
}using NUnit.Framework;
using UITest.Appium;
using UITest.Core;
namespace Microsoft.Maui.TestCases.Tests.Issues;
public class Issue54321 : _IssuesUITest
{
public Issue54321(TestDevice testDevice) : base(testDevice) { }
public override string Issue => "CollectionView with complex interactions";
[Test]
[Category(UITestCategories.CollectionView)]
public void CollectionViewDisplaysItemsCorrectly()
{
App.WaitForElement("TestCollectionView");
App.WaitForElement("HeaderLabel");
VerifyScreenshot();
}
[Test]
[Category(UITestCategories.CollectionView)]
public void AddItemIncreasesCollectionCount()
{
App.WaitForElement("AddItemButton");
App.Tap("AddItemButton");
App.Tap("AddItemButton");
VerifyScreenshot();
}
[Test]
[Category(UITestCategories.CollectionView)]
public void SelectionCanBeCleared()
{
App.WaitForElement("TestCollectionView");
SelectMultipleItems();
App.Tap("ClearSelectionButton");
VerifyScreenshot();
}
[Test]
[Category(UITestCategories.CollectionView)]
public void RefreshViewWorksCorrectly()
{
App.WaitForElement("RefreshView");
App.SwipeDown("RefreshView");
App.WaitForElement("TestCollectionView");
VerifyScreenshot();
}
[Test]
[Category(UITestCategories.Button)]
public void ItemActionButtonsAreClickable()
{
App.WaitForElement("ItemActionButton");
App.Tap("ItemActionButton");
VerifyScreenshot();
}
private void SelectMultipleItems()
{
App.WaitForElement("TestCollectionView");
var testDevice = App.GetTestDevice();
if (testDevice == TestDevice.Android)
{
App.LongPress("Item 1");
App.Tap("Item 2");
}
else
{
App.Tap("Item 1");
App.Tap("Item 2");
}
}
[Test]
[Category(UITestCategories.CollectionView)]
[TestCase("portrait")]
[TestCase("landscape")]
public void CollectionViewWorksInBothOrientations(string orientation)
{
App.WaitForElement("TestCollectionView");
if (orientation == "landscape")
App.SetOrientationLandscape();
else
App.SetOrientationPortrait();
App.WaitForElement("TestCollectionView");
VerifyScreenshot($"CollectionView_{orientation}");
}
[Test]
[Category(UITestCategories.Performance)]
public void LargeDataSetPerformance()
{
App.WaitForElement("AddItemButton");
for (int i = 0; i < 20; i++)
{
App.Tap("AddItemButton");
}
App.WaitForElement("TestCollectionView");
VerifyScreenshot();
}
}Handle platform differences explicitly in tests.
private void HandlePlatformSpecificBehavior()
{
var testDevice = App.GetTestDevice();
switch (testDevice)
{
case TestDevice.Android:
// Android-specific interaction
break;
case TestDevice.iOS:
// iOS-specific interaction
break;
}
}Create different expected screenshots per scenario for parameterized tests.
VerifyScreenshot($"CollectionView_{orientation}");Try starting appium from the command line to get a better error message:
node /usr/local/lib/node_modules/appium/build/lib/main.js
This will provide detailed information about what might be preventing Appium from starting correctly.