Wie erstellt man ein UI-Testfenster für automatisierte WPF UserControl Tests

Ausgangssituation für diesen Blog ist folgender Post auf Stackoverflow

[TestMethod]
public void TestMethod1()
{
    MainWindow window = null;
        
    // The dispatcher thread
    var t = new Thread(() =>
    {
        window = new MainWindow();

        // Initiates the dispatcher thread shutdown when the window closes
        window.Closed += (s, e) => window.Dispatcher.InvokeShutdown();

        window.Show();

        // Makes the thread support message pumping
        System.Windows.Threading.Dispatcher.Run();
    });

    // Configure the thread
    t.SetApartmentState(ApartmentState.STA);
    t.Start();
    t.Join();
}

https://stackoverflow.com/questions/13381967/show-wpf-window-from-test-unit

Das ständige Schreiben von Boilerplate-Code in jedem Test ist jedoch ineffizient! Unser Fokus liegt auf spezifischen Tests. Um dies zu erreichen, ist es notwendig, den kompletten Boilerplate-Code in eine Basisklasse zu verschieben. Was wir erreichen wollen ist folgendes:

[TestFixture]
public class UserControlTests : UnitTestBase
{
    [Test]
    public void TestUserControlIsVisible()
    {
        var testWindow = CreateTestWindowFor<TestUserControl>(
            new object(), // data context for TestUserControl
            800, // width of TestUserControl
            600, // height of testUserControl
            nameof(TestUserControlIsVisible)); // test name - will be shown in Test window TitleBar

        var testedControl =  testWindow.AutomationWindow!.FindFirstChild("MyCustomControlAutomationId");

        Assert.That(testedControl.IsAvailable, () => "Tested control is not available");

        var userNameTextBox = testedControl.FindFirstChild("UserNameTextBox");

        Assert.That(string.IsNullOrEmpty(userNameTextBox.AsTextBox().Text), ()=> "UserName TextBox is initially not empty");
    }
}

Wie man hier erkennen kann, wird das WPF UserControl „TestUserControl“ getestet. Nur die erste Code-Anweisung ist verantwortlich für das UI-Test-Fenster. Alle weiteren Anweisungen gehören zu der Test-Logik.

In der Basis-Klasse brauchen wir eine Funktion, die uns das Test-Fenster zusammenbaut und zurückgibt:

public class UnitTestBase
{
    protected TestWindow CreateTestWindowFor<T>(object dataContext, int width, int height, string testName) where T : UserControl, new()
    {
        
    }
}

Diese Methode gibt eine Instanz von TestWindow zurück, also definieren wir eine Klasse TestWindow:

public class TestWindow
{
    public FlaUI.Core.AutomationElements.Window? AutomationWindow { get; private set; }
    private static System.Windows.Window? WpfWindow { get; set; }
    private FlaUI.Core.Application? Application { get; set; }
    private UIA3Automation? Automation { get; set; }


}

Wir brauchen Referenzen für das AutomationFenster(FlaUI), das Wpf-Fenster, die Application und für die UIA3Automation. Diese Referenzen definieren wird als Eigenschaften.

Darin befindet sich eine Methode, die uns das WPF-Fenster zusammenbaut:

private void CreateWpfWindow(object? obj)
{
    if (obj is not Func<UIElement> elementFunction) return;

    _ = new Application();
    System.Windows.Application.Current!.Dispatcher.Invoke(() =>
    {
        System.Windows.Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
        WpfWindow = new System.Windows.Window
        {
            SizeToContent = SizeToContent.WidthAndHeight,
            MinWidth = 640,
            MinHeight = 480,
            Content = elementFunction()
        };
        System.Windows.Application.Current.Run(WpfWindow);
    });
}

Diese Methode hat einen Parameter von type object?. Tatsachlich erwarten wir hier eine Instanz von Typ Func<UIElement>. Wwir werden die Methode als Parameter für System.Threading.ParameterizedThreadStart(object? obj) nutzen, demnach muss CreateWpfWindow einen Parameter von type object? besitzen.  Wir erstellen eine neue WPF-Anwendung und benutzen den Dispacher um das WPF-Fenster zu initialisieren und zu starten.

Jetzt ist es soweit. Wir müssen ein Thread erstellen, der uns die Methode CreateWpfWindow aufruft. Dafur defnieren wir eine neue Methode namens InitializeTestWindow:

public void InitializeTestWindow(Func<UIElement> contentFunction, string windowTitle)
{

}

Der neue Thread wird wie folgt definiert:

// Start new thread with parameter:
Thread windowThread = new(new ParameterizedThreadStart(CreateWpfWindow));
windowThread.SetApartmentState(ApartmentState.STA);
windowThread.Start(contentFunction);

Als nächstes müssen wir den Prozess, der unser WPF-Fenster gestartet hat, an die FlaUI Applikation Instanz hängen. Danach warten wir eine Sekunde, bis das Handle im Haupt-Fenster gefunden wird:

