Selenium: Parallelisierung durch Parametrisierung

Im Blogbeitrag Parallelisierung von Selenium-Tests mittels Sauce Labs blieb ich noch eine skalierbare Parallelisierungslösung schuldig. TestNG bietet mit seinem DataProvider die Möglichkeit Parameter an seine Testmethoden zu übergeben (http://testng.org/doc/documentation-main.html#parameters-dataproviders). Dieses Tool möchte ich nun nutzen, um meinen Testmethoden zu sagen, welche Browser sie für meine Seleniumtests verwenden sollen. Als „Browserpark“ nutze ich wieder mal die Cloud der Sauce Labs Inc.
Den DataProvider kann man auch außerhalb der Testklasse speichern und dann in jede Testklasse integrieren. Meist sind bei den Funktionstests zahlreiche Testklassen im Spiel. Da ich nicht bei jedem major release von Firefox und Konsorten in jede Testklasse einzeln gehen möchte, um diesen dort hinzuzufügen, drängt sich Auslagerung des DataProviders in eine eigene .class geradezu auf.
Die verwendeten Browser sind:

  1. Firefox, Version 12, Linux
  2. Internet Explorer, Version 9, Windows Vista
  3. Chrome, Version von Sauce Labs bestimmt, Windows Vista
  4. Safari, Version 5, Mac

Ich verwende im Beispiel also zwei Testklassen namens Parallel1Test.java und Parallel2Test.java, sowie eine Klasse worin ich den DataProvider auslagere, die ich, angelehnt an einen Fuhrpark, mit BrowserPark.java bezeichne.
Parallel1Test.java

import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.testng.Assert;
import org.testng.annotations.Test;
public class Parallel1Test {
    @Test(description = "Testet Erreichbarkeit meiner Angebotsseite.", dataProvider = "BrowserPark", dataProviderClass = BrowserPark.class)
    public void paralleltest1(URL url, DesiredCapabilities caps) throws MalformedURLException, InterruptedException {
        //Konfigurationen
        caps.setCapability("name", "paralleltest1: check Michael Wowro");
        WebDriver driver;
        if (BrowserPark.isDevelopedLocal) driver = new FirefoxDriver(caps);
        else driver = new RemoteWebDriver(url, caps);
        driver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS);
        //Testcase
        driver.get("https://www.it-kosmopolit.de");
        driver.findElement(By.linkText("KONTAKT UND ANGEBOT")).click();
        WebElement profil = driver.findElement(By.partialLinkText("xing"));
        Assert.assertTrue(profil.getText().contains("Michael_Wowro"));
        //Beenden
        driver.quit();
    }
}

Parallel2Test.java

import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.testng.Assert;
import org.testng.annotations.Test;
public class Parallel2Test {
    @Test(description = "Testet Erreichbarkeit meiner Angebotsseite.", dataProvider = "BrowserPark", dataProviderClass = BrowserPark.class)
    public void paralleltest2(URL url, DesiredCapabilities caps) throws MalformedURLException, InterruptedException {
        //KonfigurationeN
        caps.setCapability("name", "paralleltest2: check xing");
        WebDriver driver;
        if (BrowserPark.isDevelopedLocal) driver = new FirefoxDriver(caps);
        else driver = new RemoteWebDriver(url, caps);
        driver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS);
        //Testcase
        driver.get("https://www.it-kosmopolit.de");
        driver.findElement(By.linkText("KONTAKT UND ANGEBOT")).click();
        WebElement profil = driver.findElement(By.partialLinkText("xing"));
        Assert.assertTrue(profil.getText().contains("xing"));
        //Beenden
        driver.quit();
    }
}

browserPark.java

