Implementing a Simple Appium Backdoor Compatible with Plugin.Maui.UITestHelpers for .NET MAUI
During my transition from Xamarin to .NET MAUI, I encountered a significant challenge: rewriting the UI tests for my app. Like many other Xamarin developers, I relied heavily on Xamarin.UITest. However, after reading this article, I discovered that Appium was the recommended tool for .NET MAUI. Fortunately, the MAUI team has done an excellent job replicating Xamarin.UITest
through the Plugin.Maui.UITestHelpers
NuGet package, which streamlined the migration process considerably.
The Challenge: Missing Backdoor Functionality
After successfully rewriting my initial UI test, I encountered an obstacle while working on the second test: the absence of backdoor functionality in both Plugin.Maui.UITestHelpers
and Appium. The backdoor feature is crucial because it allows developers to alter the app's state quickly and efficiently, accelerating UI test development and reducing the execution time for each test.
Understanding Appium’s Limitations
Appium is typically used for black-box testing, meaning it interacts only with the elements visible to the user, without direct access to the app's internal methods. However, this limitation can become problematic when you need to simulate scenarios like incoming notifications, Bluetooth device connections, or even app crashes. To perform such tests, white-box testing—which requires access to the app's internal methods—is essential.
There are existing solutions for white-box testing with Appium:
- For Android: Espresso Driver
- For iOS: Insider
However, these solutions don’t support C# apps, especially those developed for Android and iOS with Xamarin or .NET MAUI. Another project, Appium MQTT Backdoor, offers MAUI support but requires additional setup, which could increase the cost of maintaining your CI/CD environments.
My Solution: A Simple Backdoor Implementation for Android
To overcome this limitation, I developed a straightforward backdoor implementation for Android. This solution is file-based, which makes it adaptable across all platforms, as the pushFile
functionality is supported by all Appium drivers.
I chose to use the /sdcard/Android/data/{packageName}/files
directory. This location is ideal for bypassing the restrictions introduced in Android 11+ regarding file system access. Both Appium and the app itself can read and write files in this directory.
Concept
The concept is simple: the UI test generates a file on the device with a command, and the mobile application under test writes the result to a separate file for the UI test to retrieve.
The following sections describe how to establish backdoor functionality by incorporating a command file mechanism into your UI tests and implementing an in-app listener within the Android application to process these commands and return the results.
UI Test-Side Implementation
Below is the extension method that adds an Invoke
method to the IApp
interface. This interface is part of Plugin.Maui.UITestHelpers
.
public static string Invoke(this IApp app, string methodName, string parameter = null)
{
var appIdentifierKey = "AppId";
var packageName = app.Config.GetProperty<string>(appIdentifierKey);
var basePath = $"/sdcard/Android/data/{packageName}/files";
var taskFileName = "appium-backdoor-tasks.json";
var taskFilePath = $"{basePath}/{taskFileName}";
var resultFileName = $"appium-backdoor-result-{Guid.NewGuid()}.json";
var resultFilePath = $"{basePath}/{resultFileName}";
var appiumApp = app as AppiumApp;
if (appiumApp == null)
return null;
var task = new AppiumBackdoorTask
{
MethodName = methodName,
Parameter = parameter,
ResultFilename = resultFileName
};
string taskJson = JsonSerializer.Serialize(task);
appiumApp.Driver.PushFile(taskFilePath, taskJson);
int retries = 0;
string result = null;
while (result == null)
{
if (retries > 10)
throw new Exception("Failed to get backdoor result");
retries++;
Thread.Sleep(250);
byte[] bytes = null;
try
{
bytes = appiumApp.Driver.PullFile(resultFilePath);
}
catch (WebDriverException)
{
continue;
}
if (bytes == null)
continue;
result = Encoding.UTF8.GetString(bytes);
appiumApp.Driver.PushFile(resultFilePath, "");
}
appiumApp.Driver.PushFile(taskFilePath, "");
return result;
}
Android App-Side Listener Implementation
On the Android app side, a loop listens for commands from the file. I use conditional compilation
to ensure this code is only available when the app is built with a specific UI test configuration.
public async Task StartAppiumBackdoorListnerAsync()
{
while (true)
{
await Task.Delay(250);
var packageName = AppInfo.Current.PackageName;
var basePath = $"/sdcard/Android/data/{packageName}/files";
var taskFileName = "appium-backdoor-tasks.json";
var taskFilePath = $"{basePath}/{taskFileName}";
if (File.Exists(taskFilePath))
{
var taskJson = await File.ReadAllTextAsync(taskFilePath);
if (string.IsNullOrEmpty(taskJson))
continue;
var task = JsonSerializer.Deserialize<AppiumBackdoorTask>(taskJson);
var resultPath = $"{basePath}/{task.ResultFilename}";
if (File.Exists(resultPath))
continue;
var result = InvokeBackdoorMethod(task.MethodName, task.Parameter);
await File.WriteAllTextAsync(resultPath, result);
}
}
}
Here, InvokeBackdoorMethod
is a method that calls your app's internal API to perform the desired action.
Data Transfer Object (DTO) for Serialization
To complete the code, here's the DTO used for serialization:
public class AppiumBackdoorTask
{
public string MethodName { get; set; }
public string Parameter { get; set; }
public string ResultFilename { get; set; }
}
Conclusion
This simple backdoor implementation effectively allows for white-box testing in .NET MAUI apps using Appium. It enables quick state changes and enhances the efficiency of your UI test suite. While it may not cover all edge cases or platform-specific needs, it serves as a flexible foundation for more advanced testing scenarios.
Subscribe to my newsletter
Read articles from Pavlo Datsiuk directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Pavlo Datsiuk
Pavlo Datsiuk
🚀