Cross-Browser-Testing mit Jest & Selenium auf Browserstack

Jest ermöglicht einfache Tests. Selenium ermöglicht das einfache Ansteuern eines Browsers. Und Browserstack bietet eine Plattform für unzählige Browser- und Betriebssystemkombinationen. Doch wie verbindet man diese drei Tools? Genau das möchte ich euch in diesem Artikel näher bringen.

Jest, Selenium, Browserstack?

Zuallererst möchten wir abgrenzen welche Funktionen die 3 Tools beim Cross-Browser-Testing übernehmen.

Jest ist das beliebteste Testing Framework und lässt sich theoretisch mit Selenium kombinieren. Es erfüllt dabei drei große Funktionen:

  1. Es strukturiert Test-Suites und Tests.
  2. Es erlaubt uns das Schreiben von Tests mit Hilfe von bequemen Vergleichsfunktionen, die menschenlesbare Fehler produzieren.
  3. Es ermöglicht das Ausführen und ein Coverage-Reporting der Test-Suites und Tests.

Selenium spricht die vom W3C standardisierte WebDriver-API an, um einen Browser programmatisch zu steuern (auf einen Button klicken, die Inhalte eines Input-Felds oder eines Absatzes zu lesen, etc.). So kann Selenium zum Beispiel google.com in Firefox öffnen, in das Suchfeld navigieren und "esveo" eingeben.

Browserstack bietet eine Plattform, um verschiedene Browser auf verschiedenen Geräten zu benutzen (zum Beispiel Firefox auf Windows, Safari auf macOS oder Chrome auf Android). Dies ermöglicht schlussendlich das Ausführen der selben Tests auf unterschiedlichen Infrastrukturen ("Cross-Browser-Testing"). Dafür benutzt Browserstack seine realen Geräte und ruft unsere Website auf. Dafür muss die Website entweder öffentlich verfügbar sein oder wir öffnen Browserstack einen Tunnel auf unseren localhost.

Am Ende des Artikels sollst du verstehen, wie es dir möglich ist mit Jest Test-Suites und Tests zu definieren, dir diese Tests automatisiert mit Selenium in einem realen Browser vorführen zu lassen und wie du Browserstack mit Selenium verbindest, um verschiedene Betriebssysteme und Browserversionen zur Verfügung zu stellen.

Selenium und Jest verbinden

Zuerst richten wir die Jest Konfiguration und Umgebungsvariablen (Environment Variables) ein. Die Umgebungsvariablen erlauben uns später unseren Browserstack-Namen und Passwort sicher zu hinterlegen, ohne sie im Source Code abzulegen.

yarn add -D dotenv jest selenium-webdriver chromedriver \
  @types/jest @types/selenium-webdriver @types/chromedriver
# .env
BROWSERSTACK_USER=exampleUser
BROWSERSTACK_PASSWORD=examplePassword

Achtung: Die .env-Datei sollte nicht mit ins Git-Repository eingecheckt werden, sondern ignoriert werden. Jedes Teammitglied muss seine Zugangsdaten dann auf seinem Rechner einpflegen.

Nach den Umgebungsvariablen definieren wir die Konfiguration von Jest:

// jest.config.ts
import { Config } from "@jest/types";

const config: Config.InitialOptions = {
  /**
   * maxWorkers kann benutzt werden, um die Anzahl paraleller
   * Tests zu limitieren. Diese sind abhängig von der erworbenen
   * Browserstack-Lizenz.
   **/
  maxWorkers: 1,

  /**
   * Der Test Timeout muss für Browserstack erhöht werden,
   * um dem Service mehr Zeit zu geben, Tests auszuführen,
   * bevor diese abgebrochen werden. Das ist ggf. nötig,
   * weil die Tests durch die Remote-Verbindung zum
   * Browserstack-Gerät ggf. länger brauchen.
   **/
  testTimeout: 30000,
  /**
   * Mit "dotenv/config" in setupFiles laden wir die Umgebungsvariablen
   * aus der .env Datei in den Node-Prozess und können in den Tests
   * mit process.env auf diese zugreifen.
   **/
  setupFiles: ["dotenv/config"],
};

export default config;

