Testautomatisierung mit Avalonia

Testautomatisierung auf UI Ebene ist häufig nicht so einfach zu erreichen. Grund dafür können technische Fragestellungen sein. So gilt es, auf irgendeine Art das Rendering des UI-Frameworks abzubilden. Ebenso gilt es, mögliche Usereingaben zu emulieren oder Eingabeelemente an der UI zu identifizieren. Auch zeitliche Aspekte spielen mit rein, so ist ein “Warte eine Sekunde…” in einem automatisierten Test i. d. R. eine eher schlechte Idee. Doch es gibt auch Herausforderungen, die aus den Testfällen selbst entstehen. Versucht man etwa, ein Drag-Drop Verhalten automatisiert zu testen, beißt man sich dabei schon mal gerne die Zähne aus. Ich persönlich versuche UI Tests daher auf einem niedrigen Level zu halten, setze sie also primär für einfach zu überblickende Geradeausfälle ein. Mit Avalonia habe ich UI Tests zuletzt bei meinem Nuget-Paket RolandK.AvaloniaExtensions [1] eingesetzt. Hier ging es mir darum, dass die dort definierten Basisklassen und Features wie die Dependency Injection einwandfrei in einer Avalonia Applikation funktionieren.

Frameworks für die Testumgebung für Avalonia

Die Testumgebung besteht in meinem Fall aus einem Testprojekt mit xUnit.net [2] als Testframework. Abhängigkeit zu xUnit.net gibt es dabei keine – man könnte es jederzeit durch MSTest [3] oder einem vergleichbaren Testframework ersetzen. Automatisierte Tests werden also als normale Unittests ausgeführt. Man darf sich an dieser Stelle nicht von dem Begriff “Unit” in Unittest in die Irre führen lassen. Wir nutzen lediglich das selbe Testframework wie bei Unittests, lassen aber Tests auf der UI Ebene darüber laufen.

Als nächstes nutzen wir das Nuget-Paket Avalonia.Headless [4]. Dieses Paket kümmert sich darum, dass Avalonia denkt, in einer echte Umgebung mit Fenstern, Rendering, Benutzereingaben usw. zu laufen. Tatsächlich wird allerdings kein Monitor oder Ähnliches verwendet – die Umgebung wird lediglich simuliert. Dadurch ist es möglich, dass unsere UI Tests in einem Hintergrundprozess ohne UI-Umgebung laufen können. Avalonia.Headless wird durch einen Aufruf von UseHeadless während des Startvorgangs der Application aktiviert. Siehe dazu folgendes kurzes Beispiel.

AppBuilder.Configure<UnitTestApplication>()
    .LogToTrace()
    .UseHeadless()

Alles weitere ist optional. Ich verwende in den Testprojekten zusätzlich noch das Mocking-Framework NSubstitue [5]. Es dient dazu, innerhalb eines Tests sog. Mocks zu erstellen. Innerhalb dieses Artikels gehe ich aber nicht weiter auf NSubstitute ein, da der Fokus des Artikels an anderer Stelle liegt.

Avalonia Application im Testprojekt

Die nächste Herausforderung bez. Avalonia ist, dass wir für unser Testprojekt auch ein Application Objekt brauchen. Die Application wird typischerweise in der Program.cs einer Avalonia Applikation erzeugt und gestartet. Hier befindet sich i. d. R. der Startup Code der Applikation. An dieser Stelle werden etwa das Rendering-Framework (z. B. Skia), das Windowing Framework, das Logging und weitere Themen konfiguriert. Jetzt könnte man sich überlegen, dass man für jede Testmethode eine eigene Application erzeugt, hochfährt und am Ende wird runterfährt. Problem an dieser Stelle ist allerdings Avalonia selbst. Avalonia (in Version 0.18) erlaubt nur eine Applikation innerhalb eines Prozesses und kann diese Application nicht runterfahren.

Nachfolgender Codeausschnitt zeigt eine mögliche Lösung dieses Problems. Jede Testmethode kann über UnitTestApplication.RunInApplicationContextAsync(…) Logik innerhalb des Kontext einer Application ausführen. Die UnitTestApplication kümmert sich dabei darum, dass nur beim ersten Aufruf eine Application gestartet und geladen wird. Zusätzlich erfolgt ein Dispatch auf den UI-Thread – auch das ein Thema, auf das man bei UI Tests achten muss. Logik, die auf die UI zugreift, muss i. d. R. innerhalb des UI-Threads laufen.

Auf das StopAsync in Zeile 33 möchte ich nur am Rande eingehen. Dieses wird in meinem Fall über eine TestFixture nach Ausführen aller Testmethoden einmal ausgeführt und sorgt dafür, dass die Application wieder ordentlich runterfährt.

