DEV Community

Cover image for How to Automate UI testing in AvaloniaUI App
Ingvar
Ingvar

Posted on

How to Automate UI testing in AvaloniaUI App

Hello, in this post I gonna discuss details of automated UI testing. As always I gonna use my app Camelot as working example.

Why do you need UI testing

Why do you need to automate your UI testing? Because it saves time if compare with manual testing. It's more accurate and helps to find regression bugs. The only disadvantage here is time spent on writing tests and initial setup.

What UI tests could do

UI tests could emulate everything user does. Move mouse, click, press keys etc. I recommend to check a single test case/scenario in a test, like you do during manual testing. Example from my app: open create directory dialog, enter directory name and create it, observe that it was created. This test could be automated easily. I recommend to start automating from smoke testing and finish with full regression testing.

UI testing in AvaloniaUI

AvaloniaUI has functionality that allows to run custom code few seconds after app setup. You can add your testing code there and run your scenarios. AvaloniaUI 0.10.0 also introduced Headless platform. Headless app doesn't have UI so it could be started even on non-gui OS. It could be useful for running tests on servers w/o GUI. I run UI tests on Github via github actions so for me this option is useful. I prefer to run real app and execute tests on it but you can also test your controls etc w/o running whole app.

Avalonia app inside uses static fields etc so it's not possible to create new instance of app per test. I had to create single instance and reuse it across tests, so parallel execution of tests should be disabled for UI tests project. Also every test should have proper cleanup otherwise it could break other tests.

Setup infrastructure for UI testing

I use Xunit for testing, it doesn't work with UI tests out of box. I had to add my own runner for Xunit:

private class Runner : XunitTestAssemblyRunner
{
    // constructor

    public override void Dispose()
    {
        AvaloniaApp.Stop(); // cleanups existing avalonia app instance

        base.Dispose();
    }

// this method is called only if test parallelization is enabled. I had to enable it and set max parallelization limit to 1 in order to avoid parallel tests execution
    protected override void SetupSyncContext(int maxParallelThreads)
    {
        var tcs = new TaskCompletionSource<SynchronizationContext>();
        var thread = new Thread(() =>
        {
            try
            {
                // DI registrations
                AvaloniaApp.RegisterDependencies();

                AvaloniaApp
                    .BuildAvaloniaApp()
                    .AfterSetup(_ =>
                    {
                        // sets sync context for tests. avalonia UI runs in it's own single thread, updates from other threads are not allowed
                        tcs.SetResult(SynchronizationContext.Current);
                    })
                    .StartWithClassicDesktopLifetime(new string[0]); // run app as usual
            }
            catch (Exception e)
            {
                tcs.SetException(e);
            }
        })
        {
            IsBackground = true
        };

        thread.Start();

        SynchronizationContext.SetSynchronizationContext(tcs.Task.Result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Full code
One more headless tests example

Also I added AvaloniaApp class that is actually wrapper for real app:

public static class AvaloniaApp
{
    // DI registrations
    public static void RegisterDependencies() =>
        Bootstrapper.Register(Locator.CurrentMutable, Locator.Current);

    // stop app and cleanup
    public static void Stop()
    {
        var app = GetApp();
        if (app is IDisposable disposable)
        {
            Dispatcher.UIThread.Post(disposable.Dispose);
        }

        Dispatcher.UIThread.Post(() => app.Shutdown());
    }

    public static MainWindow GetMainWindow() => (MainWindow) GetApp().MainWindow;

    public static IClassicDesktopStyleApplicationLifetime GetApp() =>
        (IClassicDesktopStyleApplicationLifetime) Application.Current.ApplicationLifetime;

    public static AppBuilder BuildAvaloniaApp() =>
        AppBuilder
            .Configure<App>()
            .UsePlatformDetect()
            .UseReactiveUI()
            .UseHeadless(); // note that I run app as headless one
}
Enter fullscreen mode Exit fullscreen mode

UI testing

Now it's time to write test! Here is an example of test that opens about dialog via F1:

public class OpenAboutDialogFlow : IDisposable
{
    private AboutDialog _dialog;

    [Fact(DisplayName = "Open about dialog")]
    public async Task TestAboutDialog()
    {
        var app = AvaloniaApp.GetApp();
        var window = AvaloniaApp.GetMainWindow();
        // wait for initial setup
        await Task.Delay(100);

        Keyboard.PressKey(window, Key.Tab); // hack for focusing file panel, in headless tests it's not focused by default
        Keyboard.PressKey(window, Key.Down);
        Keyboard.PressKey(window, Key.F1); // press F1

        await Task.Delay(100); // UI is not updated immediately so I had to add delays everywhere

        _dialog = app
            .Windows
            .OfType<AboutDialog>()
            .SingleOrDefault();
        Assert.NotNull(_dialog);

        await Task.Delay(100);

        var githubButton = _dialog.GetVisualDescendants().OfType<Button>().SingleOrDefault();
        Assert.NotNull(githubButton);
        Assert.True(githubButton.IsDefault);
        Assert.True(githubButton.Command.CanExecute(null));
    }

    public void Dispose() => _dialog?.Close(); // cleanup: close dialog
}
Enter fullscreen mode Exit fullscreen mode

Looks simple, right? I had to add some extra code for delays but in fact test is simple. Here is an example of delay service:

public static class WaitService
{
    public static async Task WaitForConditionAsync(Func<bool> condition, int delayMs = 50, int maxAttempts = 20)
    {
        for (var i = 0; i < maxAttempts; i++)
        {
            await Task.Delay(delayMs);

            if (condition())
            {
                break; // stop waiting for condition
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

More tests examples
Official example

Conclusion

AvaloniaUI provides good infrastructure for running UI tests. I recommend to use them in your project if it's complicated enough because they save a lot of time. Are you using any UI tests in your project? Tell me in comments

Top comments (3)

Collapse
 
berndmehnert profile image
berndmehnert • Edited

Hi! Thank you very much for the article and in particular for the UI testing part. I have run the UI test example you gave and it worked nicely. I am quite new to this and have a question:
How would you run the camelot UI test non-headless, i.e. the app opening and you can see the tests happening ;) ?
There seems to be not much documentation to this. A (may be naive) "UseHeadless(false)" in your test example didn't work ;(.

Collapse
 
ingvarx profile image
Ingvar

Hi! Thanks for your comment.

I think that UseHeadless(false) doesn't work anymore but removing this line works and shows Camelot UI during tests for me

Collapse
 
berndmehnert profile image
berndmehnert

Indeed, commenting out that line did it ;) Thank you again ...