Als Beispielkomponente dient die Folgende. Wir nehmen an, dass sie unter http://localhost:XXXX/test aufrufbar ist. Das muss durch das Build-Tool deiner Wahl sichergestellt werden. Die Tests sind komplett unabhängig vom Framework, hier wird lediglich React gewählt, um ein kompaktes Beispiel zur Verfügung zu stellen.

// Counter.tsx
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <>
      <p id="counter">{count}</p>
      <button id="button" onClick={() => setCount(count + 1)}>
        Click me
      </button>
      {count > 2 ? (
        <p id="counterIsBiggerThanTwoMessage">Count is bigger than 2</p>
      ) : null}
    </>
  );
}

Ein typischer Jest-Test für diese Komponente, der Selenium benutzt, könnte der Folgende sein:

// Counter.test.ts;
/**
 * Der Chromedriver muss importiert werden,
 * damit Selenium Chrome benutzen kann.
 * Analog bräuchte man den "geckodriver" für Firefox.
 *
 * Später entfernen wir den Import aus dieser Datei,
 * damit wir ohne Anpassungen an den Tests unterschiedliche
 * Plattformen testen können.
 **/
import "chromedriver";

import { Builder, By, WebDriver, WebElement } from "selenium-webdriver";

/**
 * Auf diese Weise spezifieren wir,
 * welches System Selenium verwenden soll.
 * Genau auf diese Weise können wir es auch später für
 * Browserstack einstellen und es auch von Umgebungsvariablen
 * abhängig machen.
 **/
const capabilities = {
  os: "Windows",
  os_version: "10",
  browser: "chrome",
  browser_version: "latest",
  resolution: "1920x1080",
};

describe("Test Page", () => {
  /**
   * Hier umgehen wir TypeScript etwas, damit wir später
   * bequemer auf die Variablen zugreifen können.
   **/
  let driver = null as any as WebDriver;
  let counter = null as any as WebElement;
  let counterUpButton = null as any as WebElement;

  /**
   * Vor dem Start des ersten Tests lassen wir Selenium Chrome
   * mit der .build() Funktion und den angegebenen Spezifikationen starten.
   */
  beforeAll(async () => {
    driver = await new Builder()
      .withCapabilities(capabilities)
      .forBrowser(capabilities.browser)
      .build();

    /**
     * Wir navigieren zur Seite und lassen Selenium die benötigten Elemente finden.
     * Damit die folgende Zeile funktioniert, muss zu diesem Zeitpunkt bereits
     * ein lokaler WebServer laufen.
     **/
    await driver.get("http://localhost:8080/#/test");
    counter = await driver.findElement(By.id("counter"));
    counterUpButton = await driver.findElement(By.id("button"));
  });

  /**
   * Durch unsere Namenskonvention vervollständigt der Test-Text
   * den Seitennamen ("Test Page counter should increase when clicking the button.").
   **/
  test("counter should increase when clicking the button.", async () => {
    expect(await counter.getText()).toBe("0");
    await counterUpButton.click();
    expect(await counter.getText()).toBe("1");
  });

  test("should show a message when the counter is bigger than two.", async () => {
    expect(
      (await driver.findElements(By.id("counterIsBiggerThanTwoMessage"))).length
    ).toBe(0);
    await counterUpButton.click();
    await counterUpButton.click();
    expect(
      (await driver.findElements(By.id("counterIsBiggerThanTwoMessage"))).length
    ).toBe(1);
  });
});

Mit den Funktionen describe(), test() und expect() haben wir eine typische Jest-Testinfrastruktur aufgebaut, die vergleicht, ob Elemente die Werte annehmen, die wir erwarten. Mit Selenium greifen wir auf eben diese Elemente zu und interagieren mit diesen.

Anwendungs- und Test-Code abgrenzen

Bis jetzt greifen wir mit der By-Funktion, genauer der By.id()-Funktion von Selenium auf Elemente zu. Bei einer id handelt es sich um ein Attribut, was wir typischerweise durch HTML und CSS, also Anwendungs-Code ansprechen. Wir erwarten also, dass wir diese id ändern können, wenn unsere Anwendung das erfordert, ohne dabei unsere Tests ändern zu müssen. Beispielsweise könnten wir folgende Anpassung vornehmen und damit einen Fehler in unserem Test, der sich bis jetzt auf die "button"-id bezieht, erzeugen. Und das obwohl die Anwendung noch funktioniert wie wir es wollen. Das ist natürlich ein Problem:

