Email-Korrespondenz in Selenium integrieren

Das häufigste Testszenario, in dem Emails integriert sind, ist der Registrierungsvorgang bei einer Internet-Anwendung. Nachdem der neue User das Kontaktdatenformular ausgefüllt und abgeschickt hat, bekommt er eine Email in die Mailbox, welche ein Sicherheitsmerkmal (Link, Passwort) enthält, dessen Benutzung die Registrierung erfolgreich abschließt.
Nun ist der Einsatz von Selenium weitgehend auf die Reichweite des Browsers beschränkt, will sagen, dass man nicht ohne andere Tools und Aufwand auf Mailclients (Outlook, Thunderbird, …) bei der Automatisierung der Tests zurückgreifen kann. Es gibt jedoch die Möglichkeit sich eines Webmail-Clients zu bedienen, der eine Administrationsoberfläche für Emails darstellt, die vollständig innerhalb eines Browsers bedient werden kann. Den Ansatz über einen Instant-Email-Dienst (spambog.com, instant-mail.de, …) zu gehen, empfehle ich für den produktiven Testeinsatz nicht, da diese im Emailempfang mindestens verzögert, wenn nicht gar gänzlich unzuverlässig sind. So hatte beispielsweise spambog.de bei einem Einsatz eine Verzögerung von genau einer Stunde. Bei meinem aktuellen Auftrag: www.hiorg-server.de wird ausdrücklich von der Nutzung von den bekannten Mailprovidern (web.de, hotmail.de, …) abgeraten, da manche Funktionen des hiorg-server.de dort als Spam gewertet werden (natürlich ohne es zu sein), weshalb diese Möglichkeit auch wegfällt. Ich nehme mal an, dass die meisten Tester mindestens einen Webspace mit eigener Domain und angeschlossenem Webmail-Client besitzen. Dies ist für den Standardgebrauch in Selenium ausreichend. Ich benutze für diesen Artikel Atmail, weshalb auch meine WebMail-Klasse auf diesen WebMailer ausgerichtet ist.
Die Auslagerung der Emailfunktionen in eine separate Mail-Klasse entspricht der Anforderung des Page Object Patterns, dass man die Navigation beliebig umbauen kann, ohne jedoch die Testklassen selbst ändern zu müssen. Dieses Design schließt auch ein, dass der domain part der Email-Adresse (wiki) nicht in der Testklasse auftaucht. Diese Testklasse kenne nur die Methoden der Mail-Klasse, ohne ihre Implementierungsdetails, also in unserem Fall:
new WebMail().getRegistrationText(String keywordInSubject, boolean löscheEmail)
1.) Das keywordInSubject ist notwendig, um die Email meines Threads eindeutig in meiner Mailbox zu identifizieren. Bei paralleler Ausführung unterschiedlicher Testcases (mit Email-Beteiligung) kann man naturgemäß als Tester nicht die Reihenfolge vorausahnen, wann welcher Thread die Mailbox erreicht, um dort “seine” Email abzuholen. Hier entsteht also eine Race-Condition zwischen den parallel getesteten Testcases. Logiken, wie “nimm die erste Email in der Mailbox” scheiden daher aus. Wir benötigen eine Möglichkeit wie der Thread “seine” Email erkennt, diese also eindeutig in der Mailbox unterscheidbar ist.
2.) mit boolean löscheEmail kann ich mich entscheiden, ob nach der Prüfung die Registriermail gelöscht werden kann, um unseren Stall sauber zu halten…
Und hier der Code:
EmailTest.java

package de.hiorgserver.testhiorgserver;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.testng.Assert;
import org.testng.annotations.Test;
public class EmailTest {
    @Test
    public void testEmail() throws MalformedURLException, InterruptedException {
        String username = "cloudbees_it-kosmopo";
        String accessKey = "XXX";
        String url = "http://" + username + ":" + accessKey + "@ondemand.saucelabs.com:80/wd/hub";
        DesiredCapabilities cap = DesiredCapabilities.firefox(); //Browserdefaults auswählen
        cap.setCapability("version", "15"); //Version des Browsers festlegen
        cap.setCapability("platform", "Windows 2008"); //Betriebssystem festlegen
        cap.setCapability("username", username); //Mein Username bei SauceLabs
        cap.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLabs
        WebDriver driver = new RemoteWebDriver(new URL(url),cap);
        driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS);
        WebMail webmailer = new WebMail(driver, "test");
        String emailText = webmailer.getRegistrationText("mein Blog", true);
        Assert.assertTrue(emailText.contains("Benutzerkonto"), "Prüfung, ob der Registrierungstext das Schlüsselwort 'Benutzerkono' enthält.");
        driver.quit();
    }
}

WebMail.java

package de.hiorgserver.testhiorgserver;
import java.util.List;
import java.util.Set;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.interactions.Actions;
public class WebMail {
    WebDriver driver;
    String mainWindow;
    WebMail (WebDriver driver,String localPart) {
        this.driver = driver;
        String url = "http://webmail.it-kosmopolit.de/";
        String password = "XXX";
        //Öffne ein neues Fenster, dann kann ich es zwischendurch immer wieder verwenden.
        mainWindow = driver.getWindowHandle();
        JavascriptExecutor js = (JavascriptExecutor) driver;
        js.executeScript("window.open('','Email-Fenster')");
        driver.switchTo().window("Email-Fenster");
        driver.get(url);
        driver.switchTo().frame("GroupingFrame");
        driver.findElement(By.id("username")).sendKeys(localPart);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.name("Submit")).click();
    }
    String getRegistrationText(String keywordInSubject, boolean löscheEmail) throws InterruptedException {
        if ( ! driver.getCurrentUrl().contains("http://webmailtest.it-kosmopolit.de")) driver.switchTo().window("Email-Fenster") ;
        Thread.sleep(2000); //nur für's Schauvideo angehalten.
        Actions action = new Actions(driver);
        action.doubleClick(driver.findElement(By.xpath("//*[contains(text(),'" + keywordInSubject + "')]")));
        action.perform();
        Thread.sleep(2000); //nur für's Schauvideo angehalten.
        driver.switchTo().frame("msgwindow1");
        String link = driver.findElement(By.xpath("//*[contains(text(),'Registrierung')]")).getText();
        if (löscheEmail) {
            driver.switchTo().defaultContent();
            driver.findElement(By.id("Folderdelete")).click();
        }
        Thread.sleep(2000); //nur für's Schauvideo angehalten.
        driver.get(link);
        Thread.sleep(2000); //nur für's Schauvideo angehalten.
        String text = driver.findElement(By.tagName("body")).getText();
        driver.switchTo().window(mainWindow);
        return text;
    }
}

Und hier der Testverlauf samt Video: https://saucelabs.com/tests/5e80dba5bfe74327aa68aaaefaa08737#
Hinweis: der Test würde noch schneller laufen, wenn ich nicht den Thread mehrmals schlafen gelegt hätte, damit der Programmverlauf im Video leichter nachvollzogen werden kann.
“Verschärfte” Testbedingungen
Die Internetanwendung kann erzwingen, dass die Email-Adressen innerhalb der Internet-Anwendung eindeutig sein müssen. Dies ist beispielsweise dann der Fall, wenn die Email gleichzeitig die Bezeichnung des Users ist. Wenn ich nun gegen eine Testmaschine teste, habe ich bei einer hartcodierten Emailadresse nur einen Test frei. Jeden wiederholten/parallelen Testdurchlauf wird mir die Testumgebung quittieren mit: “Ihre Email-Adresse ist bereits vergeben”. Dann müsste theoretisch jedes Mal die Datenbasis der Testumgebung auf den “Zeitpunkt 0” zurückgestellt werden – ein hoher manueller Aufwand!
Einen Lösungsansatz für diese verschärften Testbedingungen möchte ich an dieser Stelle nur mal skizzieren:

  1. Neuen Webspace incl. Domain erstellen
  2. als local part (wiki) einen Timestamp (natürlich nur erlaubte Zeichen) nehmen. Durch den (Millisekunden-genauen) Timestamp ist mit hoher Wahrscheinlichkeit die verwendete Email-Adresse unique. Anhand des Empfängers kann man die Emails im Webmail-Client also eindeutig identifizieren.
  3. Forward mail of non-existent user to following-default Emailadresse: test@neuedomain.de
  4. test@neuedomain.de per Webmail-client aufrufen

Selenium WebDriver MindMap


This draft of a Selenium WebDriver MindMap was drawn with the wonderful https://bubbl.us/. The goal of this draft is to propose an overview of the main subjects while working with Selenium WebDriver and where it make sense to acquire knowledge sooner or later.
Integration
WebDriver is developed for the real world and therefore it depends on real projects with real setups. So a Selenium developer needs knowledge of these environments.
Agile Development
Web Browser Automation is an important part of agile development http://en.wikipedia.org/wiki/Agile_software_development
Cloud
www.saucelabs.com, www.cloudbees.com, …
Build-Tools
Maven, Ant, …
Continous Integration
The shorter the test cycles, the more Web Browser Automation pay off. CI-Server: Jenkins
WebDriver API
It’s the core of the Selenium engagement: http://selenium.googlecode.com/svn/trunk/docs/api/java/index.html
Selenium World
Knowing the history (Selenium 1 -> Selenium 2 = WebDriver), knowing the key players (e.g. Simon Stewart), knowing the knowledge base (http://seleniumhq.org/), knowing support ressources (https://groups.google.com/forum/?fromgroups#!forum/selenium-users), …
Pattern
There are design patterns, like Page Object-Pattern.
And there are recipes for recurrend problems, like “check if an element exists” (http://stackoverflow.com/questions/6521270/webdriver-check-if-an-element-exists)
Internals
Knowing the internals isn’t needed in straight forward problems. But it’s very helpful with complex problems and to develop sophisticated solutions.
Driver Internals
http://code.google.com/p/selenium/wiki/ArchitecturalOverview#A_Layered_Design
Browser Internals
Long time the rendering engines of browsers have been black boxes. Tali Garsiel helped a lot to reveal these internals: http://taligarsiel.com/Projects/howbrowserswork1.htm
Programming
Except for stumbeling with Selenium IDE, skills in programming are fundamental for Web Browser Automation with Selenium.
Programming Language
All programming languages with bindings to WebDriver API: http://seleniumhq.org/docs/03_webdriver.html#setting-up-a-selenium-webdriver-project
IDE
NetBeans, Eclipse, …
HTML
target of all the efforts
XPath
needed for sophisticated navigation; fantastic tutorial:
Test-Framework
TestNG, junit, …
Browser Tools
in the MindMap you find addons for my favorite development browser: Firefox.

Akkordeon-Menü mit Selenium

Beim Umgang mit einem Akkordeon-Menu (hier umgesetzt mit jquery – das Beispiel befindet sich am Ende des Artikels: http://viralpatel.net/blogs/create-accordion-menu-jquery/) ergibt sich bei der Testautomation das Problem, dass ich nicht einfach kodieren kann:
1.) Clicke auf “Technology”-Link.
2.) Clicke auf “Twitter”-Link.
um zur entsprechenden Seite zu gelangen. In einem komplexeren Testfall, bei dem ich vielmals mit dem Akkordeon-Menü arbeite, kommt es vor, dass das Technology-Menü bereits geöffnet ist. Dann würde ein Klicken das Menü wieder schließen und ich käme an den Twitter-Link nicht mehr ran – mein Testfall würde mit einer ElementNotVisibleException über den Jordan gehen. Daher müssen wir noch eine kleine Abfrage einbauen:
1.) Falls ein das Technology-Menü nicht geöffnet ist (=”Twitter”-Link nicht da ist), öffne es.
2.) Clicke auf Twitter-Link.
 
Abfragen, ob ein bestimmtes Element existiert, funktionieren im Webdriver nur indirekt, wie hier ausgeführt: http://stackoverflow.com/questions/6521270/webdriver-check-if-an-element-exists Um die Diskussion zusammen zu fassen (und zu ergänzen):
1.) Die eleganteste Lösung die Existenz eines Elements zu prüfen ist
driver.findElements( By.id("...") ).size() != 0
2.) Wenn man mit implicit wait arbeitet (wie im Folgenden angenommen), dann sollte man den Sekundenwert in einer Variable standardSecondsToWait speichern und zwar dort, wo man den implicit wait initial festlegt:
int standardSecondsToWait = 30;
driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);

3.) Der driver wartet die komplette Zeit des implicit wait, bis er endlich entscheidet, dass ein tatsächlich nicht vorhandenes Element auch tatsächlich nicht vorhanden ist. Erst dann entscheidet er, dass die o.g. Bedingung falsch ist und erst dann fährt er mit der Programmausführung fort. Bei mehreren solcher Prüfungen in einem Testcase und entsprechender Länge des implicit wait verschwenden wir kostbare Testzeit.
4.) Eine good practice ist es daher, die Bedingung isoliert und innerhalb von einer “implicit wait-Klammer” abzufrühstücken:

driver.manage().timeouts().implicitlyWait(0, TimeUnit.SECONDS);
boolean exists = driver.findElements( By.id("...") ).size() != 0
driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);
if (exists) {do something} (...)

Konsequenterweise umschließt die “implicit wait-Klammer” nur die Abfrage dieser einen Bedinungung und nicht mehr, denn wenn man ein ausgesprochener Fan der implicit waits ist (iGs zu den Fans der explicit waits), dann sollte man auch nur im notwendigen Fall davon abweichen.
Am Ende des ersten Absatzes sprach ich von der Prüfung, ob der “Twitter”-Link da ist. Der zweite Absatz handelt von der Prüfung, ob ein Element existiert. Genau gesagt geht es jedoch darum, ob ein Element im Akkordeon-Menü (im Beispiel der “Twitter”-Link) sichtbar ist. Die Prüfung auf Existenz (also ob der Knoten im DOM tree angelegt ist) greift zu kurz, denn ein existierendes Element könnte vom stylesheet noch display:none rangeklatscht bekommen: es existiert, ist jedoch nicht sichtbar. Genau das macht aber das Akkordeon-Menü aus: nur die Links des geöffneten Submenüs sind sichtbar – die Links der anderen Submenüs existieren zwar, sind jedoch unsichtbar (display:none). WebDriver quittiert den Klickversuch dieser unsichtbaren Links mit einer ElementNotVisibleException (gemäß dem Anspruch einen menschlichen User zu simulieren). Wir müssen den Ansatz im zweiten Absatz also noch anpassen:

