.NET MAUI UI Testing with Appium and UITest

.NET MAUI UI Testing with Appium and UITest

Unit tests verify your ViewModels and services. But you can't verify that buttons are tappable, navigation transitions work correctly, and the app doesn't crash on real gestures without running on a device. .NET MAUI UI testing with Appium (or the older Xamarin.UITest for Xamarin-to-MAUI migrations) bridges this gap — automating real interactions against your app running on a simulator or physical device.

The State of MAUI UI Testing in 2025

The MAUI UI testing ecosystem is evolving:

  • Appium with the .NET driver (Appium.WebDriver) is the current recommended approach for MAUI UI testing
  • Microsoft.Maui.UITest (preview) is a newer community/Microsoft approach that wraps Appium
  • Xamarin.UITest still works for Xamarin projects migrating to MAUI but isn't MAUI-native
  • Playwright handles web flows (Blazor WASM) but not native MAUI app interactions

This guide focuses on Appium, which is the most mature option with the widest platform support.

Setting Up Appium for MAUI

Prerequisites

# Install Appium
npm install -g appium

<span class="hljs-comment"># Install MAUI driver for iOS/Android
appium driver install xcuitest      <span class="hljs-comment"># iOS
appium driver install uiautomator2  <span class="hljs-comment"># Android

<span class="hljs-comment"># Verify installation
appium driver list --installed

Test Project Setup

<!-- MyApp.UITests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Appium.WebDriver" Version="5.0.0" />
    <PackageReference Include="NUnit" Version="3.14.0" />
    <PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
  </ItemGroup>
</Project>

Writing Your First MAUI UI Test

using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.iOS;
using NUnit.Framework;

[TestFixture]
public class LoginUITest
{
    private IOSDriver? _driver;

    [SetUp]
    public void SetUp()
    {
        var options = new AppiumOptions();
        options.PlatformName = "iOS";
        options.AutomationName = "XCUITest";
        options.DeviceName = "iPhone 15"; // Simulator name
        options.PlatformVersion = "17.0";
        options.App = "/path/to/MyApp.app"; // Built .app bundle
        
        _driver = new IOSDriver(new Uri("http://localhost:4723"), options);
        _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
    }

    [Test]
    public void LoginWithValidCredentials()
    {
        // Find elements by accessibility ID (set AutomationId in XAML)
        var emailField = _driver!.FindElement(MobileBy.AccessibilityId("EmailEntry"));
        var passwordField = _driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"));
        var loginButton = _driver.FindElement(MobileBy.AccessibilityId("LoginButton"));

        emailField.Clear();
        emailField.SendKeys("testuser@example.com");

        passwordField.Clear();
        passwordField.SendKeys("Password123!");

        loginButton.Click();

        // Wait for navigation to dashboard
        var dashboardTitle = _driver.FindElement(MobileBy.AccessibilityId("DashboardTitle"));
        Assert.That(dashboardTitle.Displayed, Is.True);
        Assert.That(dashboardTitle.Text, Is.EqualTo("Dashboard"));
    }

    [TearDown]
    public void TearDown()
    {
        _driver?.Quit();
    }
}

Setting AutomationId in MAUI XAML

For Appium to find elements, set AutomationId in your XAML:

<!-- LoginPage.xaml -->
<ContentPage>
    <VerticalStackLayout>
        <Entry 
            x:Name="EmailEntry"
            AutomationId="EmailEntry"
            Placeholder="Email"
            Keyboard="Email" />
        
        <Entry 
            x:Name="PasswordEntry"
            AutomationId="PasswordEntry"
            Placeholder="Password"
            IsPassword="True" />
        
        <Button 
            Text="Login"
            AutomationId="LoginButton"
            Command="{Binding LoginCommand}" />
        
        <Label 
            AutomationId="ErrorMessage"
            Text="{Binding ErrorMessage}"
            IsVisible="{Binding HasError}" />
    </VerticalStackLayout>
</ContentPage>

Android Testing with UIAutomator2

using OpenQA.Selenium.Appium.Android;

[TestFixture]
public class AndroidLoginTest
{
    private AndroidDriver? _driver;

    [SetUp]
    public void SetUp()
    {
        var options = new AppiumOptions();
        options.PlatformName = "Android";
        options.AutomationName = "UiAutomator2";
        options.DeviceName = "Pixel_7_API_34"; // AVD name or connected device
        options.App = "/path/to/com.myapp.apk";
        
        _driver = new AndroidDriver(new Uri("http://localhost:4723"), options);
        _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
    }

    [Test]
    public void AppLaunchesAndShowsLoginScreen()
    {
        // Verify initial screen
        var emailField = _driver!.FindElement(MobileBy.AccessibilityId("EmailEntry"));
        Assert.That(emailField.Displayed, Is.True);
    }

    [Test]
    public void ValidationErrorDisplayedForEmptyLogin()
    {
        var loginButton = _driver!.FindElement(MobileBy.AccessibilityId("LoginButton"));
        loginButton.Click();

        // Wait for validation message
        var errorMessage = _driver.FindElement(MobileBy.AccessibilityId("ErrorMessage"));
        Assert.That(errorMessage.Displayed, Is.True);
        Assert.That(errorMessage.Text, Does.Contain("required"));
    }
}

Testing Gestures and Scrolling

