How-To: Automatisierte UI Tests mit White

Testautomatisierung läuft uns meist eher auf Ebene der Unit Tests über den Weg. Sie kann aber auch bei System Tests recht praktisch sein. Ein Tool was uns WPF, Silverlight und Windows Forms Entwicklern dabei hilft, ist White. Bei diesem handelt es sich um ein Framework mit dem man Applikationen steuern und Steuerelemente innerhalb dieser Programme identifizieren kann, wodurch es möglich wird bestimmte Use Cases nachzustellen und deren Ergebnisse auf ihre Korrektheit zu prüfen. Dieses kann im Grunde mit jedem beliebigen Testframework verwendet werden und greift auf die UIAutomation API zurück die zum Beispiel auch von den Coded UI Tests des Studios genutzt wird.

Um dies einmal vorzustellen werden wir ein paar Tests für den „Person Manager Deluxe“ schreiben. Dabei handelt es sich um ein kleines Programm mit dem ich diverse Technologien ausprobiere bevor ich sie in echte Szenarien übertrage.

Aufgabe des Programms ist es, typische Stammdaten entgegen zu nehmen und bei einem Klick auf „Save“ in eine Liste zu übertragen. Ist eine Person ausgewählt kann man ihre Daten ändern oder sie löschen und mit „New“ wird ein neues Element angelegt.

Bevor wir uns einem dieser komplexeren Dinge widmen, schreiben wir zunächst einen simplen Smoketest. Hierbei wird das Programm einfach gestartet und nach fünf Minuten geprüft ob die Instanz des Fensters noch besteht. Sollte dies nicht der Fall sein, was zum Beispiel an einer falschen Konfiguration liegen kann, bekommt man vom Test eine entsprechende Rückmeldung. Der Smoke Test ist also ein guter Indikator dafür ob wenigstens ein Mindestmaß an Funktionalität die erwartet vorhanden ist.

Um sich selbst das Leben etwas einfacher zu machen hilft es, die Applikation in einer fest definierten Testumgebung zu deployen. Anders als bei Unit Tests wollen wir nämlich das gesamte Programm prüfen und dazu müssen zum Beispiel auch Datenbanken angelegt sowie Scripte eingespielt werden.

Solche Tests sind also absolut nicht dafür geeignet auf den Rechnern der Entwickler ausgeführt zu werden, da diese Umgebungen meist sehr heterogen und durch die alltägliche Arbeit „verschmutzt“ sind, wodurch Fehler weitaus wahrscheinlicher sind als auf einem dedizierten Testsystem. Darüber hinaus brauchen diese Tests eine geraume Zeit für die Ausführung in der keine weitere Arbeit möglich ist da man zum Beispiel keine Eingaben vornehmen kann ohne das Ergebnis zu verfälschen.

[TestMethod]
[Timeout(TestTimeout.Infinite)]
public void Application_shall_still_run_after_five_minutes()
{
   var startInfo = new ProcessStartInfo();
   startInfo.FileName = applicationPath;
   startInfo.WindowStyle = ProcessWindowStyle.Normal;
   var application = Application.Launch(startInfo);
   var window = application.GetWindow("MainWindow");

   Thread.Sleep(5 * 60 * 1000);

   Assert.IsFalse(window.IsClosed);
   window.Close();
}

Der Test selbst ist mit MS Test geschrieben. Dazu wird zunächst der Timeout abgeschalten, weil es sonst aufgrund der langen Laufzeit automatisch zu einem Fehlschlag käme. Danach werden Startparameter für die zu testende Aplikation festgelegt. Diese sind nicht zwangsläufig notwendig weil man der entsprechenden Launch-Methode von Application auch einfach den Pfad zur Exe übergeben könnte.

Durch den Aufruf von Launch bzw. AttachOrLaunch wird die Exe dann mit den zuvor definierten Parameter gestartet. GetWindow bzw. GetWindows resultiert dann in der Instanz des tatsächlichen Fensters bzw. in allen Instanzen aller verfügbaren Fenster, ausgenommen modaler. Die Prüfung selbst wird dann an der IsClosed Property des Fensters festgemacht die automatisch auf true gesetzt wird sobald das Fenster nicht mehr existiert. Zuletzt schließen wir es wieder damit es bei anderen Tests nicht zu Problemen kommt.

Auch hier wieder ein kleiner Tipp. Dank TestInitialize und TestCleanup bzw. in NUnit Setup und Teardown, kann und sollte man die Applikation zu Beginn jedes Tests neu starten um am Ende herunter fahren. Auf diese Weise kann man sicher gehen, dass sich die Tests nicht gegenseitig beeinflussen. Die zusätzliche Zeit die dazu beim Test zu Stande kommt sollte kein Problem sein da, wie bereits gesagt, solche Tests am besten im Nightly Build und in einer gesonderten Umgebung laufen.

Das Ergebnis sieht dann so aus:

[TestInitialize]
public void Initialize()
{
   var startInfo = new ProcessStartInfo();
   startInfo.FileName = applicationPath;
   startInfo.WindowStyle = ProcessWindowStyle.Normal;

   var application = Application.Launch(startInfo);
   this.window = application.GetWindow("MainWindow");
}

[TestCleanup]
public void Cleanup()
{
  // man könnte auch Kill verwenden...
  window.Close();
}

