Getting Started with Maestro UI Tests for .NET MAUI
Understand the testing boundary, write your first YAML flow, and run it locally
A ViewModel test can tell us that saving a valid task adds an item to a collection. But does the Save button execute the right command? Can the user see the validation message? Does navigation bring us back to a usable task list?
These questions sit between our application logic and the native UI.
For this sample, I wanted a small set of tests that interact with a .NET MAUI app in the same order as a user: open a page, enter a title, save it, and check the result.
In this post, we will look at the UI-testing concept, introduce Maestro, write a flow, and see how to run it locally on Android and iOS. The second part will take the same flows into GitHub Actions.
The local Android example uses Windows and an emulator. The iOS example needs a Mac and an iOS Simulator. Neither replaces a final check on real devices.
What Does a UI Test Actually Test?
Unit tests and UI tests answer different questions:
- Unit Tests: Does the ViewModel reject a blank title? / Application logic and test doubles
- UI Tests: Does tapping Save show the validation message and keep the form open? / The installed app on a device or simulator
A unit test can call a command directly. A UI test must find the button, tap it, and observe the screen afterwards. This exercises more of the application: native controls, bindings, input, and navigation.
That broader boundary is useful, but it costs more. UI tests need a running app and device, take longer, and can be affected by keyboard behavior, animations, or ambiguous selectors.
I would not replace unit tests with UI tests. Keep detailed business-rule checks close to the code, then use a focused set of UI tests for important user journeys.
The basic pattern stays familiar: arrange a known state, perform an action, and assert the result.
Introducing Maestro
Maestro describes UI interactions in YAML files called flows. A flow can launch an app, find an element in the native UI hierarchy, tap it, enter text, and check what becomes visible.
For .NET MAUI, that is an interesting boundary. Maestro does not know our ViewModel types. It interacts with the native controls that MAUI creates.
There is no Maestro NuGet package in this sample and no production-only test hook. The installed app remains the app we want to test.
This is not a pixel-diff test suite either. We check behavior through elements, identifiers, and accessibility descriptions. Screenshots can help us understand failures, but taking a screenshot does not automatically compare the UI against a visual baseline.
The Sample: MAUI Tasks
The app is deliberately small. It starts with three tasks:
- Learn .NET MAUI
- Explore Maestro
- Write UI tests
We can add a task, mark it as completed, and delete it. Empty or whitespace-only titles are rejected with an inline message. Valid titles are trimmed before saving.
There is no backend, database, or authentication. An in-memory task service keeps the example focused on UI interaction rather than infrastructure.
The repository contains five flows:
- Smoke: Check the seeded task list and Add button
- Add: Save a new task and find it in the list
- Validate: Reject blank input, then recover by saving a valid title
- Complete: Complete a specific task and return it to “To do”
- Delete: Remove a specific task without removing the other tasks
That is a useful starting point, not a claim of complete application coverage.
Keeping Unit Tests Separate
The solution separates the .NET MAUI app from a platform-independent Core library. Models, ViewModels, the task service, and a navigation contract live in Core; views and Shell integration remain in the app.
Core targets net10.0 and does not reference .NET MAUI. We can run its unit tests directly with the matching .NET SDK:
dotnet test `
tests\MaestroMauiDemo.UnitTests\MaestroMauiDemo.UnitTests.csprojThis project uses xUnit with VSTest, so the positional project path is intentional. No .NET MAUI workload or emulator is needed for this command.
The Maestro workspace is different. It is a directory of YAML files and runner scripts, not another .NET test project:
tests/
MaestroMauiDemo.UITests/
config.yaml
flows/
smoke.yaml
add-task.yaml
validate-task.yaml
complete-task.yaml
delete-task.yaml
scripts/
run-android.ps1
run-ios.shStart with a Known State
To write a first flow, create a YAML file such as open-task-list.yaml inside the workspace’s flows directory. For a temporary exercise, remove it afterwards if you want to keep the repository’s original five-flow suite.
Here is its complete content:
appId: ${APP_ID}
name: Open the task list
---
- launchApp:
clearState: true
- assertVisible: "MAUI Tasks"
- assertVisible:
id: "TaskList"The header identifies the app and names the flow. The --- separator starts the command list. Our workspace discovers files inside flows, so a new file there is included in suite runs.
APP_ID is supplied when we invoke Maestro. For this app, its value is de.tsjdevapps.maestromauidemo. Keeping it outside the flow avoids repeating the package identifier in every file.
launchApp starts the app with its data cleared. In this sample, the process restart also recreates the in-memory service and the three seeded tasks. Navigating between pages does not reset that service.
For an app backed by a server, this would not reset server-side data. We would need an explicit test-data strategy.
Finally, assertVisible checks the screen rather than assuming that launching the process means the app is usable. A launch with clearState is destructive to the selected app’s data, so use it only against the intended test app.
Give Controls Stable Selectors
Visible text can identify a screen, but it is not always the best way to identify a control. Labels change, and repeated list rows often contain identical buttons.
The sample assigns explicit .NET MAUI AutomationId values to important controls, including TaskList, TaskTitleEntry, and SaveTaskButton.
A unique button can then be selected like this:
- tapOn:
id: "SaveTaskButton"Rows require more detail. Every row has a Delete button. The relevant selector properties in its XAML are:
<Button
AutomationId="DeleteTaskButton"
Text="Delete"
SemanticProperties.Description="{Binding Title,
StringFormat='Delete task: {0}'}" />This excerpt omits the actual command binding and styling to focus on selection.
On Android, the native hierarchy exposes the description, allowing the flow to select one specific task:
- tapOn:
id: "DeleteTaskButton"
text: "Delete task: Write UI tests"The completion checkbox uses the same idea with descriptions such as Completion for Explore Maestro.
Inspect the native hierarchy when a selector does not behave as expected. A XAML property alone is not proof of how it appears on every platform.
Write a Flow That Checks the Result
Here is the complete add-task flow from the repository:
appId: ${APP_ID}
name: Add a task and return to the list
---
- launchApp:
clearState: true
- assertVisible:
id: "TaskList"
- assertNotVisible: "Write Maestro UI tests"
- tapOn:
id: "AddTaskButton"
- assertVisible: "A new task"
- tapOn:
id: "TaskTitleEntry"
- inputText: "Write Maestro UI tests"
- hideKeyboard
- tapOn:
id: "SaveTaskButton"
- assertVisible:
id: "TaskList"
- assertNotVisible:
id: "TaskTitleEntry"
- scrollUntilVisible:
element:
id: "TaskTitleLabel"
text: "Write Maestro UI tests"
- assertVisible:
id: "TaskTitleLabel"
text: "Write Maestro UI tests"We first verify the starting screen and make sure the new task is absent. Then we open the form, focus the entry, type the title, and hide the keyboard before saving.
The important part comes after the tap. We check that the list returns, the entry is no longer visible, and the new task appears.
scrollUntilVisible avoids depending on a particular screen height. The flow uses state-based checks instead of arbitrary fixed sleeps.
The repository’s other flows start independently too. None needs another flow to create its data first. That makes a single-flow run useful during development.
Do Not Forget the Rejection Path
A successful save does not prove that an invalid save behaves correctly.
After opening the add page, the validation flow contains:
- tapOn:
id: "SaveTaskButton"
- assertVisible:
id: "ValidationMessageLabel"
text: 'Enter a task title\.'
- assertVisible:
id: "TaskTitleEntry"
- assertNotVisible:
id: "TaskList"This checks the message and the navigation result: the form stays open.
Get Sebastian Jensen’s stories in your inbox
Join Medium for free to get updates from this writer.
The full flow repeats the check with three spaces, then replaces them with a valid title and verifies the saved task. We test both rejection and recovery, not just the presence of red text.
Prepare the Local Android Environment
For the Windows runner in this repository, we need:
- .NET SDK 10.0.403
- .NET MAUI Android workload and compatible Android SDK
- Android Platform-Tools and Emulator
- An existing Android AVD
- Java 17+; use your vendor’s October 2026 security release or later, and point
JAVA_HOMEto that JDK - Native Maestro CLI
- PowerShell 7
Follow the official Maestro CLI installation guide. On Windows, download the release ZIP, extract it to a stable directory, and add its bin directory to your user PATH. This uses the native CLI, without WSL.
Set JAVA_HOME to the JDK root containing bin\java.exe, not to bin itself. Configure ANDROID_HOME to point to the Android SDK. Open a new terminal after changing environment variables.
Check the tools before running a test:
dotnet --version
& "$env:JAVA_HOME\bin\java.exe" --version
maestro --version
& "$env:ANDROID_HOME\platform-tools\adb.exe" devices -l
& "$env:ANDROID_HOME\emulator\emulator.exe" -list-avdsIf no AVD exists, create one through Android Device Manager or Android Studio. The helper starts existing AVDs; it does not create them or install missing tools.
Run the Tests Locally on Android
From the repository root:
.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1The helper reuses an eligible running emulator. If none is available, it selects the only existing AVD automatically, or offers a numbered choice when several exist.
For a repeatable command:
.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1 `
-AvdName 'pixel_5_-_api_33'Replace the example with a name from your own AVD list. The screenshots and local verification in this article use API 33.
The script waits for boot, evaluates the project’s Android target and application ID, builds a fresh signed APK, installs it, and runs all five flows.
To work on just one flow:
.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1
-Flow "add-task.yaml"The -Flow value is a filename inside the existing flow directory.
For YAML-only changes, we can avoid rebuilding:
.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1 `
-DeviceId "emulator-5554" -Flow "add-task.yaml" -SkipBuild-SkipBuild skips both build and installation. The selected emulator must already contain the app. Do not use this option to claim that freshly changed app code has been tested.
The helper shuts down an emulator only when it started that emulator itself. A pre-existing emulator stays running. Boot, installation, and test failures return nonzero rather than becoming a successful run after cleanup.
See What the Android Helper Runs
Once the app is installed and the emulator is booted, we can invoke Maestro directly:
maestro --device=emulator-5554 --platform=android test `
--env "APP_ID=de.tsjdevapps.maestromauidemo" `
tests\MaestroMauiDemo.UITests\flows\add-task.yamlReplace the serial with the intended test emulator. This command runs the flow only: it does not build, install, or manage emulator shutdown.
For the whole workspace:
maestro --device=emulator-5554 --platform=android test `
--env "APP_ID=de.tsjdevapps.maestromauidemo" `
tests\MaestroMauiDemo.UITestsThe workspace’s config.yaml limits discovery to the YAML flow directory:
flows:
- flows/*Maestro reports the commands and flow outcomes in the terminal. When a flow fails, check its first failed assertion and diagnostic output before changing the app or adding delays.
The five Android flows have passed locally, both as a suite and individually in a different order.
A Few Practical Pitfalls
An ADB-installed MAUI Debug APK needs its managed assemblies included. Visual Studio Fast Deployment can otherwise leave the APK depending on separately deployed files.
The helper builds with:
AndroidPackageFormats=apk
EmbedAssembliesIntoApk=trueAn installation that succeeds is not enough if the app then aborts at startup.
For Java issues, check the actual output of java -version, not just the JDK folder name. The local Microsoft JDK reports 17.0.21.0.2, which produces an Android-tooling XA0033 parsing warning in this environment. Builds and flows passed, but the warning is not presented as resolved.
For element-selection failures, inspect the native hierarchy, the foreground screen, and the starting state. Stable IDs and row-specific descriptions are more useful than relying on “the second Delete button”.
The UI-test guide contains the full setup and troubleshooting details.
Run the Same Flows Locally on iOS
The YAML files do not need a separate iOS copy. The environment around them does change.
For an iOS Simulator run, we need:
- macOS and Xcode
- A compatible .NET SDK and .NET MAUI iOS workload
- An installed iOS Simulator runtime and an existing simulator
- Java 17+; use your vendor’s October 2026 security release or later, and point
JAVA_HOMEto that JDK - Native Maestro CLI
The repository’s hosted example uses Xcode 27.1, workload set 10.0.403, and Maestro 2.12.1. Maestro 2.12.0 added support for starting iOS simulators with Xcode 27; that support is included in 2.12.1. Keep Xcode and the installed .NET iOS workload compatible; changing one without checking the other can prevent the app from building.
An iOS Simulator runs only on macOS. A Windows or Linux machine cannot run it locally; a Windows developer needs access to a Mac for this part. The simulator does not require an Apple signing certificate or provisioning profile.
On the Mac, check the available simulators and run the Bash helper from the repository root:
xcrun simctl list devices available
bash tests/MaestroMauiDemo.UITests/scripts/run-ios.shThe helper selects an available simulator, starts it when needed, builds a fresh simulator .app, checks its bundle identifier, installs it, and runs the same five flows. If several simulators are available, it asks us which one to use.
We can also select a simulator by its UDID and run one flow:
bash tests/MaestroMauiDemo.UITests/scripts/run-ios.sh \
--device-id "YOUR-SIMULATOR-UDID" --flow add-task.yamlReplace the placeholder with a UDID from simctl. Like the Android helper, --skip-build skips both the build and installation, so use it only when the intended app is already installed. The helper shuts down only a simulator it started itself.
If the simulator is already running and the app is installed, we can invoke the flow directly:
maestro --device="YOUR-SIMULATOR-UDID" --platform=ios test \
--env "APP_ID=de.tsjdevapps.maestromauidemo" \
tests/MaestroMauiDemo.UITests/flows/add-task.yamlThis direct command does not build or install the app. It also does not manage simulator startup or shutdown.
Sharing a flow is useful, but it does not guarantee identical native behavior. Inspect the iOS accessibility hierarchy and verify selectors, scrolling, and keyboard dismissal on the simulator.
Do Not Skip Real-Device Testing
An emulator or simulator is a fast, repeatable place to catch many problems. It is not the final answer about how the app behaves in someone’s hand.
Before a release, run the app on a physical Android device and a physical iPhone as well. Check the important journeys, including navigation, text input, scrolling, and accessibility. Depending on the app, also check hardware features, permissions, notifications, performance, and real network conditions. Expand the device and OS coverage to match the people using the app.
An iOS simulator .app is not a device build. Running on an iPhone still requires macOS and the appropriate Apple signing and provisioning setup. Passing a simulator flow is useful evidence, not proof that the physical-device experience is ready.
Conclusion
A useful first UI test does not need to cover every screen. Start with one important journey and verify its outcome through the real app.
For this sample, that means starting from known data, finding controls through stable selectors, saving a task, and checking the resulting list. Add a rejection path next, and keep detailed business rules in unit tests. Run the flows on both platforms where their tooling is available, then check the app on real devices.
The sample and local runners are available on GitHub.