import java.net.MalformedURLException;
import java.net.URL;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.testng.annotations.DataProvider;
// Übersicht über Browser-Angebot bei Sauce Labs: https://saucelabs.com/docs/browsers
public class BrowserPark {
    static boolean isDevelopedLocal;
    static String username = "it-kosmopolit";
    static String accessKey = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
    static String url = "http://" + username + ":" + accessKey + "@ondemand.saucelabs.com:80/wd/hub";
//    /* Entwicklung gegen meinen lokalen Firefox-Browser */
//    @DataProvider(name = "BrowserPark")
//    public static Object[][] developLocal() throws MalformedURLException {
//        isDevelopedLocal = true;
//        DesiredCapabilities capFF = DesiredCapabilities.firefox(); //Browserdefaults auswählen
//
//        return new Object[][]{
//                    {new URL("http://www.egal.de"), capFF}
//        };
//    }
//    /* Entwicklung gegen einen ausgesuchten Browser in der Sauce Labs-Cloud */
//    @DataProvider(name = "BrowserPark")
//    public static Object[][] developRemote() throws MalformedURLException {
//        isDevelopedLocal = false;
//
//        // FF
//        DesiredCapabilities capFF = DesiredCapabilities.firefox(); //Browserdefaults auswählen
//        capFF.setCapability("version", "12"); //Version des Browsers festlegen
//        capFF.setCapability("platform", "Linux"); //Betriebssystem festlegen
//        capFF.setCapability("username", username); //Mein Username bei SauceLabs
//        capFF.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLabs
//
//        return new Object[][]{
//                    {new URL(url), capFF}
//        };
//    }
    /* Produktivmodus mit den von meinen Kunden gewünschten Browser-Betriebssystem Kombinationen */
    @DataProvider(name = "BrowserPark")
    public static Object[][] productive() throws MalformedURLException {
        isDevelopedLocal = false;
        // FF
        DesiredCapabilities capFF = DesiredCapabilities.firefox(); //Browserdefaults auswählen
        capFF.setCapability("version", "12"); //Version des Browsers festlegen
        capFF.setCapability("platform", "Linux"); //Betriebssystem festlegen
        capFF.setCapability("username", username); //Mein Username bei SauceLabs
        capFF.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLabs
        // IE
        DesiredCapabilities capIE = DesiredCapabilities.internetExplorer(); //Browserdefaults auswählen
        capIE.setCapability("version", "9"); //Version des Browsers festlegen
        capIE.setCapability("platform", "Windows 2008"); //Betriebssystem festlegen
        capIE.setCapability("username", username); //Mein Username bei SauceLabs
        capIE.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLab
        //Chrome
        DesiredCapabilities capCH = DesiredCapabilities.chrome(); //Browserdefaults auswählen
        capCH.setCapability("platform", "Windows 2008"); //Betriebssystem festlegen
        capCH.setCapability("username", username); //Mein Username bei SauceLabs
        capCH.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLab
        //Safari
        DesiredCapabilities capSA = DesiredCapabilities.safari(); //Browserdefaults auswählen
        capSA.setCapability("version", "5");//Version des Browsers festlegen
        capSA.setCapability("platform", "Mac 10.6");//Betriebssystem festlegen
        capSA.setCapability("username", username); //Mein Username bei SauceLabs
        capSA.setCapability("accessKey", accessKey); //Mein Access-key bei SauceLab
        return new Object[][]{
                    {new URL (url), capFF},
                    {new URL (url), capIE},
                    {new URL (url), capCH},
                    {new URL (url), capSA}
        };
    }
}

Wie im Code zu sehen, habe ich drei Versionen des DataProviders:

  1. zum Entwickeln der Tests gegen meinen aktuell lokal installierten Firefox
  2. zum Entwickeln der Tests gegen einen ausgewählten Browser in der Sauce Labs-Cloud
  3. und den vom Kunden gewünschten Browserpark, gegen den seine Internet-Anwendung getestet wird (sozusagen der Produktivmodus). Und hier kann man sich die Browser-Betriebssystem-Kombinationen für seinen individuellen Browserpark zusammenstellen: https://saucelabs.com/docs/browsers

Die beiden Versionen, die ich gerade nicht nutzen möchte, schalte ich einfach per Auskommentieren aus.
Alle 3 Klassen liegen in meiner Maven-Verzeichnisstruktur \src\test\java. Ein entsprechendes Maven-Projekt habe ich in netbeans angelegt. Ein Click auf „test“ und nachdem mir netbeans den erfolgreichen Testlauf bestätigt, gehe ich in mein Sauce Labs-Konto um mir die Schätzchen anzuschauen. Und siehe da, für jeden meiner vier verschiedenen Browser sind je beide Tests erfolgreich durchgelaufen:

Hinweis: Da das für diesen Blogpost verwendete Sauce Labs-Konto auf zwei Threads beschränkt ist, sind in diesem Beispiel immer nur zwei Testläufe parallel.
Weil ich es richtig gut finde, dass Sauce Labs von jedem meiner Testläufe ein Video aufzeichnet, schau ich mir eins auch noch mal an:

Selenium ganz in der Cloud

