Implementing a Simple Appium Backdoor Compatible with Plugin.Maui.UITestHelpers for .NET MAUI

Implementing a Simple Backdoor for Enhanced Appium Testing in .NET MAUI

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:

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.

Did you find this article valuable?

Support Pavlo Datsiuk's Blog by becoming a sponsor. Any amount is appreciated!