// Alter Code
// <button id="button" onClick={() => setCount(count + 1)}>
//   Click me
// </button>

<button id="bigButton" onClick={() => setCount(count + 1)}>
  Click me
</button>

Den Anwendungs- und Test-Code grenzen wir voneinander ab, indem wir den Elementen eine data-testid zuweisen und zukünftig diese ansprechen. Dafür ändern wir den Code unserer Komponente und und schreiben eine Helferfunktion für Selenium:

// Counter.tsx
<>
  <p data-testid="counter">{count}</p>
  <button data-testid="button" onClick={() => setCount(count + 1)}>
    Click me
  </button>
  {count > 2 ? (
    <p id="counterIsBiggerThanTwoMessage" data-testid="message">
      Count is bigger than 2
    </p>
  ) : null}
</>
// byTestId.ts
import { By } from "selenium-webdriver";

export function byTestId(id: string) {
  return By.css(`[data-testid=${id}]`);
}
// Counter.test.ts;
import { byTestId } from "/selenium-webdriver-extensions/byTestId";
// ...

// Beispiel alter Code: counter = await driver.findElement(By.id("counter"));
counter = await driver.findElement(byTestId("counter"));
counterUpButton = await driver.findElement(byTestId("button"));
// ...
expect((await driver.findElements(byTestId("message"))).length).toBe(0);
expect((await driver.findElements(byTestId("message"))).length).toBe(1);

Selenium und Browserstack verbinden

Um unseren und alle zukünftigen Tests Browserstack-fähig zu machen, lagern wir das Starten des Browsers durch Selenium (und das das hinzukommende Verbinden mit Browserstack) in eine separate Datei aus, die jeder Test aufrufen muss. Dafür passen wir den Test zuerst wie folgt an. Die getTestUtils-Funktion wird nachfolgend erklärt:

// Counter.test.ts;
import "chromedriver";
import { Builder, WebDriver, WebElement } from "selenium-webdriver";
import { byTestId } from "/selenium-webdriver-extensions/byTestId";
import { getTestUtils } from "/test-infrastructure/getTestUtils";

/**
 * Die Testutils werden in jedem Test-File eingebunden,
 * welches auch wirklich im realen Browser ausgeführt werden soll.
 * So können wir Unit-Tests und E2E-Tests parallel warten.
 **/
const utils = getTestUtils();
beforeAll(async () => {
  await utils.setup();
});
afterAll(async () => {
  await utils.teardown();
});

describe("Test Page", () => {
  let counter = null as any as WebElement;
  let counterUpButton = null as any as WebElement;

  beforeAll(async () => {
    await utils.driver.get("http://localhost:8080/#/test");
    counter = await utils.driver.findElement(byTestId("counter"));
    counterUpButton = await utils.driver.findElement(byTestId("button"));
  });

  it("counter should increase when clicking the button.", async () => {
    expect(await counter.getText()).toBe("0");
    await counterUpButton.click();
    expect(await counter.getText()).toBe("1");
  });

  it("should show a message when the counter is bigger than two.", async () => {
    expect((await utils.driver.findElements(byTestId("message"))).length).toBe(
      0
    );
    await counterUpButton.click();
    await counterUpButton.click();
    expect((await utils.driver.findElements(byTestId("message"))).length).toBe(
      1
    );
  });
});

Zuallererst müssen wir uns nun bei Browserstack registrieren und unseren Browserstack Automate Username sowie Access Key in die .env Datei eintragen. Diese finden sich hier und sind nicht die E-Mail und das Passwort des Browserstack Accounts.

Als nächstes fügen wir das Package hinzu, was uns localhost mit Browserstack verbinden lässt:

yarn add -D browserstack-local
import { Local } from "browserstack-local";
import { Builder, WebDriver } from "selenium-webdriver";