internal class UnitTestApplication : Application
{
    public static async Task RunInApplicationContextAsync(Action? action = null)
    {
        if (Application.Current != null)
        {
            if (action != null)
            {
                await Dispatcher.UIThread.InvokeAsync(action);
            }
            return;
        }

        var taskComplSource = new TaskCompletionSource();
        var uiThread = new Thread(_ =>
        {
            AppBuilder.Configure<UnitTestApplication>()
                .LogToTrace()
                .UseHeadless()
                .AfterSetup(_ => taskComplSource.SetResult())
                .StartWithClassicDesktopLifetime(Array.Empty<string>(), ShutdownMode.OnExplicitShutdown);
        });
        uiThread.Start();

        await taskComplSource.Task;

        if (action != null)
        {
            await Dispatcher.UIThread.InvokeAsync(action);
        }
    }
    
    public static async Task StopAsync()
    {
        var application = Application.Current;
        if (application == null) { return; }

        var appLifetime = application.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
        if (appLifetime == null) { return; }

        await Dispatcher.UIThread.InvokeAsync(() => appLifetime.Shutdown());
    }
}

Ein automatisierter Testfall

Jetzt wird es Zeit, dass wir uns mit der Umsetzung einer Testmethode beschäftigen. Nachfolgender Codeausschnitt zeigt den Testfall, bei dem es um die Zuweisung eines ViewModels an ein MvvmWindow geht. Das MvvmWindow ist dabei eine Klasse aus dem RolandK.AvaloniaExtensions Paket. Es registriert sich und diverse Services aus der View am ViewModel – allerdings nur solange es tatsächlich geöffnet ist. Letzteres soll in der gezeigten Testmethode getestet werden.

Wir sehen am Anfang der Testmethode den Aufruf der vorher vorgestellten UnitTestApplication. Hierbei ist völlig egal, ob die Application vorher schon geladen wurde oder erst durch diese Testmethode geladen wird. Innerhalb der Testmethode laufen wir schließlich die für Unittests üblichen Schritte durch: Arrange, Act, Assert und Cleanup. Letzteres schließt in diesem Fall das Fenster wieder.

[Fact]
public async Task Attach_MvvmWindow_to_ViewModel()
{
    await UnitTestApplication.RunInApplicationContextAsync(() =>
    {
        // Arrange
        var testViewModel = new TestViewModel();
        var mvvmWindow = new MvvmWindow();
        
        // Act
        mvvmWindow.DataContext = testViewModel;
        mvvmWindow.Show();
        
        // Assert
        Assert.True(mvvmWindow.IsVisible);
        Assert.Equal(testViewModel.AssociatedView, mvvmWindow);
        
        // Cleanup
        mvvmWindow.Close();
    });
}

Automatisierter Test ohne Fenster

Nun haben wir an der Stelle eigentlich schon alles wichtige gesehen. Auf einen Punkt möchte ich aber noch eingehen. Und zwar testen wir in obigen Beispiel das Verhalten eines Fensters. In anderen bzw. den meisten anderen Testfällen testen wir wohl eher das Verhalten innerhalb eines Controls. Für solche Fälle kann man ebenso ein Fenster öffnen, eine andere Alternative dazu wäre aber eine TestRoot Klasse, wie diese auch in einem Beispiel aus dem Avalonia Projekt verwendet wird [6]. Ich habe diese Klasse in meine Tests wie im folgenden Codeausschnitt übernommen.

// Original code from:
// https://github.com/AvaloniaUI/Avalonia/blob/ec74057151e8f405bbc8a324325a9f957e84bf7b/tests/Avalonia.UnitTests/TestRoot.cs

internal class TestRoot : Decorator, IFocusScope, ILayoutRoot, IInputRoot, IRenderRoot, IStyleHost, ILogicalRoot
{
    private readonly NameScope _nameScope = new NameScope();

    public Size ClientSize { get; set; } = new Size(1000, 1000);

    public Size MaxClientSize { get; set; } = Size.Infinity;

    public double LayoutScaling { get; set; } = 1;

    public ILayoutManager LayoutManager { get; set; }

    public double RenderScaling => 1;

    public IRenderer Renderer { get; set; }

    public IAccessKeyHandler AccessKeyHandler => null!;

    public IKeyboardNavigationHandler KeyboardNavigationHandler => null!;

    public IInputElement? PointerOverElement { get; set; }

    public bool ShowAccessKeys { get; set; }

    /// <inheritdoc />
    public IMouseDevice? MouseDevice { get; } = null;
    
