Klickroboter
Automatisches Testen mit Selenium
Automatisches Testen kann die Fehlerrate von Programmen drastisch reduzieren. Um die Oberflächen von Webanwendungen automatisch zu testen, muss der Rechner wie ein Mensch klicken und tippen. Mit dem Automatisierungsframework Selenium gelingt das mit wenigen Zeilen Code.
Webseite aufrufen, Formular ausfüllen, Button anklicken, prüfen, ob der richtige Text angezeigt wird. Webanwendungen testen ist einfach, aber stupide. Genau diese drei Schritte muss ein Entwickler nämlich nach jeder Änderung am Quellcode erneut ausführen, genau wie zahlreiche andere ebenso stupide Abläufe. Selbst bei winzigen Bugfixes sollte er sich diese Arbeit auf keinen Fall sparen, da jede Änderung Seiteneffekte haben könnte, die er übersieht. Damit der Entwickler noch Zeit zum Entwickeln hat, statt nur zu testen, führt an automatischen Tests kein Weg vorbei.
Selenium erlaubt genau solche automatischen Tests von Web-GUIs. Das Framework steuert den Browser, wie ein Mensch das tun würde. Das heißt, es klickt auf Links und Buttons, füllt Textfelder aus oder wählt Einträge aus Listen aus. Zeigt der Browser dabei seine Oberfläche an, kann man dem Rechner sogar dabei zusehen, wie er wie von Geisterhand die Webseite bedient.
Selenium wird seit 2004 als freie Software entwickelt (siehe ct.de/ywru) und steht unter der Apache License. Das Framework bringt eine Reihe von Bindings mit, sodass man es aus Java, C#, Ruby, JavaScript (mit Node.js) und Python nutzen kann. Wir zeigen Seleniums magische Fähigkeiten anhand einer winzigen Demo-App in Python. Die mit dem Python-Framework Flask programmierte Webanwendung zeigt lustige Kombinationen aus Vor- und Nachnamen an und erklärt bei Bedarf den Witz. Daher verwenden wir auch das Python-Binding von Selenium für die Tests. Selenium führt die Tests nicht selbst aus und prüft sie auch nicht. Darum kümmert sich das bei Python mitgelieferte unittest-Modul.
Selenium-Tests funktionieren immer gleich: Man sucht in der Baumstruktur einer HTML-Seite (DOM) nach Elementen und interagiert danach mit ihnen. Die HTML-Elemente wählt man per Name, ID, Klasse oder XPath. Da eine Web-GUI aus nichts anderem als HTML besteht (eventuell mit JavaScript garniert), kann man damit alles auslösen, was ein Nutzer auch mit seinem Browser anstellen kann. Ob das HTML per Hand geschrieben oder mit JavaScript in den DOM eingefügt wurde, spielt dafür keine Rolle.
Kopflos durch die Nacht
Damit Selenium wie von Geisterhand Knöpfe drücken und Eingabefelder ausfüllen kann, braucht das Framework einen zum Browser passenden WebDriver. Bei Firefox ist das der geckodriver, bei Chrome der chromedriver. Beides sind kleine Hilfsprogramme (Download siehe ct.de/ywru), die Sie irgendwo in den PATH kopieren müssen, beispielsweise in ein Virtualenv unter venv/bin/. Außer den WebDrivern brauchen Sie nur noch das Framework selbst, das Sie ganz leicht mit pip install selenium installieren. Im Code erzeugen Sie dann einen webdriver.Firefox() oder einen webdriver.Chrome() und steuern mit diesem Objekt das Browserfenster.
Lässt man die Tests auf einem Continuous-Integration-Server (CI-Server) laufen, nutzt man die Browser lieber mit der -headless-Option, sodass sie kein Fenster öffnen. Im Code übergibt man die Option als Objekt:
def set_firefox(self):
options = webdriver.FirefoxOptions()
options.add_argument('-headless')
self.browser = webdriver.Firefox(
options=options)
Chrome lässt den Strich vor der Option weg:
def set_chrome(self):
options = webdriver.ChromeOptions()
options.add_argument('headless')
self.browser = webdriver.Chrome(
options=options)
Suchen und finden
Der erste Schritt jedes Tests besteht darin, die Webseite abzurufen. In der sucht man danach per ID, Name, Klasse oder XPath nach dem Element, mit dem man zuerst interagieren möchte. Folgerichtig nennt Selenium die Methoden dafür „Locators“. Eine Suche kann beispielsweise bei einer Klasse mehrere Elemente liefern. Enthält die Seite beispielsweise eine lange Liste, liefert ein Locator auf der Suche nach <li>-Elementen für jeden Listenpunkt einen Treffer. Selenium hat daher Locators in zwei Ausfertigungen: Eine im Singular formulierte wie beispielsweise find_element_by_name(). Hier liefert Selenium das erste gefundene Element zurück. Wo es sinnvoll ist, gibt es noch eine Methode im Plural, beispielsweise find_elements_by_name(), die eine Liste mit allen Treffern liefert. Eine Plural-Methode muss es nicht in allen Fällen geben. Da IDs eindeutig sein müssen, gibt es nur find_element_by_id().
Nachdem Selenium das HTML-Element gefunden hat, ist die Arbeit des Frameworks erledigt. Für das Prüfen, ob Inhalt und Attribute des Elements den Erwartungen entsprechen, nutzt man nämlich das Testframework. Das stellt Assertions (auf Deutsch „Zusicherungen“) bereit, mit denen man die Erwartungen formuliert. Schlägt eine dieser Prüfungen fehl, protokolliert das Testframework den Fehler. Eine typische Assertion in Pythons unittest-Framework sieht so aus:
self.assertTrue(found)
Hier ist found eine Boolean-Variable. Die Assertion testet, ob sie wahr ist. Daneben gibt es noch Assertions, die auf Gleichheit testen, ob Elemente Teil einer Liste sind, ob eine Exception geworfen wird und jeweils welche für das Gegenteil. unittest kümmert sich darum, die Anzahl der erfolgreichen und der fehlgeschlagenen Tests zu zählen und auszugeben.
Die App
Um den Umgang mit Selenium an einem realen Beispiel zeigen zu können, haben wir mit dem leichtgewichtigen Python-Framework Flask eine Web-Anwendung programmiert (siehe ct.de/ywru). Unsere Anwendung präsentiert lustige Namen, die sie zufällig aus einer Liste auswählt. Die Namen muss man im Kopf umdrehen, um den Gag zu verstehen. Beispielsweise: Knito, Ingo ≥ Ingo Knito ≥ Inkognito, oder: Silie, Peter ≥ Peter Silie ≥ Petersilie.
Das GUI besteht aus drei Seiten: Die erste Seite zeigt einen Namen an. Ruft man die App auf, ohne einen Pfad anzugeben, wählt die App einen zufälligen Namen aus. Da Flask beim Entwickeln standardmäßig Port 5000 verwendet, erscheint der zufällige Name bei der URL http://localhost:5000. Gibt man dahinter /name und die Parameter firstname und lastname an, zeigt die App diesen Namen an. Beispielsweise http://localhost:5000/name?firstname=Lore&lastname=Mipsum für Frau Mipsum. Ein mit „Zeigs mir“ beschrifteter Button blendet den Namen in umgedrehter Form ein. Wer den Witz dann immer noch nicht versteht, kann sich mit dem neu erschienenen Button „Erklärs mir“ den Witz erklären lassen.
Auf einer weiteren Seite kann man nach einem Vor- oder Nachnamen suchen (Pfad: /search/). Drückt man den Suchen-Schalter, ohne wenigstens einen Vor- oder Nachnamen einzugeben, schlägt sie fehl. Ebenso, falls es keinen passenden Namen in der Datenbank gibt. Ein Test sollte besonders solche Grenz- und Fehlerfälle abfangen.
Zum Kasten: Automatische Testverfahren
Die dritte Seite ist eine Liste aller Namen (Pfad: /all/). Die Liste zeigt neben jedem Namen gleich die Auflösung.
Die Namen kommen der Einfachheit halber nicht aus einer Datenbank. Die App liest sie stattdessen direkt aus einer CSV-Datei. Bei automatischen Tests ist das Verwenden einer „Fixture“ genannten Datei mit Dummy-Daten ein übliches Vorgehen: Die Tests sollen ja bei jedem Durchlauf gleich ablaufen. Würden sie Datenbankeinträge anlegen, könnte das andere Tests oder den nächsten Durchlauf beeinflussen. Fixtures fallen meist viel kleiner aus als reale Datenbanken, sodass die Tests schneller ablaufen. Außerdem enthalten sie keine datenschutzrelevanten Einträge, sodass die Tester keine Datenfreigabe brauchen. Niemand will beispielsweise reale Patientendaten in den Tests einer Krankenhaussoftware.
Um unser Beispiel auszuprobieren, checken Sie es von GitHub aus und wechseln in sein Verzeichnis:
git clone https://github.com/nikolaus:
.schueler/sillynames.git
cd sillynames
Mit make venv legen Sie dort ein Virtualenv an, damit die Pakete der App nicht anderen Python-Paketen in die Quere kommen. Mit source venv/bin/activate beziehungsweise venv\bin\activate.bat unter Windows aktivieren Sie das Virtualenv in der aktuellen Konsole. Die Pakete installieren Sie anschließend einfach mit pip:
pip install -r requirements.txt
Stilfragen
Schön muss unsere Demo nicht sein, ein CSS-Stylesheet sollte sie aber trotzdem haben. Das Template base.html lädt es mithilfe der Funktion url_for():
<link rel="stylesheet"
type= "text/css"
href="{{ url_for('static',
filename='css/style.css') }}"/>
Das Stylesheet setzt lediglich einen serifenlosen Font und eine (geschmacklich durchaus diskussionswürdige) Hintergrundfarbe.
Zum Kasten: Unittest-Alternativen
Da Selenium direkt auf das DOM zugreift, findet es auch problemlos Elemente, die der Browser beispielsweise wegen eines zu großen Paddings nicht anzeigt. Ob der User ein Element tatsächlich sieht, kann man mit Selenium nicht testen.
Die Tests
Pythons Unittest-Framework kümmert sich ums Ausführen aller Testfunktionen und Einsammeln der Ergebnisse. Um das zu nutzen, schreibt man für die Tests eine Klasse, die von unittest.TestCase erbt. Enthält die Methoden, deren Name mit test beginnt, führt Unittest sie als jeweils einzelnen Test aus. Damit das beim Ausführen der Datei passiert, reichen folgende zwei Zeilen:
if __name__ == '__main__':
unittest.main()
Implementiert man die Methode setUp(), führt Unittest sie vor jedem Test aus. Die Methode ist daher die ideale Ort, um den Selenium-WebDriver zu initialisieren und den Testmethoden das Fixture zur Verfügung zu stellen:
def setUp(self):
self.set_firefox()
self.browser.implicitly_wait(3)
with open(names.CSV_FILE) as f:
self.names=names.Name.from_csv(f)
Ganz ähnlich funktioniert tearDown(), womit man nach jedem Test wieder aufräumen kann:
def tearDown(self):
self.browser.quit()
In den test-Methoden stehen außer den Membervariablen der Testklasse noch die assert-Methoden von TestCase zur Verfügung. Mit diesen prüft man boolesche Ausdrücke, Variablen auf Gleichheit, Einträge in Listen und ob Funktionen bestimmte Exceptions werfen. Was so überprüft wurde, taucht in der Zusammenfassung auf, die Unittest erstellt, nachdem es alle Tests ausgeführt hat.
Der erste Test soll prüfen, ob die App einen der Namen aus der CSV-Datei anzeigt, wenn man http://localhost:5000 aufruft. Den Abruf der URL übernimmt der vom WebDriver ferngesteuerte Browser:
self.browser.get(
'http://127.0.0.1:5000')
Nun gilt es das HTML-Element mit dem Namen zu finden. Dafür eignet sich der „Inspektor“, den Firefox und Chrome mit F12 öffnen (auf dem Mac mit Alt+Cmd+I). Damit kann man Elemente in der Seite anklicken und bekommt alle relevanten Informationen über das Element. Bei dem <p> mit dem Namen beispielsweise, dass es die id „name“ hat. Mit der findet Selenium das Element und extrahiert seinen Inhalt:
t = self.browser.find_element_by_id(
'name').text
In self.names stehen die Namen in der Reihenfolge Vorname, Nachname, sodass der Test nicht einfach prüfen kann, ob t in self.names enthalten ist. Die Funktion get_puzzle_name() dreht den Namen um, sodass der Code ihn überprüfen kann. Da die App dieselbe Funktion verwendet, prüft der Test nicht, ob sie fehlerfrei ist, sondern lediglich, ob ihre Ausgabe im HTML landet. Ob die Funktion richtig arbeitet, sollte ohnehin ein getrennter Unittest prüfen, da sich das unabhängig von der Weboberfläche testen lässt.
Falls t vorn oder hinten Whitespace enthält, soll der Test nicht fehlschlagen. Deswegen entfernt der Test mit t=t.strip() alle Leerzeichen, Tabs und so weiter. Danach kann er prüfen, ob einer der Namen exakt übereinstimmt:
self.assertIn(t.strip(),
[name.get_puzzle_name() for
name in self.names])
Der nächste Test prüft, ob die Seite auch den unvertauschten Namen anzeigt. Dafür sucht der Test den Button „Zeigs mir“ und klickt ihn an:
self.browser.find_element_by_xpath(
'//input[@value="Zeigs mir"]'
).click()
Der Locator hangelt sich hier mit einem XPath durch das DOM, da der Button keine eindeutige id hat. Die Prüfung des Inhalts von <p id="show"> funktioniert genau wie zuvor nur mit der Funktion get_funny_name() statt get_puzzle_name().
Ein wenig komplizierter wird es beim Test für die ausführliche Erklärung. Der Button, der die Erklärung einblendet, erscheint nämlich nur bei den Namen, bei denen auch eine ausführliche Erklärung in der CSV-Datei hinterlegt ist. Fehlt sie, zeigt die App gar keinen Button an. Ein Test, der den Button drückt, soll aber nicht fehlschlagen, falls er fehlt.
Findet Selenium ein Element nicht, wirft das Framework eine NoSuchElementException. Die kann der Test abfangen, um ohne Fehler abzubrechen, falls es den Button nicht gibt:
try:
be=self.browser.find_element_by_id(
'explainbutton')
except NoSuchElementException:
return
Ein Test, der kein assert ausführt, gilt als erfolgreich.
Such mich
Der nächste Test gilt der Suchseite. Wenn die Suche erfolgreich ist, landet man auf der Seite für den gesuchten Namen. Das ist dieselbe Seite, die im vorherigen Test einen zufälligen Name serviert hat. Diesmal weiß der Test aber, welchen Namen er erwartet, und prüft direkt das gewünschte Ergebnis.
Beim Suchen kann man aber auch viel falsch machen. Die Tests sollten nicht nur den Erfolg, sondern auch gerade die Fehlerbehandlung prüfen. Ein Nutzer könnte beispielsweise in die Suchfelder gar nichts eingeben und dann trotzdem den Submit-Button drücken. Oder er sucht nach einem Namen, den es nicht gibt. Beides sollte zu verschiedenen Fehlermeldungen führen.
Das Textfeld zur Eingabe eines Vornamens findet der Test mit einem XPath-Ausdruck:
self.browser.find_element_by_xpath(
'//input[@name="firstname"]'
).send_keys('Ingo')
Da der Test mit dem Eingabefeld nichts anderes machen will, als ein paar Buchstaben einzutippen, muss er ihn keiner Variable zuweisen.
Anschließend klickt der Test den Suchknopf:
self.browser.find_element_by_xpath(
'//input[@value="Suchen"]').click()
Das lädt die Ergebnisseite, auf der nun der Name stehen muss:
self.assertEqual(
self.browser.find_element_by_id(
'name').text.strip(),
'Name: Knito, Ingo')
Bei Fehlern gibt es ein Extraelement mit dem Attribut flashes, in dem die App die Fehler in roter Schrift angezeigt. Die Fehlerbeschreibung holt sich der Test wieder aus names.py, damit er nicht fehlschlägt, nur weil man die Formulierung geändert hat:s
def test_search_fail_empty(self):
self.browser.get(
'http://127.0.0.1:5000/search')
self.browser.find_element_by_xpath(
'//input[@value="Suchen"]').click()
self.assertEqual(
self.browser.find_element_by_xpath(
'//ul[@class="flashes"]/li'
).text.strip(),
names.ERROR_EMPTY_SEARCH)
Analog testet ein zweiter Test die richtige Fehlermeldung, falls man nach einem Namen sucht, den es nicht gibt.
Und jetzt alle
Der letzte Test des Beispiels prüft, ob die Seite für alle Namen auch wirklich alle Namen anzeigt:
def test_all_names(self):
self.browser.get(
'http://127.0.0.1:5000/all')
names_without_heading=self.names[1:]
list_items=self.browser.find_:
.elements_by_xpath(
'//li[@class="nameentry"]')
self.assertCountEqual(
[name.get_puzzle_name() for
name in names_without_heading],
[item.text.strip() for
item in list_items])
Der XPath //li[@class="nameentry"] findet hier die ganze Liste mit Namen, da die <li>-Elemente alle die Klasse „nameentry“ haben. Die Funktion assertCountEqual() vergleicht diese Liste mit der Liste aller Namen. Die Reihenfolge ist dieser Assertion egal. Entgegen des verwirrenden Namens prüft sie aber nicht nur die Länge. Möchte man auch die Reihenfolge prüfen, bringt Unittest dafür assertListEqual() mit.
Wir haben unsere Beispiel-App sehr einfach gehalten, sodass sie sich mit nur wenigen Tests bereits gut testen lässt. Bei realen Anwendungen sind erheblich mehr Tests nötig, um wirklich alle möglichen Eingabefehler und Klickwege zu prüfen. Die einzelnen Tests werden aber auch bei echten Anwendungen nicht viel komplexer als die hier gezeigten. Wer den Aufwand dennoch scheut, sollte aber zumindest darüber nachdenken, jeden gefundenen Bug mit einem automatischen Test zu prüfen. Das verhindert nämlich, dass man später versehentlich den gleichen Fehler wieder einbaut. (pmk@ct.de)
Quellcode und Dokumentation:ct.de/ywru