export function getTestUtils() {
  let driver = null as any as WebDriver;
  const browserstackLocalModule = new Local();

  /**
   * Die Setup-Funktion wird vor jedem Test aufgerufen.
   * Sie verbindet zuerst localhost mit Browserstack und
   * lässt Selenium anschließend auf Browserstack die
   * gewünschte Betriebssystem-Browser-Kombination starten.
   **/
  async function setup() {
    /**
     * Im Gegensatz zur Nicht-Browserstack-Variante
     * müssen und sollten zusätzliche Capabilities definiert werden.
     * Um den Test auf Browserstack identifizierbar zu machen,
     * sollte er einen Namen bekommen.
     * Zusätzlich muss man seine Browserstack Credentials hinterlegen,
     * sowie die Attribute "browserstack.local" und
     * "browserstack.localIdentifier".
     **/
    const capabilities = {
      os: "Windows",
      os_version: "10",
      browser: "chrome",
      browser_version: "latest",
      resolution: "1920x1080",
      name: "Our first test",
      "browserstack.user": process.env.BROWSERSTACK_USER,
      "browserstack.key": process.env.BROWSERSTACK_PASSWORD,
      "browserstack.local": true,
      "browserstack.localIdentifier": "LocalConnection",
    };

    /**
     * Durch diese Erweiterung der start-Funktion des
     * browserstack-local-Moduls kann sie asynchron verwendet werden,
     * sodass mit await darauf gewartet wird,
     * dass Browserstack-Local erfolgreich gestartet wird.
     **/
    async function start() {
      return new Promise<void>((resolve, reject) => {
        browserstackLocalModule.start(
          {
            key: process.env.BROWSERSTACK_PASSWORD,
            localIdentifier: "LocalConnection",
          },
          (error) => {
            if (error) {
              reject(error);
            }
            resolve();
          }
        );
      });
    }
    await start();

    driver = await new Builder()
      // Der Browserstack Server
      .usingServer("https://hub-cloud.browserstack.com/wd/hub")
      .withCapabilities(capabilities)
      .forBrowser(capabilities.browser)
      .build();

    /**
     * Dies ermöglicht den Tests,
     * die getTestUtils() aufrufen und
     * den Selenium-Webdriver zu benutzen.
     **/
    utils.driver = driver;
  }

  async function teardown() {
    await utils.driver.quit();
    // Siehe start() Funktion
    async function stop() {
      return new Promise<void>((resolve, reject) => {
        browserstackLocalModule.stop(() => {
          resolve();
        });
      });
    }
    await stop();
  }

  const utils = {
    setup,
    teardown,
    driver,
  };

  return utils;
}

Alle Browserstack Capabilities sind hier zu finden: https://www.browserstack.com/automate/capabilities

Der folgende Code-Abschnitt muss also jedem Test hinzugefügt werden, um den Test zu starten, zu beenden und den Selenium-Webdriver zu verwenden.

// Counter.test.ts

const utils = getTestUtils();
beforeAll(async () => {
  await utils.setup();
});
afterAll(async () => {
  await utils.teardown();
});

Nachfolgend muss der Dev-Server gestartet werden und unsere Jest Tests werden ab jetzt auf Browserstack ausgeführt:

yarn jest Counter

Abschluss

Zum Zeitpunkt des Schreibens dieses Artikels traten auf Seiten von Browserstack allgemein oder im Zuge des browserstack-local-Moduls für Node.js regelmäßig und ohne erkennbares Muster Probleme auf. Die Verbindung konnte ca. alle 24 Testdurchläufe nicht hergestellt werden, was eine Einbindung in eine CI-Umgebung schwierig gestaltet. Dennoch bieten uns die Tests große Mehrwerte, weil wir sämtliche Oberflächen automatisiert abtesten können, ohne dass wir uns erst selbst durch alle UI arbeiten müssen.

Auch wenn Jest nicht die Standardlösung für Selenium-Tests auf Browserstack ist, hat euch dieser Artikel gezeigt, wie dies einfach ermöglicht wird. Jest kann neben den Selenium-Tests außerdem für eure Unit-Tests verwendet werden und ist durch das Jest-Ökosystem erweiterbar, beispielsweise durch Test-Reporter.