Teil 2: Remotes Browser-Testing mittels Web Sockets einfach umsetzen

Mit Web Sockets den Browser fernsteuern
Keine Kommentare

Die Fähigkeiten eines Browsers zu nutzen, auch wenn die eigentliche Applikation gar nicht im Browser läuft, wie kann das gehen? Mittels Web Sockets und ein wenig JavaScript lässt sich das im Prinzip einfach umsetzen.

Im ersten Teil haben wir zwei Anwendungsfälle für die remote Ausführung von Browserfunktionalität betrachtet: Die Verwendung des Browsers als remote Logging Console und die Automatisierung von Browsertests. Letzteren Anwendungsfall haben wir bisher nur theoretisch beschrieben und wollen ihn jetzt komplett umsetzen.

Artikelserie
Teil 1: Die Browserkonsole als remote Logausgabe verwenden
Teil 2: Remotes Browser-Testing mittels Web Sockets einfach umsetzen

Die Grundidee

Der Anwendungsfall der remote Browserkonsole war relativ einfach: Eine serverseitige Anwendung sendet dem Browser über Web Sockets Logkommandos nach dem Prinzip „Fire and Forget“. Der Browser hat die Kommandos hoffentlich ausgeführt, aber leider noch keine Rückmeldung dazu gegeben. Jetzt geht es darum, eine Browserapplikation remote zu testen. Dazu ist es notwendig, dass wir nicht nur Kommandos absetzen, sondern auch Ergebnisse zurückbekommen, z. B. die Testresultate. Um das Beispiel auch in seiner Gesamtheit gut erläutern zu können, verwenden wir eine React.js-Applikation und die zugehörigen Tests. Die Applikation und die Tests sind im Vergleich zu einer Echtapplikation stark vereinfacht, da sie das dahinterliegende Prinzip zeigen sollen.

UI-Integrationstests

Was und wie genau wollen wir testen? Wir haben letztes Mal bereits den Mehrwert von UI-Integrationstests aus DevOps-Sicht hervorgehoben: Wir testen im Sinne von White- oder -Grey-Box-Tests komplette Use Cases der Applikation durch. Im Gegensatz zu simulierten UI-Tests (z. B. mit Selenium) haben wir Zugriff auf die interne Applikationsstruktur. Dabei gehen wir aber nicht auf Implementierungsdetails wie bei Unit Tests ein, sondern auf High-Level-Objekte wie z. B. die UI-Komponenten.

Kostenlos: Docker mit .NET auf einen Blick

Container unter Linux und Windows nutzen? Unser Cheatsheet zeigt Ihnen wie Sie: Container starten, analysieren und Docker.DotNet (in C#) verwenden. Jetzt kostenlos herunterladen!

Download for free


In Abbildung 1 sehen wir ein konkretes, wenn auch stark vereinfachtes Beispiel: Unsere Applikation verwendet zwei UI-Komponenten (PersonList und ProjectList). Die Testmethoden haben vollen Zugriff auf die Komponenten, so wird z. B. in Listing 1 über den Router zur PersonList navigiert und deren „State“ abgeprüft. Vom Entwicklungszyklus her sieht es so aus, dass die Testmethoden testPersonList und testProjectList gemeinsam mit der App entwickelt werden. Die benötigten Abhängigkeiten werden auf Sourcecode-Ebene importiert und die Tests mit diesen Abhängigkeiten kompiliert (TypeScript). (In Listing 2 sieht man die komplette Testsuite und ihre Imports). Ein großer Vorteil liegt darin, dass im Fall von Refaktorisierungen die Tests auch mitfaktorisiert werden und nicht extra nachgezogen werden müssen.

Abb. 1: Vereinfachte Darstellung der Browser-App und der Testsuite

/**
 * Test the person list component. This is kind of a grey box test:
 * Navigate to the "route" and check the state of the component.
 * @param protocol 
 */
async testPersonList(protocol: DemoTestProtocol): Promise {
    // Get app context from the window object.
    // App has registered itself on startup.
    let app: App = (window as any)["mr_app_context"];

    // Navigate to a page. Router returns the UI component 
    // after data has been loaded.
    let component = await app.router.goto("/personList");

    // Check the component and its state.
    Assert.true(component instanceof PersonList, "Component must be a Person-List");
    let persons = (component.state.persons as Person[]);
    Assert.true(persons.length === 3, "3 Persons expected");

   // If needed, add something to the test-protocol.
return protocol;
import { App } from "../app";
import { Person } from "../person";
import { Project } from "../project";
import { PersonList } from "../personList.comp";
import { ProjectList } from "../projectList.comp";
import { Assert } from "../assert";


/**
 * For the demo app, this represents the result protocol 
 * of our test suite. In a real app, we would use
 * a more sophisticated protocol mechanism.
 */
class DemoTestProtocol {
  protected entries: string[] = [];

  addEntry(entry: any): any {
    this.entries.push(entry);
    return entry;
  }
}

/**
 * The demo-test-suite runs various tests.
 * In a real app, we would use a more
 * sophisticated testing framework.
 */
class UiTestSuite {
  /**
   * Run the test suite. 
   */
  async run(protocol: DemoTestProtocol): Promise {
    let countErrors = 0;
    let testSuiteEntry = protocol.addEntry({ test: "DemoTestSuite" });
    try {
      await this.testPersonList(protocol);
      protocol.addEntry({ test: "testPersonList", status: "OK" });
    } catch (e) {
      protocol.addEntry({
        test: "testPersonList",
        status: "ERROR", error: e
      });
      countErrors++;
    };
    try {
      await this.testProjectList(protocol);
      protocol.addEntry({ test: "testProjectList", status: "OK" })
    } catch (e) {
      protocol.addEntry({
        test: "testProjectList",
        status: "ERROR", error: e
      });
      countErrors;
    }
    if (countErrors) {
      testSuiteEntry.status = "ERROR";
      testSuiteEntry.countErrors = countErrors;
    } else {
      testSuiteEntry.status = "OK";
    }
  }

  /**
   * Test the person list component. This is kind of a grey box test:
   * Navigate to the "route" and check the state of the component.
   * @param protocol 
   */
  async testPersonList(protocol: DemoTestProtocol): Promise {
    // Get app context from the window object. 
    // App has registered itself on startup.
    let app: App = (window as any)["mr_app_context"];

    // Navigate to a page. Router returns the UI component 
    // after data has been loaded.
    let component = await app.router.goto("/personList");

    // Check the component and its state.
    Assert.true(component instanceof PersonList,
      "Component must be a Person-List");
    let persons = (component.state.persons as Person[]);
    Assert.true(persons.length === 3, "3 Persons expected");

    return protocol;
  }

  /**
   * Same as "testPersonList" but for ProjectList component.
   * @param protocol 
   */
  async testProjectList(protocol: DemoTestProtocol): Promise {
    let app: App = (window as any)["mr_app_context"];
    let component = await app.router.goto("/projectList");
    Assert.true(component instanceof ProjectList,
      "Component must be a Project-List");
    let projects = (component.state.persons as Project[]);
    Assert.true(projects.length === 3, "3 Projects expected");
    return protocol;
  }
}

/**
 * A shell for remotely calling test suites.
 */
class RemoteTestContext {
  async runTests(callBack: (result: any) => {}) {
    console.info(`RemoteTestSuite.runTests: Starting`);
    let testSuite = new UiTestSuite();
    let protocol = new DemoTestProtocol();
    await testSuite.run(protocol);
    callBack(protocol);
  }
}

// Create test-context and register it in window context. 
// It will be used by the remote app to create a "RemoteProxy" 
// that calls methods on the test-context.
let remoteTestContext = new RemoteTestContext();
  (window as any)["mr_remote_testsuite"] = remoteTestContext;

Was hat das mit „remote“ zu tun?

Die Frage ist nun, wie man diese UI-Tests in ein gesamtheitliches Testkonzept integrieren kann, z. B. in einen automatisierten Integrationstest. Der Integrationstest soll verschiedene Testszenarien durchspielen und natürlich auch die Client-App testen. Doch wie funktioniert das, wenn die Client-App-Tests eng gekoppelt mit der App entwickelt wurden? Wir wollen sie vom Integrationstest aufrufen und ihr Ergebnis automatisch prüfen.

Müssen wir dazu die Tests mit der Browser-App ausliefern? Nein! Denn hier kommen die Ansätze des RemoteProxy des letzten Artikels zum Einsatz: Wir nutzen ihn, um der Applikation zur Laufzeit die gewünschten Tests zu injizieren, diese remote von unserem zentralen Integrationstest zu starten und die Ergebnisse direkt zu verwerten.

Eine nicht empfohlene Alternative zum RemoteProxy-Ansatz wäre es, verschiedene Versionen der App mit verschiedenen Test-Bundles (verschiedene index.html-Seiten, die unterschiedliche Test-Bundles laden) nach Bedarf zu starten. Das wäre allerdings ein erhöhter Aufwand und fehleranfällig, weil die Clienttestergebnisse nicht direkt an Integrationstests zurückgeliefert werden.

Die Struktur unserer App

  1. browser: die Browser-App
    o index.html: Die zentrale HTML-Datei. Sie lädt app.bundle.js. Dieses enthält alle clientseitigen Assets.
    o app.tsx: React-App (Listing 3)
    o remoteBrowser.ts: RemoteUtils (injiziert zur Laufzeit Script-Tags im Browser) und RemoteBrowser (Web-Socket-Kommunikation zum Empfangen von Kommandos, bekannt aus dem letzten Artikel). Beide Klassen werden im App-Bundle mitgeladen (Listing 4).
    o personList.tsx: Die React-Komponente zum Anzeigen von Personen (Listing 5).
    o projectList.tsx: Die React-Komponente zum Anzeigen von Projekten.
    o person.ts und project.ts: Die Objekte, die in den UI-Komponenten angezeigt werden.
    o router.tsx: Der vereinfachte Router unserer App (Listing 6).
    o repo.ts: Das clientseitige Mock Repository zum simulierten Laden der Daten (Listing 7).
    o test
    o uiTestSuite.ts: Klasse UiTestSuite: Die UI-Tests, die gemeinsam mit der App entwickelt und remote ausgeführt werden (Listing 2).
  • integrationTest: Integrationstests
    o integrationTest.ts: Der Integrationstest (läuft auf Node.js), der im Browser remote Testmethoden der UI-Testsuite ausführt (Listing 8).
  • remoteControl: Remote-Klassen
    o Enthält im Wesentlichen die Klassen RemoteControl und RemoteProxy des ersten Artikels.
 
/**
 * We use webpack to pack our client-side JS/TS-code.
 * It will handle the imports properly.
 */
import * as React from "react";
import * as ReactDOM from "react-dom";
import { PersonList } from "./personList.comp";
import { ProjectList } from "./projectList.comp";
import { Component } from "react";
import { RemoteUtils, RemoteBrowser } from "./remoteBrowser";
import { Router } from "./router";
import { DemoMockRepo } from "./repo";

/**
 * Minimal Demo-App.
 * Instatiates {@link RemoteBrowser} and {@link RemoteUtils}.
 * Our integration test (external Node.js app) will test the app object via.
 * the RemoteBrowser.
 */
export class App {
  // The port would not be hard-coded in a real app.
  static readonly PORT = 8001;

  // The demo router for our demo app.
  router: Router;

  // Utilities for injecting script code into the page.
  protected remoteUtils = new RemoteUtils();

  // Mock repository for loading data.
  protected repo = new DemoMockRepo();

  /**
   * Start app.
   */
  start() {
    // Connect to the Web-Socket-Server. Retry-timeout == 5 seconds.
    let remoteBrowser = new RemoteBrowser("localhost",
      App.PORT, 5).waitForCommands();

    // Register app in window context.
    // They will be used from our test-suite later on.
    (window as any)["mr_app_context"] = this;

    // Initialize simplistic demo router. 
    this.router = new Router(this.repo);
    this.router.registerRoutes([
      { path: "persons", componentClass: PersonList },
      { path: "projects", componentClass: ProjectList },
    ])

    let personList = this.router.goto("persons");
  }
}

new App().start();
import { Command, CommandResult } from "../../common/command";

/**
 * Connects to Web-Socket-Server and accepts remote commands.
 */
export class RemoteBrowser {
  public reconnectMilliSecs: number;

  /**
   * @param serverUrl 
   * @param port 
   * @param retryIntervalSec: Try to reconnect every n seconds.
   */
  constructor(public serverUrl: string, public port: number,
    public retryIntervalSec: number) {
    this.reconnectMilliSecs = retryIntervalSec * 1000;
  }

  /**
   * Wait for commands from the remote control (= websocket-server).
   */
  waitForCommands() {
    let socket: WebSocket;
    let selfRb = this;
    try {
      socket = new WebSocket("ws://" +
        this.serverUrl + ":" +
        String(this.port));
      console.info(
        `Starting remote browser, waiting for socket to connect...`);

      socket.onopen = function (event) {
        console.clear();
        socket.send(JSON.stringify("Remote browser connecting now..."));
        console.log(`Remote control connected`);
      };

      socket.onmessage = function (event) {
        // Use same command interface as on web-socket-server.
        let command: Command = (JSON.parse(event.data));

        /**
         * Local function that will send the 
         * response to the server if needed.
         * @param result 
         */
        let sendResult = (result: any, error: any) => {
          let commandResult: CommandResult = {
            proxyId: command.proxyId,
            commandId: command.commandId,
            method: command.method,
            commandResult: true,
            status: error ? -1 : 0,
            data: result,
            error: error,
          };
          let commandResultJson = JSON.stringify(commandResult);
          socket.send(commandResultJson);
        }

        let result: any;
        try {
          // Get the object to call the methods on.
          let object = (window)[command.object];
          if (!object) {
            throw new Error(
              `Object "${command.object}" not found 
              in window context`);
          }
          // Get the method to be called.
          let method: any = object[command.method];
          if (!method) {
            throw new Error(
              `Method "${command.method} not found 
              in object "${command.object}"`);
          }
          // If the method to be called is async, add a callback to the parameters of the method.
          if (command.async && command.expectResult) {
            let callBack = (result: any, error: any) => {
              sendResult(result, error);
            }
            command.parameters.push(callBack);
          }
          result = method.apply(object, command.parameters);
        } catch (e) {
          result = {
            error: true,
            errorName: e.name,
            message: e.message
          };
        }
        let resultJson = JSON.stringify(result);
        if (command.expectResult && !command.async) {
          sendResult(resultJson, undefined);
        }
      };

      socket.onerror = function (event) {
        console.error(
          "Socket.onerror: %O, will try to reconnect in 'onClose'",
          event);
      };

      socket.onclose = function (event) {
        console.warn(
          `Socket.onclose: %O, trying to reconnect 
          in ${selfRb.retryIntervalSec} secs`, event);
        if (this.readyState === 3) {
          window.setTimeout(() => {
            console.warn("Trying to reconnect now...");
            selfRb.waitForCommands()
          }, selfRb.reconnectMilliSecs);
        }
      }
    } catch (e) {
      if (e.code !== "ERR_CONNECTION_REFUSED") {
        throw e;
      } else {
        window.setTimeout(this.waitForCommands, 3000);
      }
    }
  }
}

/**
 * Utils for remotely injecting scripts.
 */
export class RemoteUtils {
  static readonly InjectedScriptId = "mr-injected-script";

  constructor() {
    // Register in window context. Overwrite potentially existing object.
    // It will be used to create a "RemoteProxy" to it 
    // from the remote app or test-suite.
    (window as any)["mr_remote_utils_context"] = this;
  }

/**
 * Injects the script with the given scriptUrl into the page.
 * @param scriptUrl 
 * @param callBack 
 */
injectScript(scriptUrl: string,
  callBack: (result: any, error: any) => void): any {

  let existingScriptTag =
    document.getElementById(RemoteUtils.InjectedScriptId);
  let newScriptTag = document.createElement("script");
  newScriptTag.type = "text\/javascript";
  newScriptTag.id = RemoteUtils.InjectedScriptId;

  newScriptTag.onerror = (e: ErrorEvent) => {
    console.error(`RemoteUtils.injectInlineScript, error: %O`, e);
    callBack(undefined, e);
  }
  newScriptTag.onload = () => {
    console.info("Script loading successful");
    callBack("Script loading successful", undefined);
  }

  if (existingScriptTag) {
    existingScriptTag.parentNode!.replaceChild(newScriptTag,
      existingScriptTag);
  } else {
    let body = document.body ||
      document.getElementsByTagName('body')[0];
    try {
      body.appendChild(newScriptTag);
    } catch (e) {
      let i = 0;
    }
  }

  newScriptTag.src = scriptUrl;

  console.info(`RemoteUtils.injectInlineScript done, 
    waiting for browser to parse it. 
    => newScriptTag.onload or newScriptTag.onerror`);
  return "Script injection successful";
  }
}
 
import * as React from "react";
import { Person } from "./person";
import { BaseComponent, BaseProps, BaseState } from "./base.comp";

/**
 * The properties passed to the PersonList.
 */
export class PersonListProps extends BaseProps {
}

/**
 * The internal state of the PersonList. 
 */
export class PersonListState extends BaseState {
  persons: Person[];
}

/**
 * This represents one of the components in our react-app under test.
 * {@link BaseComponent} is an abstract base-class for all components.
 */
export class PersonList extends
  BaseComponent<PersonListProps, PersonListState> {

  /**
   * Ctor.
   * @param props Parameter passed from the "outside" to the component. 
   * @param context React passes the "context" object down the component tree.
   */
  constructor(props: PersonListProps, context: any) {
    super(props, context);
    this.state = { persons: [] };
  }

  /**
   * @override 
   * In our simple demo-app, the component itself 
   * is responsible for loading its data.
   * The repo just simulates loading the data asynchronously.
   */
  async load(): Promise<any> {
    let persons = await this.props.repo.loadPersons();
    this.setState({ persons });
    return persons;
  }

  /**
   * Render the person list.
   */
  render(): JSX.Element | null {
    if (!this.state.persons) {
      return null;
    }
    let personListEntries =
      this.state.persons.map((person: Person, index: number) => {
        // Important: Render a "key" property in order to enable
        // efficient React-rendering. See react-docu.
        return <p
          key={person.uniqueId} id={`person-${person.uniqueId}`}>
          {person.firstName} - {person.lastName}
        </p>
      })

    // Return a heading and the person-list.
    return (
      <div id="person-list">
        <hr />
        <h2>The incredible person list</h2>
        {personListEntries}
      </div>
    )
  }
}
import * as ReactDOM from "react-dom";
import * as React from "react";
import { Assert } from "./assert";
import { BaseComponent } from "./base.comp";
import { Repo } from "./repo";

export const ReactContainerDivId = "react-container";

/**
 * Define the type for component class. 
 * Used to pass it as type parameters to methods, e.g.
 */
export interface ComponentClass&glt;T extends BaseComponent&glt;any, any>> {
  new(...args: any[]): T;
}

/**
 * A route defines a path plus the according UI-component.
 */
export interface Route {
  // The path of our route, e.g. "persons/12" oder "persons/12/contracts".
  path: string;
  // The component to navigate to;
  componentClass: ComponentClass&glt;any>;
  // The regeExp to match the route
  regExp?: RegExp;
}

/**
 * Simplified demo-router.
 */
export class Router {
  protected registeredRoutes: Map&glt;string, Route> = new Map();

  /**
   * In our simplified demo-app, the router instantiates 
   * React-Components and passes the Rep to them.
   * @param repo 
   */
  constructor(protected repo: Repo) {
  }

  /**
   * Register the route. The path must be unique.
   * in a real app, we would provide an "unregister", too.
   * @param route 
   */
  register(route: Route) {
    Assert.definedAll([route, "route"],
      [route.path, "route.path"],
      [route.componentClass, "route.component"]);
    if (this.registeredRoutes.has(route.path)) {
      throw new Error(`Route-paths must be unique, 
        but was not: ${route.path}`);
    }

    // Omitted in demo code: the logic for creating the regular expression for matching route paths.

    this.registeredRoutes.set(route.path, route);
  }

  /**
   * Register several routes at once.
   * @param routes 
   */
  registerRoutes(routes: Route[]) {
    Assert.defined(routes, "routes");
    for (let route of routes) {
      this.register(route);
    }
  }

  /**
   * Navigate to the given path. 
   * @param path 
   * @param optionalParams 
   * @returns The promise will be fullfilled after the 
   *     page-component was mounted and data was loaded.
   */
  async goto(path: string, optionalParams?: any):
    Promise&glt;BaseComponent&glt;any, any>> {

    Assert.notEmpty(path, "path");
    let route = this.findRoute(path);
    let ComponentClass = route.componentClass;
    let reactContainerDiv = document.getElementById(ReactContainerDivId);
    if (!reactContainerDiv) {
      throw new Error(`Should not happen, 
        div "${ReactContainerDivId}" not found.`);
    }

    let params = optionalParams || [];

    let promise = new Promise&glt;BaseComponent&glt;any, any>>(
      (resolve: (value: any) => void, reject: (err: any) => void) => {
        let mountedComponent: BaseComponent&glt;any, any>;

        /**
         * Local callback, called after component was mounted.
         * @param component 
         */
        let componentMountedCallBack =
          async (component: BaseComponent&glt;any, any>) => {
            if (!component) return;
            let result = await component.load();
              resolve(component);
          }

        // Pass the rep as paramters (React "props") to the component
        params.repo = this.repo;
        // Render the component for the route.
        // "ref" is a React callback indicating that the component was mounted.
        let CompToRender =
          &glt;ComponentClass {...params} 
          ref={componentMountedCallBack} />

        ReactDOM.render(CompToRender, reactContainerDiv);
      })

    return promise;
  }

  /**
   * Find the route for the given path.
   * @param path 
   * @throws Error if route with given path not registered.
   */
  findRoute(path: string): Route {
    Assert.notEmpty(path, "path");
    let route: Route;
    for (let route of this.registeredRoutes.values()) {
      // In a real router, here comes the RegExp-Matching!
      if (route.path = path) {
        return route;
      }
    }
    // In real app, we would have a more sophisticated "not found" handling.
    throw new Error(`Route "${path}" not found in registered routes.`);
  }
}

import { Person } from "./person";
import { Project } from "./project";

/**
 * Interface for repository. 
 * (Very simplistic for demo)
 */
export interface Repo {
  /**
   * Load persons asnychronously.
   * @param queryParams 
   */
  loadPersons(queryParams?: any): Promise<Person[]>;

  /**
   * Load projects asnychronously.
   * @param queryParams 
   */
  loadProjects(queryParams?: any): Promise<Project[]>;
}

/**
 * Demo/mock repo for sample app;
 */
export class DemoMockRepo implements Repo {

  /**
   * Simulate loading persons asynchronously.
   * @param queryParams 
   */
  loadPersons(queryParams?: any): Promise<Person[]> {
    let promise = new Promise(
      (resolve: (value: any) => void, reject: (err: any) => void) => {
        window.setTimeout(() => {
          // Simulate async data fetching.
          let demoPersons: Person[] = [
            { uniqueId: 0, firstName: "Jane", lastName: "Doe" },
            { uniqueId: 1, firstName: "Jon", lastName: "Doe" },
            { uniqueId: 2, firstName: "Frank", lastName: "Smith" }
          ];
          resolve(demoPersons);
        }, 1000);
      });
    return promise;
  }

  /**
   * Simulate loading projects asynchronously.
   * @param queryParams 
   */
  loadProjects(queryParams?: any): Promise<Project[]> {
    let promise = new Promise(
      (resolve: (value: any) => void, reject: (err: any) => void) => {
        window.setTimeout(() => {
          // Simulate async data fetching.
          let demoPersons: Project[] = [
            { uniqueId: 0, name: "Project 1" },
            { uniqueId: 1, name: "Project 2" },
            { uniqueId: 2, name: "Project 3" }
          ];
          resolve(demoPersons);
        }, 1000);
      });
    return promise;
  }
}
import { RemoteControl } from "../remoteControl/remoteControl";
import * as proc from "process";

// To be moved to a config file in a real app.
const webSocketListenToBrowser = 8001;

// Start test.
integrationTest();

/**
 * Inject a test script into the remote browser, 
 * run the test and evaluate the test result protocol.
 */
async function integrationTest() {
  let remoteControl = new RemoteControl(webSocketListenToBrowser);
  await remoteControl.init();

  // Inject test-suite into remote browser.
  let remoteUtilsProxy =
      remoteControl.getRemoteProxy("mr_remote_utils_context");
  try {
    let res =
      await remoteUtilsProxy.execute("injectScript", 
      ["/test.bundle.js"], true, true);
  } catch (e) {
    console.log(`testSuite: Error while injecting script into client: `, e);
  }

  // Get a proxy to the the remote test suite and call "runTests" on it.
  let remoteTesSuiteProxy =
    remoteControl.getRemoteProxy("mr_remote_testsuite");
  try {
    let testResult = await remoteTesSuiteProxy.execute("runTests", 
    [], true, true);
    // Evaluate the test result here.
    console.log(
      "Result of remotely executing 'mr_remote_utils_context.runTests': ",
      testResult);
  } catch (e) {
    console.log(`testSuite: Error while executing remote: `, e);
  }

  // Uncomment if not debugging: proc.exit();
}

Der genaue Ablauf der Tests

Abbildung 2 enthält den Gesamtüberblick über den Ablauf:

  • Der Integrationstest (integrationTest.ts, Listing 8) startet den Browser mit dem URL der Applikation.
  • Start der Browser-App (index.html)
    o Der Browser lädt die Applikation (app.tsx und anderes) in Form des App-Bundles.
    o Die Applikation registriert sich im globalen window-Kontext (window.mr_app_context). Das ist wichtig, weil unser Test, der zur Laufzeit injiziert werden wird, darauf zugreift.
    o Im App-Bundle wird auch eine schlanke Utility-Klasse mitgeladen, die zur Laufzeit Scripts injizieren kann. Das entsprechende Utility-Objekt registriert sich ebenfalls im window-Kontext (window.mr_remote_utils).
    o Zuletzt instanziiert die App den vom letzten Artikel bekannten RemoteBrowser. Er stellt die Web-Socket-Verbindung zum Integrationstest her. Damit steht die Verbindung, und es können Nachrichten ausgetauscht werden.
  • Integrationstest: Injizieren des Scripts
    o Der Integrationstest erzeugt einen RemoteProxy, und zwar mit dem clientseitigen Gegenstück window.mr_remote_utils. Über diesen Proxy ruft er die remote Funktion injectScript auf und gibt als Parameter test.bundle.js mit.
    o Das RemoteUtils-Objekt im Browser erstellt nun dynamisch ein Script-Tag mit dem src-Attribut test.bundle.js. Dadurch wird die UI-Testsuite (UiTestSuite, Listing 2) geladen.
    o Die UI-Testsuite registriert sich selbst unter window.mr_remote_testsuite.
  • Integrationstest: Remotes Starten des Tests
    o Der Integrationstest (integrationTest.ts) erstellt nun einen RemoteProxy zur UI-Testsuite (d. h. zur zuvor registrierten window.mr_remote_testsuite).
    o Der Integrationstest ruft über den Proxy die remote Methode runTests auf.
  • Browser-App: Ausführen der Tests: Die UI-Testsuite läuft, befüllt das Testprotokoll und liefert es an den Integrationstest zurück.
  • Integrationstest: Auswerten der Ergebnisse und weitere Tests
    o Der Integrationstest wertet die Testergebnisse aus und kann mit den nächsten Schritten fortfahren.

Abb. 2: Überblick über den gesamten Prozess des remoten Testens

Fazit

Mit der Technik der remoten Proxies lässt sich Browserfunktionalität auf vielfältige Weise von einer remoten Applikation aus ansprechen. Das beschränkt sich nicht auf einfache Kommandos wie das Absetzen von Remote-Logging-Anweisungen des letzten Artikels, sondern ermöglicht komplexe Abläufe und eine Verzahnung von serverseitigem und clientseitigem Code. Als ein Anwendungsbeispiel haben wir in diesem Artikel UI-Integrationstests gesehen, die sehr oft eine gute Ergänzung oder sogar Alternative zu simulierten Benutzertests darstellen.

Windows Developer

Windows DeveloperDieser Artikel ist im Windows Developer erschienen. Windows Developer informiert umfassend und herstellerneutral über neue Trends und Möglichkeiten der Software- und Systementwicklung rund um Microsoft-Technologien.

Natürlich können Sie den Windows Developer über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist der Windows Developer ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

X
- Gib Deinen Standort ein -
- or -