[TestMethod]
[Timeout(TestTimeout.Infinite)]
public void Application_shall_still_run_after_five_minutes()
{
   Thread.Sleep(5 * 60 * 1000);
   Assert.IsFalse(window.IsClosed);
}

Soweit so gut, jetzt wollen wir aber eine neue Person anlegen, die Personendaten eingeben, „Save“ drücken und dann in der Liste nachsehen ob das Ergebnis tatsächlich dort gelandet ist wo es hin sollte.

Dazu müssen wir erst einmal den „New“-Button betätigen. Um diesen zu finden bediene ich mich einfach mal seiner Beschriftung und lege ein Suchkriterium an, das nach genau diesem Text suchen soll. Dieses wiederum übergebe ich der Get-Methode des Fensters wodurch alle Element des Fensters durchsucht werden und mir das Ergebnis gleich im richtigen Typ zurückgegeben wird.

var criteria = SearchCriteria.ByText("New");
var button = this.window.Get<Button>(criteria);
button.Click();

Dieser Typ wiederum entspricht keinem aus WPF, Silverlight o.ä. bekannten sondern einem Wrapper von White, was den Vorteil hat, dass man zum einen in den Tests unabhängig von der eingesetzten Technologie ist und sich zum anderen nicht mit den vielen Properties und Methoden rumschlagen muss die man im Test gar nicht braucht.

var textBox = this.window.Get<TextBox>("FirstNameTbx");
textBox.Text = firstName;

textBox = this.window.Get<TextBox>("LastNameTbx");
textBox.Text = lastName;

textBox = this.window.Get<TextBox>("BirthDateTbx");
textBox.Text = birthDay.ToShortDateString();

Der Zugriff auf die Textboxen gestalltet sich ähnlich einfach. Hierbei übergebe ich aber einfach nur den Namen den ich zuvor im Xaml o.ä. festgelegt habe. Auf diese Weise ist die Suche besser vor Fehler geschützt. An der Stelle gibt es jedoch einen Unterschied zwischen den einzelnen Technologien. Verwendet man zum Beispiel Windwos Forms, ist es in der Regel nicht mit dem Namen des Elements getan. Statt dessen muss man unter Umständen eine AutomationID nutzen.

criteria = SearchCriteria.ByControlType(ControlType.List);
var list = this.window.Get<ListBox>(criteria);

var listElement = list.Items.FirstOrDefault();
Assert.IsNotNull(listElement, "Element was not added.");
var text = listElement.Text;
StringAssert.Contains(text, firstName, "First name was not found.");
StringAssert.Contains(text, lastName, "Last name was not found.");

Um nun auf die Liste zuzugreifen habe ich zu Demonstrationszwecken noch ein anderes Suchkriterium angelegt. Hierbei wird ein ControlType übergeben. Dieser stammt aus dem Namespace System.Windows.Automation aus der Dll UiAutomation. Dies ist insofern „ungünstig“ da im Grunde das erste Element des entsprechenden Typs geliefert wird. Enthält die View also beispielsweise mehrere Listen, also ListView oder ListBox, kann man evtl. ein Element erhalten das man gar nicht wollte.

Um nun zu prüfen ob mein Ergebnis korrekt ist, sehe ich nach ob es ein Element gibt welches den Vornamen und Nachnamen der neuen Person enthält. Dies ist etwas tückisch, denn wer sich das Screenshot am Anfang richtig ansieht wird erkennen, dass es eigentlich auch noch eine ID zu prüfen gibt. Hier hatte ich im Xaml ein DataTemplate angelegt wodurch auf der ersten Zeile des Elements der Namen und in der zweiten die ID dargestellt wird.   Als Ergebnis erhalte ich in der Texteigenschaft aber nur die erste der beiden Zeilen. Eine Lösung für dieses Problem habe ich noch nicht gefunden.

<DataTemplate>
   <StackPanel Margin="4">
      <TextBlock FontSize="14" FontWeight="SemiBold">
         <TextBlock.Text>
            <MultiBinding StringFormat=" {0} {1} ">
               <Binding Path="FirstName" />
               <Binding Path="LastName" />
            </MultiBinding>
         </TextBlock.Text>
      </TextBlock>
    <TextBlock Margin="5,0,0,0" Text="{Binding Path=Id, StringFormat=Id: \{0\}}" />
  </StackPanel>
</DataTemplate>

Ich denke es wird recht gut deutlich was mit White alles möglich ist. Mir persönlich gefällt es weitaus besser als die recht unübersichtlichen Klassen die bei den Coded UI Tests des Visual Studios generiert werden. Denn diese zeichnen die ausgeführten Aktionen nur auf und erstellen den Test Code dann weitestgehen selbstständig.

Auf der anderen Seite muss man aber sehen, dass White seit einigen Monaten/fast einem Jahr nicht mehr weiter entwickelt wird und so manches Feature von zum Beispiel WPF nicht unterstützt wird. Bei all zu kreativen Steuerelementen ist also schon mal schnell die Luft raus. Weiterhin ist es rein theoretisch auch dafür geeignet Webseiten zu testen. Praktisch kommt man da aber sehr schnell an die Grenzen und ist mit Selenium oder evtl. CUITe besser bedient.