// Fla UI Application Wrapper
Application = FlaUI.Core.Application.Attach(Process.GetCurrentProcess());
Application.WaitWhileMainHandleIsMissing(TimeSpan.FromMilliseconds(1000));

In einer Sekunde musste das WPF-Fenster bereits entstehen. Diese wert kann auch vergrößert werden – zum Beispiel wenn UserControls, die wir testen wollen etwas komplexer werden und mehr Zeit zur Initialisierung benötigen.

In nächsten Schritt initialisieren wir die UIA3Autmation und das AutomationWindow. Das sollte auch nicht langer als eine Sekunde dauern (der Wert kann aber vergrößert werden).

// Automation for WPF
Automation = new UIA3Automation();
AutomationWindow = Application.GetMainWindow(Automation, TimeSpan.FromMilliseconds(1000));
AutomationWindow.Patterns.Window.PatternOrDefault?.SetWindowVisualState(
   FlaUI.Core.Definitions.WindowVisualState.Normal);

Die letzte Anweisung setzt den Titel des Wps-Fenster. Das muss mit Dispacher.Invoke() gemacht werden.

// Set Wpf-Window Title
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
    if (WpfWindow is not null)
    {
        WpfWindow.Title = windowTitle;
    }
});

Insgesamt sieht unser InitializeTestWindow Methode folgendermaßen aus:

public void InitializeTestWindow(Func<UIElement> contentFunction, string windowTitle)
{
    // Start new thread with parameter:
    Thread windowThread = new(new ParameterizedThreadStart(CreateWpfWindow));
    windowThread.SetApartmentState(ApartmentState.STA);
    windowThread.Start(contentFunction);

    // Fla UI Application Wrapper
    Application = FlaUI.Core.Application.Attach(Process.GetCurrentProcess());
    Application.WaitWhileMainHandleIsMissing(TimeSpan.FromMilliseconds(1000));

    // Automation for WPF
    Automation = new UIA3Automation();
    AutomationWindow = Application.GetMainWindow(Automation, TimeSpan.FromMilliseconds(1000));
    AutomationWindow.Patterns.Window.PatternOrDefault?.SetWindowVisualState(
       FlaUI.Core.Definitions.WindowVisualState.Normal);

    // Set Wpf-Window Title
    System.Windows.Application.Current.Dispatcher.Invoke(() =>
    {
        if (WpfWindow is not null)
        {
            WpfWindow.Title = windowTitle;
        }
    });
}

Jetzt können wir zu unser Test-Basis klasse zurück gehen und den Inhalt der Funktion CreateTestWindowFor<T>  definieren:

protected TestWindow CreateTestWindowFor<T>(object dataContext, int width, int height, string testName) where T : UserControl, new()
{
    var testWindow = new TestWindow();
    testWindow.InitializeTestWindow(
        () => new T
        {
            DataContext = dataContext,
            Width = width,
            Height = height
        }, 
        testName);

    return testWindow;
}

Die Klassen TestWindow und UnitTestBase sind damit fertig.

Jetzt erstellen wir eine WPF UserControl, die wir testen wollen. Die XAML Definition sollte wie hier aussehen:

<UserControl x:Class="WpfUIApplication.TestUserControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfUIApplication"
             mc:Ignorable="d" 
             AutomationProperties.AutomationId="MyCustomControlAutomationId"
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <Button AutomationProperties.AutomationId="LoginButton" Content="Button" HorizontalAlignment="Left" Margin="259,244,0,0" VerticalAlignment="Top" Height="31" Width="89"/>
        <TextBox AutomationProperties.AutomationId="UserNameTextBox" HorizontalAlignment="Left" Margin="162,147,0,0" TextWrapping="Wrap" Text="Username" VerticalAlignment="Top" Width="186" Height="28"/>
        <TextBox AutomationProperties.AutomationId="UserPasswordTextBox" HorizontalAlignment="Left" Margin="162,180,0,0" TextWrapping="Wrap" Text="Password" VerticalAlignment="Top" Width="186" Height="27"/>

    </Grid>
</UserControl>

Jede UserControl und alle Elemente die sich darin befinden, sollen die Eigenschaft AutomationProperties.AutomationId nutzen. Über diese AutomationID ist es möglich, die Elemente im UI-Test zu finden und abzufragen.

var testedControl =  testWindow.AutomationWindow!.FindFirstChild("MyCustomControlAutomationId");

Assert.That(testedControl.IsAvailable, () => "Tested control is not available");

var userNameTextBox = testedControl.FindFirstChild("UserNameTextBox");

Assert.That(string.IsNullOrEmpty(userNameTextBox.AsTextBox().Text), ()=> "UserName TextBox is initially not empty");

Nachdem wir den Boilerplate-Code in weitere Klassen ausgelagert haben, können wir uns nun auf die spezifischen Testfälle für unsere Benutzeroberfläche konzentrieren.

0 Kommentare

Hinterlasse einen Kommentar

An der Diskussion beteiligen?
Hinterlasse uns deinen Kommentar!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert