Testgetriebene Entwicklung (TDD) von G-Suite-Addons – mit TypeScript!

Erweiterung der Google G Suite mithilfe von Addons

Sei es wegen Kundenaufträgen, aufgrund unserer internen automatisierten Rechnungsstellung oder weil letztens Kollegen einen Konfigurationsfehler rekursiv rückgängig machen wollten: Immer wieder beschäftigen wir vom Team Google bei Seibert Media uns mit Google Apps Script.

Häufig besteht ein starkes Interesse daran, Otto Normalverbraucher die Funktionalität leichtgewichtig und schnell zur Verfügung zu stellen. Die Google G Suite sieht dafür Addons vor. Die können Benutzer einfach installieren, und die Addons können entscheiden, wo in der G Suite sie zusätzliche Buttons hinzufügen wollen, die dann die gewünschte Funktion ausführen.

Der Wunsch nach testgetriebener Entwicklung

So weit, so gut. Die APIs sowohl für die verschiedenen Google-Services als auch für die Addons an sich sind gut dokumentiert; es gibt jeweils wundervolle Beispiele zum Selber-Basteln und auch Videos, mittels derer wir in kurzer Zeit viel lernen können.

Moderne Software-Entwicklungsteams (manche nennen sich auch Software Crafter) möchten aber oft gerne noch etwas: TDD – Test Driven Development. Am liebsten sogar BDD, also Behaviour Driven Development. Diese Begriffe in Vollständigkeit zu beschreiben, würde hier zu weit führen. Deshalb verweise ich auf unseren früheren Artikel, und auch im Internet finden sich gängige Definitionen.

Die Herausforderung: Wir brauchen bestimmte Entry Points

Das Problem daran? Google Apps Script fordert gewisse Entry Points ins Programm. Für ein Editor-Addon ist es zum Beispiel zwingend notwendig, dass es Funktionen onOpen und onInstall gibt – sie erzeugen nämlich das Addon-Menü, das der Nutzer dann verwenden soll. Ohne diese ganz genau so benannten Funktionen zu implementieren, bekomme ich meine Funktionalität nicht zum User.

Jetzt ist es aber so, dass neuere JavaScript-Standards (und ergo auch TypeScript) aus vielen guten Gründen einer import/export-Logik folgen und nicht (wie früher) einfach sequenziell *.js-Dateien lesen und auswerten, bis sie "unten" angekommen sind (für eine nicht näher ausgeführte Definition von "unten"). Stattdessen importiert man aus einzelnen Modulen Funktionen und Klassen in andere Module.

Noch hat sich das zwar nicht in allen Browsern als einzig erlaubtes Verhalten durchgesetzt, doch jedenfalls haben Google Apps Scripts mittlerweile eine V8-Umgebung als Standard-Engine spendiert bekommen (statt der früheren Rhino-Engine von Mozilla).

Neue JavaScript-Standards erfordern den Export von Funktionen oder Klassen

Diese spezielle V8-Engine unterstützt aber derzeit noch keine Module. Das ist mindestens unpraktisch – aber für einen sauberen TDD-Ansatz katastrophal. Wenn ich mein Addon testgetrieben entwickeln will, möchte ich vermutlich Tests und Quellcode in unterschiedliche Dateien legen. Außerdem ist das Addon häufig kompliziert genug, dass auch unterschiedliche Funktionalitäten in unterschiedliche Dateien kommen sollen, um Klarheit zu schaffen.

Das erfordert in den neueren JavaScript-Versionen aber zwingend, dass ich Funktionen oder Klassen exportiere, um sie dann in den anderen Dateien zur Verfügung zu haben. Und sobald ich das tue, erkennt ein Trans- oder Compiler diese Datei als Moduldatei – und lässt mich eine Funktionalität daraus wiederum nicht mehr ohne import anderswo verwenden. Was tun?

Naiver Lösungsansatz: An window heften

Jetzt könnte ich ja ganz einfach hingehen und meine Funktionalität fest an window heften. Dadurch sollte meine Funktion ja global bekannt sein, was wiederum die Apps-Script-Götter zufriedenstellen müsste. Oder?

import { onInstall } from './install.trigger';
import { onOpen } from './open.trigger';
import { myFunction } from './myLogic';

declare global {
    interface Window {
        onInstall:  (e: { authMode: GoogleAppsScript.Script.AuthMode }) => void;
        onOpen:     (e: { authMode: GoogleAppsScript.Script.AuthMode }) => void;
        myFunction: any;
    }
}

window.onInstall  = onInstall;
window.onOpen     = onOpen;
window.myFunction = myFunction; // die ich vielleicht brauche, weil ich sie im Menü aufrufbar haben will

Ganz so einfach ist es leider nicht. Wenn ich dieses TypeScript (zum Beispiel mit dem Transpiler ts2gas) kompilieren lasse, kommentiert mir selbiger natürlich meine import-Statements aus – Google Apps Script kennt schließlich keine Module. Wenn ich es ohne Transpiler in einfachem JavaScript versuche, erkennt GAS die Keywords import und export nicht.