    public TestRoot()
    {
        this.Renderer = Substitute.For<IRenderer>();
        this.LayoutManager = new LayoutManager(this);
        this.IsVisible = true;

        KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);
    }

    public TestRoot(Control child)
        : this()
    {
        this.Child = child;
    }
    
    public IRenderTarget CreateRenderTarget()
    {
        var dc = Substitute.For<IDrawingContextImpl>();
        dc.CreateLayer(Arg.Any<Size>()).Returns(_ =>
        {
            var layerDc = Substitute.For<IDrawingContextImpl>();
            var layer = Substitute.For<IDrawingContextLayerImpl>();
            layer.CreateDrawingContext(Arg.Any<IVisualBrushRenderer>()).Returns(layerDc);
            return layer;
        });
        
        var result = Substitute.For<IRenderTarget>();
        result.CreateDrawingContext(Arg.Any<IVisualBrushRenderer>()).Returns(dc);
        return result;
    }

    public void Invalidate(Rect rect)
    {
    }

    public Point PointToClient(PixelPoint p) => p.ToPoint(1);

    public PixelPoint PointToScreen(Point p) => PixelPoint.FromPoint(p, 1);

    public void RegisterChildrenNames()
    {
        var scope = NameScope.GetNameScope(this) ?? new NameScope();
        NameScope.SetNameScope(this, scope);

        void Visit(StyledElement element, bool force = false)
        {
            if (element.Name != null)
            {
                if (!ReferenceEquals(scope.Find(element.Name), element))
                {
                    scope.Register(element.Name, element);
                }
            }

            if (element is Visual visual && (force || NameScope.GetNameScope(element) == null))
                foreach (var child in visual.GetVisualChildren())
                    if (child is StyledElement styledChild)
                        Visit(styledChild);
        }

        Visit(this, true);
    }

    protected override Size MeasureOverride(Size availableSize)
    {
        return base.MeasureOverride(ClientSize);
    }
}

Anhand der TestRoot Klasse lassen sich Testfälle gegen eigene Controls wie folgt schreiben. Wie man darin sieht, ist dabei das Schließen des Fensters nicht mehr notwendig.

[Fact]
public Task Attach_MvvmUserControl_to_ViewModel()
{
    return UnitTestApplication.RunInApplicationContextAsync(() =>
    {
        // Arrange
        var testMvvmControl = new MvvmUserControl();
        var testViewModel = new TestViewModel();

        // Act
        testMvvmControl.DataContext = testViewModel;
        var testRoot = new TestRoot(testMvvmControl);
        
        // Assert
        Assert.Equal(testMvvmControl, testViewModel.AssociatedView);

        GC.KeepAlive(testRoot);
    });
}

Fazit

Testautomatisierung kann dafür sorgen, dass man als Entwickler frühzeitig auf Fehler hingewiesen wird, die man sonst womöglich übersehen würde. In diesem Artikel habe ich eine Möglichkeit beschrieben, Teile einer Avalonia Applikation automatisiert zu testen. Im verwiesenen Beispiel sind das relativ einfach Abläufe, wie die Zuweisung von ViewModels, Registrierung auf ein ViewModel, Deregistrierung vom ViewModel und noch viele mehr. Es handelt sich somit um Testfälle, die man in einem manuellen Test nur indirekt sehen würde – wenn überhaupt. Innerhalb der Programmlogik könnte eine Fehlfunktion aber schwerwiegende Auswirkungen haben. Etwa dass ein Timer im ViewModel weiterläuft, obwohl die zugehörige View längst nicht mehr aktiv ist. Für mich ist Testautomatisierung an dieser Stelle ein hervorragendes Werkzeug, um die Qualität der Software auf einem hohen Niveau zu halten.

Downloads

Verweise

  1. Nuget-Paket RolandK.AvaloniaExtensions
    https://www.nuget.org/packages/RolandK.AvaloniaExtensions
  2. Das Testframework xUnit.net
    https://xunit.net/
  3. Einführung in das Testframework MSTest
    https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest
  4. Nuget-Paket Avalonia.Headless
    https://www.nuget.org/packages/Avalonia.Headless
  5. Das Mocking-Framework NSubstitute
    https://nsubstitute.github.io/
  6. TestRoot Klasse für das Testen eines Controls ohne Fenster
    https://github.com/AvaloniaUI/Avalonia/blob/ec74057151e8f405bbc8a324325a9f957e84bf7b/tests/Avalonia.UnitTests/TestRoot.cs

Ebenfalls interessant

  1. Cross-Plattform GUI mit C# und Avalonia
    https://www.rolandk.de/wp-posts/2020/07/cross-platform-gui-mit-c-und-avalonia/
  2. Custom Window Chrome mit Avalonia
    https://www.rolandk.de/wp-posts/2021/05/custom-window-chrome-mit-avalonia/
  3. Das DataGrid von Avalonia
    https://www.rolandk.de/wp-posts/2022/10/das-datagrid-von-avalonia/
  4. Avalonia Applikationen übersetzen
    https://www.rolandk.de/wp-posts/2022/12/avalonia-applikationen-uebersetzen/

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.