Meine bisherigen Blogbeiträge haben nur die Testausführung in die Cloud (saucelabs.com) geschickt (also sozusagen den „Tanz“ mit den Browsern“). Der Seleniumcode residierte dabei auf meinem lokalen Rechner und wurde dort auch ausgeführt. Wie wäre es nun, wenn ich als Selenium-Dienstleister meinem Kunden einfach nur ein „Testbutton im Internet“ bereitstellen müsste, damit er seine Tests zum Fliegen bringt und dort dann anschließend auch das Testergebnis zu sehen bekommt. All dies kann man beispielsweise mit cloudbees.com umsetzen, wo Kohsuke Kawaguchi, der Papa von Jenkins (und Hudson) inzwischen angestellt ist. Dort bekommt man beispielsweise eine vorkonfigurierte Jenkins-Instanz, eine Maveninstallation, diverse Repositories usw. – alles als Platform as a Service in der Cloud.
In diesem Blogpost verwendete Tools: Cloudbees DEV@cloud (svn-repository, Jenkins, Sauce Labs OnDemand), Maven, TestNG, Subversion, TortoiseSVN
In diesem Blogpost verwendeter Code:
Maven – pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>de.itkosmopolit.blog</groupId>
    <artifactId>paralleltest</artifactId>
    <version>1.0-SNAPSHOT</version>
	 <properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	 </properties>
    <dependencies>
        <dependency>
            <groupId>org.testng</groupId>
            <artifactId>testng</artifactId>
            <version>5.13.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium</artifactId>
            <version>2.0b1</version>
        </dependency>
	</dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
				<version>2.12.3</version>
                <configuration>
					<parallel>methods</parallel>
					<threadCount>2</threadCount>
                </configuration>
            </plugin>
		</plugins>
    </build>
</project>

ParallelTest.java (Selenium-Code)

package de.itkosmopolit.blog;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.By;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.testng.annotations.Test;
import org.testng.Assert;
import java.net.URL;
import java.net.MalformedURLException;
public class ParallelTest {
    @Test
    public void parallel_with_firefox() throws MalformedURLException{
		//Konfigurationen
		DesiredCapabilities caps = DesiredCapabilities.firefox(); //Browser auswählen
        caps.setCapability("version", "13"); //Version des Browsers festlegen
        caps.setCapability("platform", "Linux"); //Betriebssystem festlegen
		caps.setCapability("name", "parallelTestFirefox"); //hier wird der Test benannt und in SauceLabs wieder auffindbar.
		caps.setCapability("username", "cloudbees_it-kosmopo"); //Mein Username bei SauceLabs
		caps.setCapability("accessKey", "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); //Mein Access-key bei SauceLabs
        RemoteWebDriver driver = new RemoteWebDriver(
                new URL("http://cloudbees_it-kosmopo:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx@ondemand.saucelabs.com:80/wd/hub"),
                caps);
		//Testcase
        driver.get("https://www.it-kosmopolit.de");
        driver.findElementByLinkText("KONTAKT UND ANGEBOT").click();
		WebElement profil = driver.findElement(By.partialLinkText("xing"));
		Assert.assertTrue(profil.getText().contains("Michael_Wowro"));
		//Beenden
        driver.quit();
    }
	 @Test
    public void parallel_with_internetExplorer() throws MalformedURLException {
	    //Konfigurationen
		DesiredCapabilities caps = DesiredCapabilities.internetExplorer(); //Browser auswählen
        caps.setCapability("version", "9"); //Version des Browsers festlegen
        caps.setCapability("platform", "Windows 2008"); //Betriebssystem festlegen
		caps.setCapability("name", "parallelTestInternetExplorer"); //hier wird der Test benannt und in SauceLabs wieder auffindbar.
		caps.setCapability("username", "cloudbees_it-kosmopo"); //Mein Username bei SauceLabs
		caps.setCapability("accessKey", "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); //Mein Access-key bei SauceLabs
        RemoteWebDriver driver = new RemoteWebDriver(
                new URL("http://cloudbees_it-kosmopo:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx@ondemand.saucelabs.com:80/wd/hub"),
                caps);
		//Testcase
        driver.get("https://www.it-kosmopolit.de");
        driver.findElementByLinkText("KONTAKT UND ANGEBOT").click();
		WebElement profil = driver.findElement(By.partialLinkText("xing"));
		Assert.assertTrue(profil.getText().contains("Michael_Wowro"));
		//Beenden
        driver.quit();
    }
}

Hier ist ein Quickstart, den ich als Ausgangspunkt für meinen Post genutzt habe: http://wiki.cloudbees.com/bin/view/DEV/Sauce+OnDemand+Service. Und noch ein Quickstart-Video: http://www.youtube.com/watch?v=NENNAZQxK28&feature=youtu.be
1.) Also brauchen wir ein Konto bei cloudbees, welches einfach anzulegen und in der Variante für unter 300 Minuten/Monat kostenlos ist: https://www.cloudbees.com/signup

Das was wir dort unter Domain/Account eingeben ist Teil der URL, mit der wir nach erfolgreicher Anmeldung unsere Jenkins-Instanz direkt erreichen können: https://[accountname].ci.cloudbees.com
Alternativ erreichen wir diese in unserem cloudbees-Konto mit Click auf den Builds-Button in der Navigationsleiste

2.) Im nächsten Schritt abonnieren wir den Service: Sauce Labs (s.u. Abbildung) – Achtung, hierbei wird automatisch ein neues (!) Sauce Labs Konto erstellt mit eigenen Credentials.

