Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > TypeScript REST service autotesztelése Cucumberrel

Szűrő megjelenítése

TypeScript REST service autotesztelése Cucumberrel

Sok évnyi Spring Boot alkalmazás fejlesztés után, el kell ismernem, eleinte ódzkodtam a TypeScript használatától és ezzel a Boot adta kényelem feladásától. Az egyik legérdekesebb és talán legkomplexebb megoldandó probléma az elkészült servicek API vagy acceptance tesztelése volt. Erre a Spring Boot az @SpringBootTest annotációt biztosítja, aki már dolgozott vele, az tudja, hogy gyakorlatilag az egész alkalmazás elindul, nekünk csak a felülbírálásokkal illetve a külső függések mockolásával kell foglalkoznunk, meg persze magukkal a tesztekkel. (Megjegyzés: tisztában vagyok vele, hogy ez inkább integrációs tesztek írására ajánlott, de kiválóan használható teljes API tesztelésre is.) Ehhez hasonló megoldást sajnos a TypeScript-es ekoszisztémában nem találtam, viszont a Bootnak ezen megoldása kifejezetten tetszett mindig is, így egy hasonló tesztfuttató eszköz felépítése volt a tervem.

Először is nézzük pontosan, mire lesz szükségünk:

  1. A servicenek futnia kell a tesztek során. Nyilván a legtisztább megoldás egy önálló staging környezet üzemeltetése lenne, de sokkal inkább szerettem volna egy az integrációs tesztekhez hasonlóan "eldobható" formában felépíteni a tesztkörnyezetet, amely bármikor futtatható a CI/CD pipeline részeként. A cél tehát az, hogy az alkalmazást a CI/CD környezet el tudja indítani és lefuttatni rajta a teszteket.
  2. Ha a tesztelt service adatbázist is használ, szükségünk lesz egy mockolt adatbázisra. Azt javaslom, mindig törekedjünk egy könnyen tisztítható, gyakorlatilag eldobható és újraépíthető megoldásra, hogy a tesztek mindig egy konzisztens adatbázison fussanak, amely mindig olyan állapotban van, mint amire számítunk is.
  3. Ha a tesztelt service hív külső API-okat is, azok mockolására is szükség lehet, attól függően, hogy mennyire szeretnénk a teszteket inkább izoláltan futtatni, vagy end-to-end (E2E) irányban gondolkodunk inkább. Mindkettőnek van értelme, igazából akár két önálló tesztkészletről is beszélhetünk.
  4. Szükségünk lesz egy teszt frameworkre, amivel a fenti lépéseket koordinálni tudjuk. Igazából például a Jest framework is bőven megfelelhet a feladatra, az én választásom azonban a Cucumber JavaScript-es változatára esett - erre később még visszatérek.

Az alábbiakban megnézzük részleteiben a fenti pontokat egy Express alapú, TypeScriptben írt REST API példáján keresztül.

A service elindítása

Node.js használata esetén elég egyszerű a dolgunk, létrehozunk egy script bejegyzést a package.json-ban, ami a ts-node (vagy JS esetén a node) használatával lefuttatja az alkalmazás belépési pontját adó .ts filet. A tesztek futtatásához azonban ezt programozottan kell megoldanunk, és persze használhatnánk OS hívásokat, ennél azonban sokkal elegánsabb és kontrolláltabb a tesztfuttatóból elindítani az alkalmazást. Erre létrehoztam egy külön osztályt, mely az alkalmazás indításáért felelős, de a lényege gyakorlatilag egyetlen utasítás, az import(...). Paramétere az alkalmazás belépési pontját adó .ts file kell legyen, pont mint az npm-mel való futtatás esetén.

export default class ApplicationManager {

    // ...

    private static started: boolean = false;

    // ...

    public static async start(): Promise<void> {

        return new Promise((resolve) => {

            if (this.started) {
                this.notifyStarted(resolve, "Application is already loaded");
            } else {

                this.logger.info(`Starting application with environment=${process.env.NODE_ENV}...`);

                this.started = true;
                import("../../src/lsrs-main");

                setTimeout(() => {
                    this.notifyStarted(resolve, "Assuming application is now running");
                }, this.startUpTimeout);
            }
        });
    }