[Test]
public void ScrollToLoadMoreItems()
{
    // Scroll down to trigger lazy loading
    var scrollView = _driver!.FindElement(MobileBy.AccessibilityId("ProductList"));
    
    // Scroll using W3C Actions
    var actions = new AppiumActions(_driver);
    actions.MoveToElement(scrollView)
           .Pointer(PointerInputDevice.Kind.Touch, "finger")
           .AddPointerMove(CoordinateOrigin.Element, scrollView, 0, 0, TimeSpan.Zero)
           .AddPointerDown(0)
           .AddPointerMove(CoordinateOrigin.Element, scrollView, 0, -300, TimeSpan.FromSeconds(1))
           .AddPointerUp(0)
           .Perform();

    // Wait for new items to load
    Thread.Sleep(2000); // In production, use explicit waits instead

    var items = _driver.FindElements(MobileBy.AccessibilityId("ProductItem"));
    Assert.That(items.Count, Is.GreaterThan(10));
}

[Test]
public void SwipeToDeleteItem()
{
    var firstItem = _driver!.FindElement(MobileBy.AccessibilityId("ListItem_0"));
    
    // Swipe left to reveal delete action
    var size = firstItem.Size;
    var start = new Point(size.Width - 10, size.Height / 2);
    var end = new Point(10, size.Height / 2);
    
    firstItem.SendKeys(""); // Focus the element first
    
    var actions = new AppiumActions(_driver);
    // ... perform swipe gesture
    
    var deleteButton = _driver.FindElement(MobileBy.AccessibilityId("DeleteButton"));
    Assert.That(deleteButton.Displayed, Is.True);
    deleteButton.Click();
    
    // Verify item removed
    Assert.Throws<NoSuchElementException>(() => 
        _driver.FindElement(MobileBy.AccessibilityId("ListItem_0")));
}

Using Explicit Waits

Implicit waits are blunt instruments. Explicit waits are faster and more reliable:

public static IWebElement WaitForElement(AppiumDriver driver, By locator, int timeoutSeconds = 15)
{
    var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(timeoutSeconds));
    return wait.Until(d => {
        try
        {
            var element = d.FindElement(locator);
            return element.Displayed ? element : null;
        }
        catch (NoSuchElementException)
        {
            return null;
        }
    });
}

[Test]
public void NavigatesToProductDetail()
{
    var firstProduct = WaitForElement(_driver!, 
        MobileBy.AccessibilityId("ProductItem_0"));
    firstProduct.Click();
    
    var productTitle = WaitForElement(_driver!, 
        MobileBy.AccessibilityId("ProductDetailTitle"));
    
    Assert.That(productTitle.Displayed, Is.True);
}

Using the Page Object Pattern

The Page Object pattern makes UI tests maintainable:

public class LoginPage
{
    private readonly AppiumDriver _driver;

    public LoginPage(AppiumDriver driver)
    {
        _driver = driver;
    }

    private IWebElement EmailField => 
        _driver.FindElement(MobileBy.AccessibilityId("EmailEntry"));
    
    private IWebElement PasswordField => 
        _driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"));
    
    private IWebElement LoginButton => 
        _driver.FindElement(MobileBy.AccessibilityId("LoginButton"));
    
    public IWebElement ErrorMessage => 
        _driver.FindElement(MobileBy.AccessibilityId("ErrorMessage"));

    public DashboardPage Login(string email, string password)
    {
        EmailField.Clear();
        EmailField.SendKeys(email);
        PasswordField.Clear();
        PasswordField.SendKeys(password);
        LoginButton.Click();
        
        return new DashboardPage(_driver);
    }
}

public class DashboardPage
{
    private readonly AppiumDriver _driver;

    public DashboardPage(AppiumDriver driver)
    {
        _driver = driver;
    }

    public bool IsDisplayed =>
        _driver.FindElement(MobileBy.AccessibilityId("DashboardTitle")).Displayed;
}

// Clean test using Page Objects
[Test]
public void SuccessfulLoginShowsDashboard()
{
    var loginPage = new LoginPage(_driver!);
    var dashboard = loginPage.Login("user@example.com", "Password123!");
    
    Assert.That(dashboard.IsDisplayed, Is.True);
}

Running UI Tests in CI with GitHub Actions

# .github/workflows/ui-tests-ios.yml
name: iOS UI Tests
on: [push]

jobs:
  ui-test:
    runs-on: macos-14  # Required for iOS simulator
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      
      - name: Install Appium
        run: npm install -g appium && appium driver install xcuitest
      
      - name: Build iOS App
        run: |
          dotnet build MyApp/MyApp.csproj \
            -f net8.0-ios \
            -p:RuntimeIdentifier=iossimulator-x64 \
            -p:Configuration=Debug
      
      - name: Start Simulator
        run: |
          xcrun simctl boot "iPhone 15"
          
      - name: Start Appium Server
        run: appium &
        
      - name: Run UI Tests
        run: dotnet test MyApp.UITests/ --logger trx
        
      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: '**/*.trx'

Beyond Device Tests: Continuous Behavioral Monitoring

MAUI UI tests run on specific simulators in CI. They don't catch issues that appear on Android 12 but not 14, or on specific device models with unusual screen sizes.

For your MAUI app's backend APIs, HelpMeTest provides continuous behavioral monitoring — verifying that the API your app depends on responds correctly 24/7. Pair device UI tests with API monitoring for complete confidence in your mobile app stack.

Summary

  • Set AutomationId in every XAML element you plan to target in UI tests — this is non-negotiable
  • Page Object pattern makes UI tests maintainable — selectors change, tests shouldn't
  • Explicit waits beat implicit waits — use WebDriverWait for dynamic elements
  • iOS tests require macOS CI runners; Android tests can run on Linux
  • Appium with UIAutomator2 for Android, XCUITest for iOS
  • Keep UI tests focused on user-critical flows — don't automate everything

Read more