Erzeuge ich hingegen (beispielsweise mittels des Bundlers webpack) ein JavaScript-Bundle, dann sind zwar alle Im- und Exporte innerhalb des Bundles aufgelöst – das allerdings nur zur Laufzeit. Denn die Funktionsnamen sind nicht explizit definiert, sondern werden erst zur Laufzeit des Skripts von der webpack-Logik verfügbar gemacht.

Wie eingangs erwähnt, ist es aber zwingend erforderlich, dass die Entry Points auch durch eine einfache lexikalische Prüfung sichtbar sind, sonst kann die G-Suite-Addon-Laufzeitumgebung nicht mit meinem Code umgehen und versteht nicht, wie es ihn aufzurufen hat. Und wieder stellt sich die Frage: Ein Teufelskreis?

Die Lösung: In TypeScript eine globale Ebene deklarieren mittels gas-webpack-plugin

Nein, natürlich nicht. Es hat ja gute Gründe, dass das alles so aufgesetzt ist. Und so, wie ich mittels Deklarationen in TypeScript globale Variablen sichtbar machen kann, obwohl der Compiler sie nicht kennt, kann ich auch eine globale Ebene deklarieren, wo meine Entry Point-Funktionen angehängt werden sollen – beispielsweise also in einer index.ts folgendermaßen:

import { onInstall } from './install.trigger';
import { onOpen } from './open.trigger';
import { myFunction } from './myLogic';

declare let global: any;

global.onInstall  = onInstall;
global.onOpen     = onOpen;
global.myFunction = myFunction; // die ich vielleicht brauche, weil ich sie im Menü aufrufbar haben will

Hier bildet die Variable global das Äquivalent des in JavaScript historisch üblichen window. Damit sich das jedoch zur Laufzeit auch korrekt verhält, brauche ich noch das entsprechende Plugin. Für webpack wäre das gas-webpack-plugin. Zu bekommen ist es, wie viele, über npm:

# npm install --save-dev webpack webpack-cli typescript ts-loader gas-webpack-plugin

Dieses Plugin kann ich einfach in meine webpack.config.js hinzufügen, und schon wird vor den Modul-Deklarationen noch Code ausgeführt, der die Variable global korrekt befüllt:

const path = require('path');
const GasPlugin = require('gas-webpack-plugin');

module.exports = {
    mode: 'development',
    entry: './src/index.ts',
    devtool: false,
    output: {
        filename: '[name].js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.ts$/,
                use: 'ts-loader'
            }
        ]
    },
    resolve: {
        extensions: [
            '.ts'
        ]
    },
    plugins: [
        new GasPlugin()
    ]
};

Da hierdurch nun die oberste Ebene geklärt ist, kann ich jetzt hingehen und meine Funktionalitäten so entwickeln, wie ich sie brauche – und zwar so, dass sie sich gegenseitig importieren lassen, inklusive Test-Frameworks wie mocha oder jest.

Beispielhafter TDD-Setup

Um die Verwaltung meiner Toolchain ein bisschen zu vereinfachen, lohnt es sich noch, ein paar gängige Pakete zu installieren und Skripte zu definieren, die es einfach machen, die Tests jederzeit auszuführen. Hier zeige ich das mit der Kombination mocha als Runner, chai als Assertion-Bibliothek und sinon zum Mocken und Stubben, aber jasmine- oder jest-Setups funktionieren natürlich gleichermaßen.

# npm install --save-dev cpx ts-node mocha chai sinon @types/mocha @types/chai @types/sinon

Mit ein paar Deklarationen in der package.json:

"scripts": {
  "build": "webpack && cpx appsscript.json dist && cpx \"src/**/*.html\" dist",
  "deploy": "npm run lint && npm run test && npm run build && clasp push",
  "lint": "tslint -p .",
  "test": "mocha --require ts-node/register --extensions ts 'test/**/*.spec.ts'"
},

Wenn wir in der .clasp.json angeben, dass "rootDir": "dist" ist, können wir nun in der Kommandozeile unserer Wahl einfach npm test laufen lassen und haben somit die Voraussetzungen geschaffen, unseren ersten TDD-Zyklus zu beginnen. Schreiben wir einen roten Test im Verzeichnis test/ und erzeugen dann die benötigte Funktionalität in src/!

Voila und happy Testing!

Ihr Partner für Lösungen mit Google Cloud

Interessieren Sie sich für moderne Zusammenarbeit und Lösungen mit Google-Technologien? Melden Sie sich bei uns, wenn Sie Fragen haben oder mehr wissen wollen. Wir sind offizieller Google Cloud Partner: Gerne beraten wir Sie unverbindlich zur Einführung, Lizenzierung und produktiven Nutzung von G Suite sowie zur Planung und Umsetzung von Anwendungen auf Basis der Google-Cloud-Technologien.

Weiterführende Infos

Google G Suite erweitern: Google Apps Scripts oder App Maker?
Google Apps Scripts, Clasp und Data Studio
Google Apps Script – Skript-Verwaltung mit dem App Scripts CLI (Clasp)
Weshalb sich große Organisationen für die Google G Suite entscheiden


Mehr über die Creative-Commons-Lizenz erfahren