    // ...
}

A fenti példán az LSRS nevű servicemhez írt futtató kódja látható. Viszonylag egyszerű a működése, figyeli, hogy már el volt-e korábban indítva az alkalmazás (started flag), ha nem, importálja a belépési pontot, ezzel elindítva magát az alkalmazást, majd némi várakozás után visszaadja a vezérlést a hívó félnek (ez maga a Cucumber lesz, később visszatérünk rá). Egy pár dolgot egyébként még megcsinál ez az osztály, melyek a mai cikkem szempontjából érdektelenek, de a cikk végén mellékelt repositoryban megtekinthető a teljes kód.

Az alkalmazás konfigurálására a config NPM packaget használom, így az alkalmazás teszt konfigurációja csak egy extra .yml filet igényel. Ahhoz azonban, hogy ez működjön, érdemes átállítani a tesztekben használt NODE_ENV-et, például test-re, ebben az esetben pedig a konfigurációs file neve test.yml. A környezet neve a process.env.NODE_ENV változóval állítható be, ehhez szükségünk lesz egy inicializáló scriptre, melyet aztán a Cucumberbe is integrálnunk kell. Esetemben a fix konfiguráció nem volt elegendő, így még a NODE_CONFIG környezeti változót is felül kellett bírálnom, ugyanebben a fileban.

import os from "os";

// konkrétan emiatt volt szükség a dinamikus konfigurálásra
export const uploadPath = `${os.tmpdir()}/lsrs-storage`;
export const databasePath = `acceptance/out/lsrs-sqlite.db`;

process.env.NODE_ENV = "test";

// a felülbírált configot JSON stringként kell átadnunk a NODE_CONFIG környezeti változónak
process.env.NODE_CONFIG = JSON.stringify({
    lsrs: {
        datasource: {
            uri: `sqlite:${databasePath}`
        },
        storage: {
            "upload-path": uploadPath
        }
    }
});

A mockolt adatbázis inicializálása

Az LSRS eredetileg egy MySQL adatbázist használ, nyilván a tesztek futtatásához nem szerettem volna azt használni. Szerencsére a Sequelize ORM nagyon rugalmasan tudja kezelni a mögötte levő motort, így könnyedén ki tudtam cserélni egy file alapú sqlite adatbázisra. Az alkalmazás indulásakor csatlakozni fog az sqlite adatbázishoz, azonban ekkor az még üres. A feladatunk az, hogy populáljuk mockolt adatokkal, melyeket könnyen el tudunk dobni és újrainicializálni, amennyiben egy teszteset módosít azokon. Ehhez a következő lépéseket kell megtennünk:

  1. Eldobjuk az adatbázist. Ez az első futtatásnál lényegében hatástalan, mivel még üres az adatbázis, szóval ez akár feltételesen is futtatható, de nincs igazán értelme időt pazarolni rá.
  2. Felépítjük a sémát.
  3. Feltöltjük a sémát a teszt adatokkal.

Elcsomagolva a fenti logikát egy újrahasználható komponensbe, bármikor könnyedén újrainicializálhatjuk az adatbázist a tesztek futtatása közben. Az alábbi kód éppen erre szolgál:

export default class DatasourceManager {

    // ...

    public static async reInitDatabase(): Promise<void> {

        await this.prepareSequelize();
        await this.dropDatabase();
        await this.initDatabase();
    }
    
    // ...

    private static async initDatabase() {

        await this.sequelize.query(this.getScript("schema"));
        await this.sequelize.query(this.getScript("data"));
    }

    private static getScript(script: "schema" | "data" | "drop") {

        if (!this.scripts.has(script)) {
            this.scripts.set(script, fs.readFileSync(`./acceptance/support/sql/${script}.sql`).toString());
        }

        return this.scripts.get(script)!;
    }
}

Az első meghívott metódus csatlakozik az sqlite adatbázishoz, a másik kettő pedig egyszerű SQL scripteket futtat le (a teljes kód mellékelve lesz a cikk végén). A tesztfuttató pedig magát a reInitDatabase() metódust fogja meghívni.

Külső függések mockolása

A külső service hívásokat a legegyszerűbben talán éppen az Express használatával lehet megoldani. Lényegében tudunk készíteni egy mock-API-t a segítségével, majd a service külső hívásait erre a mock-API-ra irányítjuk. Az LSRS-nek azonban egy fokkal komplikáltabb külső függése van, méghozzá egy OAuth authorization server. Erre egy egészen kiváló eszközt találtam, az OAuth Mock Server NPM package személyében. Segítségével beállíthatunk egy OAuth authorization servert, ami úgy viselkedik, mintha egy igazi lenne, így az alkalmazásunk például képes lesz tokent verifikálni, vagy a tesztfuttató maga tokent igényelni.

OAuth authorization server mockolása

Szükségünk lesz tehát magára az OAuth server mockjára. Az alábbi néhány sorra lesz szükségünk:

public static async initAuthorizerMock() {

    if (!this.authorizerMock) {
        // a konfiguráció beállítása természetesen függ a használt konfigurációs módszertől
        this.authConfig = (new ConfigurationProvider).getAuthConfig();

        // létrehozzuk a servert, beállítjuk az issuert és generálunk egy az alkalmazásunk által
        // használt token aláíró kulcsot
        this.authorizerMock = new OAuth2Server();
        this.authorizerMock.issuer.url = this.authConfig.oauthIssuer;
        await this.authorizerMock.issuer.keys.generate("RS256");
    }

    // végül elindítjuk a szervert, ami egy önálló webserverként fog futni
    // a portra figyeljünk oda, az authorization server portja szabad kell legyen
    await this.authorizerMock.start(9999, "localhost");
}

A fenti kód minden kötelező, az OAuth authorization folyamatokhoz köthető endpointot létrehoz, így például a publikus kulcsokat listázó endpoint is elérhető lesz a token verifikálásához. A service hívása során pedig szükségünk lesz egy érvényes Bearer tokenre, amit az alábbi kód tud majd nekünk generálni:

const token = await this.authorizerMock.issuer.buildToken({
    scopesOrTransform: (header, payload) => {
        payload.iss = this.authConfig.oauthIssuer;
        payload.aud = this.authConfig.oauthAudience;
        payload.scope = scope.join(" ");
    }
});

Mint az a fenti kódban látható, be kell állítanunk az issuert, opcionálisan az audiencet (ha használjuk) és persze a token scopeját (több scope esetén szóközzel elválasztva). A generált token ezután beszúrható az Authorization headerbe, mint Bearer token.

Kössük össze az egészet

Mint azt már említettem, a tesztek futtatását a Cucumber végzi majd, annak is a JS/TS nyelvű átírata, a Cucumber JS. A Cucumber egyébként egy REST API teszteléséhez némiképp túl sok is, de személy szerint nagyon kedvelem a koncepciót ami köré felépítették, így tesztelésre általában azt szoktam használni. Aki nem ismerné, annak dióhéjban: a tesztek leírása természetes, ember által könnyen olvasható módon történik, és egy úgynevezett "glue" kód megírásával tudjuk biztosítani a kapcsolatot a teszt leírása és viselkedése között. Vegyük például az alábbi Cucumber (illetve egész pontosan Gherkin) nyelvű teszt leírást:

Scenario: Downloading the given file is rejected if it does not exist
 Given the user wants to request the file identified by 123456ee-0000-1111-2222-2faf7fa8ded6/test.jpg
  When calling the download endpoint
  Then the application responds with HTTP status NOT_FOUND

A "scenario" szerint egy nem létező filet próbálunk lekérni. A felhasználó elküldi a kérést,, mire a service 404-el válaszol. A "given", "when", "then" szavak utáni mondatok egy-egy kisebb kódrészlethez vannak kapcsolva, például a "calling the download endpoint" egy HTTP hívást indít a service felé. Bővebben talán most nem mennék bele, a Cucumber működése egy önálló cikk lehetne (lehet el is készül egyszer!).