driver.manage().timeouts().implicitlyWait(0, TimeUnit.SECONDS);
        Boolean isOffen = driver.findElement(By.xpath("//a[contains(text(),'Football')]")).isDisplayed();
        driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);

Achtung: Würde ich hier mittels linkText() oder partialLinkText() suchen, statt mir xpath(), würde dieser Code nicht funktionieren. Diese beiden Methoden erfordern einen sichtbaren Text und wenn sie diesen nicht finden, brechen Sie den Testlauf mit “NoSuchElement” ab (http://www.seleniumhq.org/docs/03_webdriver.html#by-link-text)
Und hier der vollständige Code:

package de.it-kosmopolit.meinblog;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.internal.Locatable;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.testng.annotations.Test;
public class Accordeon {
    @Test
    public void testAccordeon() throws InterruptedException, MalformedURLException {
        String username = "it-kosmopolit";
        String accessKey = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
        String url = "http://" + username + ":" + accessKey + "@ondemand.saucelabs.com:80/wd/hub";
        DesiredCapabilities cap = DesiredCapabilities.firefox(); //Browserdefaults auswählen
        cap.setCapability("version", "15"); //Version des Browsers festlegen
        cap.setCapability("platform", "Windows 2008"); //Betriebssystem festlegen
        cap.setCapability("username", username); //Mein Username bei SauceLabs
        cap.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLabs
        WebDriver driver = new RemoteWebDriver(new URL(url), cap);
        int standardSecondsToWait = 30;
        driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);
        driver.get("http://viralpatel.net/blogs/create-accordion-menu-jquery/");
        // das Fenster an die Position scrollen, mit der man das "Schauspiel" am besten sieht
        // Code-Quelle: http://stackoverflow.com/a/9852744
        Locatable hoverItem = (Locatable) driver.findElement(By.xpath("//h2[contains(text(),'Online Demo')]"));
        int y = hoverItem.getCoordinates().getLocationOnScreen().getY();
        ((JavascriptExecutor) driver).executeScript("window.scrollBy(0," + y + ");");
        Thread.sleep(2000); //nur für's "Schauspiel"
        //das Beispiels-Akkordeon befindet sich in einem namenlosen iframe ...
        WebElement properFrame = driver.findElement(By.xpath("//iframe/preceding-sibling::br/following-sibling::iframe"));
        driver.switchTo().frame(properFrame);
        driver.manage().timeouts().implicitlyWait(0, TimeUnit.SECONDS);
        Boolean isOffen = driver.findElement(By.xpath("//a[contains(text(),'Football')]")).isDisplayed();
        driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);
        //Das Sports-Menü ist initial tatsächlich geöffnet.
        //Ohne die Prüfung würde es hier also knallen (ElementNotVisibleException)!
        if (isOffen) {
            driver.findElement(By.xpath("//a[contains(text(),'Football')]")).click();
            Thread.sleep(2000); //nur für's "Schauspiel"
        } else {
            driver.findElement(By.xpath("//li[contains(text(),'Sports')]")).click();
            driver.findElement(By.xpath("//a[contains(text(),'Football')]")).click();
        }
        driver.manage().timeouts().implicitlyWait(0, TimeUnit.SECONDS);
        Boolean isOffen2 = driver.findElement(By.xpath("//a[contains(text(),'Twitter')]")).isDisplayed();
        driver.manage().timeouts().implicitlyWait(standardSecondsToWait, TimeUnit.SECONDS);
        if (isOffen2) {
            driver.findElement(By.xpath("//a[contains(text(),'Twitter')]")).click();
        } else {
            driver.findElement(By.xpath("//li[contains(text(),'Technology')]")).click();
            Thread.sleep(2000); //nur für's "Schauspiel"
            driver.findElement(By.xpath("//a[contains(text(),'Twitter')]")).click();
            Thread.sleep(2000); //nur für's "Schauspiel"
        }
        driver.quit();
    }
}

Und hier der Testcase zum Anschauen: https://saucelabs.com/tests/e7cdafe4389b4585867399af71907ea2#
(Hinweis: die Links sind “blinde” Demolinks – führen also niergens hin, im Video erkennt man das Klicken an einer Farbänderung des Links von blau zu rot)