3.) Nun legen wir ein Subversion-Repository in cloudbees an – (Repositories-Button in der Navigationsleiste)
Von cloudbees aus betrachtet sieht das Ergebnis dann so aus:

4.) In diesem Repository hab ich per TortoiseSVN die pom.xml (/pom.xml) und die ParallelTest.java (/src/test/java/ParallelTest.java) importiert und zwar, wie in den Klammern gezeigt, in der Standard-Maven Verzeichnisstruktur.
5.) Als letzter Schritt bleibt noch, den Jenkins-Job anzulegen
5.1) Wir tragen den Projektnamen ein und wählen Maven 2 / 3 Projekt aus.
5.2) In der Rubrik „Source Code Management“ wählen wir Subversion und tragen die URL unseres cloudbee-repositories ein.

5.3) Als Build-Auslöser habe ich „Build when a change is pushed to CloudBees Forge“ angehakt. Dadurch startet der Build automatisch, sobald ich ins cloudbees Subversion-Repository committe. Das funktioniert bei cloudbees zuverlässig, wenn auch ein paar Sekunden zeitverzögert, da erst ein Build-Prozess frei werden muss. Selbst wenn ich die Seleniumtests lokal entwickle und auch lokal überprüfe, ob diese fehlerfrei durchlaufen, so kann ich mit dieser Konfiguration doch nachvollziehbar dokumentieren, dass meine Tests zum Zeitpunkt des commits fehlerfrei durchgelaufen sind.
5.4) In der Rubrik „build“ verweise ich auf die pom.xml (wie bereits voreingestellt) und trage als Maven-Phase test ein.

[Exkurs: Falls die Entwickler meines Kunden Maven als Buildtool nutzen, kann ich Ihnen meine Tests auch als jar in cloudbees Maven-reposity zur Verfügung stellen: http://maven.apache.org/guides/mini/guide-attached-tests.html. Aber Ziel dieses Blogposts ist ja, meinem Kunden einen „Testbutton im Internet“ zur Verfügung zu stellen. Also weiter …]
5.5) Den Rest lassen wir bei den default-Einstellungen. Die Einstellungen können natürlich über „Konfigurieren“ jederzeit geändert werden.
FERTIG – etwas Aufwand, aber nun können wir unserem Kunden einen „Test-Button im Internet“ geben, mit dem er seine Tests rennen lassen kann. Was er braucht, ist (abgesehen von Account-Credentials) nur die URL des Jenkins-Jobs (https://[accountname].ci.cloudbees.com/job/[jobname]).
Im Build-Verlauf erkennt er, ob die letzten „Builds“ (bei unserer Verwendung von Jenkins vielleicht besser als „Testläufe“ zu bezeichnen) erfolgreich waren oder nicht.

blau = Testlauf war fehlerfrei
gelb = Testlauf war fehlerbehaftet
Mit diesem Button startet er seine Seleniumtests:

Wenn’s geknallt hat, kann er auf den entsprechenden Build clicken und schauen, wo es genau geknallt hat:

Das dazugehörige Log kann mein Kunde natürlich auch einsehen und die entsprechenden SauceLabs-Videos (unter Services -> Sauce Labs).
Und wieder habe ich diese Videos für die Öffentlichkeit freigegeben, welche aufgrund der Einfachheit der Testcases nicht spektakulär daherkommen:
parallelTestInternetExplorer
parallelTestFirefox