Ami most lényeges a mi szempontunkból, hogy a Cucumbernek is vannak a Jest-hez és amúgy az összes többi teszt futtató frameworkhöz hasonlóan hookjai - Before, After, BeforeAll, AfterAll. Ezen hookok segítségével fogjuk vezérelni az alkalmazást. Mire is lesz pontosan szükségünk?

  1. Az összes teszt futtatása előtt egyszer el kell indítanunk a servicet. Mivel egészen pontosan ilyen hookunk nincs (a BeforeAll minden Cucumber feature file előtt lefut egyszer), ezért korábban már felkészítettük az alkalmazás indító kódot, hogy ne próbálja újra elindítani, ha már egyszer fut.
  2. Az összes teszt futtatása előtt egyszer fel kell építenünk az adatbázist. Azonban az adatbázis ürítésére szükség lehet a tesztek futtatása közben is, ezt ne felejtsük.
  3. Az összes teszt futtatása előtt egyszer el kell indítanunk az OAuth mock servert is.

A következő lépésben ezért létrehozunk egy step definition file-t, ami ezeket a közösen használt lépéseket fogja kezelni. A fenti három lépést az összes teszt előtt le kell futtatnunk egyszer, tehát létrehozunk egy BeforeAll hookot, amiben meghívjuk a megfelelő kezelőket:

BeforeAll({timeout: 10000}, async () => {
    // elindítjuk az alkalmazást
    await ApplicationManager.start();
    // inicializáljuk az adatábist
    await DatasourceManager.reInitDatabase();
    // elindítjuk az OAuth mockot
    await AuthManager.initAuthorizerMock();
});

Mivel a tesztfuttatás szemetet hagyhat maga után, szükségünk lesz egy AfterAll hookra, ahol elvégezzük a szükséges takarító lépéseket.

AfterAll(async () => {
    // leállítjuk az OAuth mockot
    await AuthManager.stopAuthorizerMock();
    // töröljük az adatbázis filet, illetve az LSRS létrehoz egy temp mappát is, amit itt törlünk
    ApplicationManager.cleanUp();
});

Majdnem készen vagyunk, de korábban említettük, hogy az adatbázis on-demand takarítása is szükséges lesz. Ezt meg tudjuk oldani például egy taggel, ha nagyon elegánsak akarunk lenni.

// a hook egy-egy scenario után fut majd le, de csak akkor, ha a @DirtiesDatabase taggel van megjelölve
After({tags: "@DirtiesDatabase"}, async () => {
    await DatasourceManager.reInitDatabase();
});
@DirtiesDatabase
Scenario: Updating the metadata of an existing file
  # ...

Egy utolsó lépés maradt még, ez pedig a Cucumber beállítása, hogy ténylegesen használja is ezeket a hookokat. Ehhez a Cucumber konfigurációs fájlát kell módosítunk:

default:
  # ...
  require:
    # az init.ts a korábban említett alkalmazás konfigurációs felülbírálásokat tartalmazza
    - acceptance/support/init.ts
    # a step-definitions mappában van az step definition file is, amiben a fenti hookokat definiáltuk
    - acceptance/step-definitions/**/*.ts
  # ...

Elindítva a Cucumber teszteket, mielőtt a futtató bármilyen tényleges scenariot lefuttatna, az alkalmazás maga el fog indulni, populált adatbázissal és egy OAuth mock serverrel, ezzel egészen közel kerülve ahhoz, amit a @SpringBootTest annotációval megtehetünk pár sornyi konfigurációval egy Boot alkalmazásban. A JS/TS ekoszisztéma védelmében viszont a Cucumber konfigurációja Javaban (JUnit-tal) lényegesen körülményesebb - de erre majd talán a következő cikkemben visszatérünk.

Az LSRS-hez készült teljes Cucumber teszt futtató implementáció

Kommentek

Komment írásához jelentkezz be
Bejelentkezés

Még senki nem szólt hozzá ehhez a bejegyzéshez.