Testen in Mule mit Datenbanken – Teil 2: In-Memory-Datenbanken

Keine Kommentare

Nachdem ich im ersten Teil der Artikelserie das Mocken einer Datenbank im Rahmen von Munit-Tests beschrieben habe, werde ich im Folgenden zeigen, wie eine In-Memory-Datenbank zum Testen benutzt werden kann.

Warum sollten überhaupt In-Memory-Datenbanken zum Testen von Mule-Flows verwendet werden? Die eigentliche Logik der Flows wird in Unit-Tests üblicherweise mit gemockten Datenbanken überprüft. Allerdings werden dabei einige Aspekte der Flows einfach ausgeklammert, die in späteren Testphasen eventuell teurer zu finden und beheben sind.

Als erstes sind die Konfigurationsprameter des Connectors zu prüfen: Auch wenn die konkreten Werte für die verschiedenen Umgebungen natürlich nicht zu testen sind, kann zumindest die korrekte Syntax geprüft werden. Desweiteren wird beim Mocking der Datenbank-Aufruf selbst nicht geprüft: Also stimmt die SQL-Syntax und werden alle Parameter wie erwartet übergeben? Und zuguterletzt wird beim Mocking die Rückgabe des Ergebnisses in einem Format fest vorgegeben, das möglicherweise von dem Format abweicht, das die Datenbank tatsächlich liefert.

All das könnte man mit einer „echten“ Datenbank natürlich auch testen. Allerdings ist es deutlich aufwendiger, so eine Datenbank zu starten. Im Sinne von schnellen Testzyklen kann es also sinnvoll sein, bestimmte Tests mit einer In-Memory-Datenbank durchzuführen, die sich idealerweise ähnlich verhält wie die Produktionsdatenbank.

In weiteren Module- oder Integrationstest sollen aber auch die Konnektoren mit getestet werden, weswegen die entsprechenden Services mitlaufen müssen. Für einige dieser Services bietet das Anypoint-Studio mit Munit bestimmte Server-Komponenten an (Database, FTP).

Mule 3.9 liefert eine solche Datenbank als Bestandteil von Munit mit. Tatsächlich steckt dahinter eine H2-Datenbank, die häufig verwendet wird, um schnelle Unit- oder Modultests mit einer quasi-echten-Datenbank auszuführen.

Der vollständige Code für die gesamte Artikelserie liegt in gitlab.

Modul-Tests mit In-Memory Datenbank

Installation des Datenbank-Moduls

Die MUnit-Tools liefern ein Modul (namespace dbserver), mit dem eine InMemory-Datenbank einfach in Tests integriert werden kann. Vom AnypointStudio aus kann dieses Modul über die Munit Update Site installiert werden:

Select DBServer in Munit-Update Site

Select DBServer in Munit-Update Site

Vorbereiten der Datenbank

Die eigentliche Anwendung verwendet einen generischen Datenbank-Konnektor, um die Datenbank anzusprechen (ich habe in der Anwendung eine Postgres-DB angenommen):

<db:generic-config name="Generic_Database_Configuration"
    url="${db.url}"
    driverClassName="org.postgresql.Driver"
    doc:name="Generic Database Configuration"/>

Ausgehend von dem Szenario aus dem ersten Artikel müssen die Tests ein wenig angepasst werden. Zunächst wird das Mocking der Konnektoren abgeschaltet. Das geschieht auf Ebene der Test-Suite:

<munit:config name="munit" doc:name="MUnit configuration" mock-connectors="false" mock-inbounds="false"/>

Als nächstes müssen Test-Properties geladen werden, die für die Ausführung der Tests die Datenbank-Konfiguration definieren:

<context:property-placeholder location="api.dbserver.properties" />

Diese Datei enthält die Einstellungen, um die In-Memory-Datenbank anzusprechen, sobald sie gestartet wurde. Es werden die Properties definiert, die in der DB-Konfiguration der Anwendung verwendet werden: db.url=jdbc:h2:mem:postgres

Als nächstes konfigurieren wir den Datenbankserver. Man kann den H2-Datenbankserver in verschiedenen Emulationsmodi laufen lassen. In diesem Fall benutzen wir den PostgreSQL-Modus. Zusätzlich wird ein SQL-File angegeben, das beim Starten ausgeführt wird. Ich verwende das in der Regel, um das Schema vorzubereiten und ggf. Daten zu laden, die für alle Tests gelten sollen.

<dbserver:config name="DB_Server" database="postgres"
    connectionStringParameters="MODE=PostgreSQL" doc:name="DB Server"
    sqlFile="USERS.sql"/>

Für unsere Tests enthält USERS.sql folgenden Code:

CREATE TABLE users (
    id int8 NOT NULL,
    username VARCHAR NOT NULL,
    lastname VARCHAR NOT NULL,
    firstname VARCHAR NULL
);

Aus Performance-Gründen ist es sinnvoll, die Datenbank möglichst selten hochzufahren, zu starten und zu stoppen. Daher wird das in den Before- bzw. After-Phasen der Test-Suite erledigt:

munit:before-suite name="get_users_id-test-suite-dbserverBefore_Suite"
    description="Before suite actions"&gt;
    <dbserver:start-db-server config-ref="DB_Server" doc:name="Start DB Server"/>
</munit:before-suite>
 
<munit:after-suite name="get_users_id-test-suite-dbserverAfter_Suite" description="After suite actions">
    <dbserver:stop-db-server config-ref="DB_Server" doc:name="Stop DB Server"/>
</munit:after-suite>

Wenn mehrere Tests in einer Suite enthalten sind und auf dieselbe Datenbank zugreifen, nutze ich die Before-/After-Test-Abschnitte, um per SQL mit TRUNK oder DELETE die Datenbank auf einen definierten einheitlichen Zustand zu bringen:

start and stop database-server für test-suite

<munit:before-suite name="dbserverBefore_Suite" description="Start DB on Testsuite-startup">
    <dbserver:start-db-server config-ref="DB_Server" doc:name="Start DB Server"/>
</munit:before-suite>
 
<munit:after-suite name="dbserverAfter_Suite" description="Ater suite actions">
    <dbserver:stop-db-server config-ref="DB_Server" doc:name="Stop DB Server"/>
</munit:after-suite>
 
<munit:before-test name="dbserverBefore_Test" description="Before tests actions">
    <dbserver:execute config-ref="DB_Server" sql="TRUNCATE TABLE users" doc:name="Trunc users"/>
    <dbserver:execute config-ref="DB_Server" 
        sql="INSERT INTO users (id, username, lastname, firstname) VALUES(1234, 'jdoe', 'Doe', 'John')"
        doc:name="Insert user"/>
</munit:before-test>

Implementierung der Unit-Tests

Mit einer „schnellen“ Datenbank im Hintergrund möchte ich zunächst zeigen wie, vergleichbar zur vollständig gemockten Datenbank, Unit-Tests aussehen können.

Für die Test des Happy-Path und user-not-found für den Flow-Under-Test get_users_id gilt: Das war es fast. Im Vergleich zur Version des Tests für die gemockte Datenbank muss lediglich das Mocking entfernt werden. Die Tests sehen jetzt also so aus:

<munit:test name="get_users_idTest-happy-path" description="Happy-Path Test">
    <set-variable variableName="id" value="#[1234]" doc:name="set id=1234"/>
    <flow-ref name="get_users_id" doc:name="get_users_id"/&gt;
    <assert-object-equals:compare-objects expected-ref="#[getResource('user-expected.json').asStream()]"
        doc:name="user is returned"/>
</munit:test>
 
<munit:test name="get_users_idTest-user-not-found" description="Expects a NotFoundException"
    expectException="org.mule.module.apikit.exception.NotFoundException">
    <set-variable variableName="id" value="#[2]" doc:name="set id=2"/>
    <flow-ref name="get_users_id" doc:name="get_users_id"/>
</munit:test>

Ändern Tests Werte in der Datenbank und sollen diese Werte mit einem Assert geprüft werden, kann die dbserver-Operation validate-that verwendet werden. Es wird eine Query und das erwartete Ergebnis (als csv) angegeben. Da dabei allerdings lediglich die Ergebnisse als String verglichen werden, lohnt es sich, alle möglicherweise zufälligen aber relevanten Bedingungen (verwendete Spalten, Spaltenreihenfolge, Groß-/Kleinschreibung, etc.) explizit im Query-Statement zu spezifizieren. Für Details hierzu soll ein Verweis auf die Dokumentation reichen.

Implementierung der Integrationstests

Mit Tests zu prüfen, ob die Subflows funktionieren ist gut und schön. Allerdings ist damit nicht gewährleistet, dass die APIs auch so funktionieren wie erwartet. Deswegen sollten Integrationstests den End-2-End-Aufruf prüfen. Um ein komplettes Set an Tests für per RAML spezifizierte APIs zu erzeugen sei auf einen anderen Artikel verwiesen. Im Rahmen dieses Artikels unterscheiden sie sich nicht sehr stark von den Unit-Tests. Die Datenbank wird auf exakt dieselbe Art und Weise angesteuert, lediglich der Aufruf des Flow-Under-Test und das Prüfen des Ergebnis unterscheiden sich leicht:

<http:request-config name="HTTP-APIKit" host="localhost" port="${http.port}" basePath="/api/" doc:name="HTTP Request Configuration">
    <http:raml-api-configuration location="api.raml"/>
</http:request-config>
 
<munit:test name="users-id-get:/users/{id}:apiConfigTest" description="Test apikit-flow happy-path">
    <http:request config-ref="HTTP-APIKit" path="/users/{id}" method="GET" doc:name="HTTP">
        <http:request-builder>
            <http:uri-param paramName="id" value="1234"/>
        </http:request-builder>
    </http:request>
    <munit:assert-on-equals expectedValue="#[200]" actualValue="#[message.inboundProperties['http.status']]"
        doc:name="Assert http.status is 200"/>
    <assert-object-equals:compare-objects expected-ref="#[getResource('user-expected.json').asStream()]"
        doc:name="user is returned "/>
</munit:test>
 
<munit:test name="users-id-get:/users/{id}:apiConfigTest-nouser" description="Test apikit-flow with missing user">
    <http:request config-ref="HTTP-APIKit" path="/users/{id}" method="GET" doc:name="HTTP">
        <http:request-builder>
            <http:uri-param paramName="id" value="2"/>
        </http:request-builder>
        <http:success-status-t-validator values="404"/>
    </http:request>
    <munit:assert-on-equals expectedValue="#[404]" actualValue="#[message.inboundProperties['http.status']]"
        doc:name="Assert http.status is 404"/>
</munit:test>

Zusammenfassung

(M)Unit-Tests, die Datenbankzugriffe komplett mocken, ignorieren wesentliche Teile der zu testenden Mule-Anwendungen. Das ist einerseits vorteilhaft, weil so wirklich nur die gewünschte Logik getestet wird und Tests möglichst schnell ausgeführt werden sollen. Andererseits können so Fehler in der Konfiguration von Datenbank-Endpoints nicht gefunden werden.

In diesem zweiten Teil der Artikelserie haben wir gesehen, wie als Kompromiss eine (In-Memory)-Datenbank für eine Reihe von MUnit-Tests gestartet werden kann, so dass aus Mule-Sicht die echten Endpoints ausgeführt und mit getestet werden.

Ein Nachteil ist allerdings immer noch, dass die von Mule bereitgestellte In-Memory-Datenbank die spätere externe Datenbank lediglich simuliert und sich diese daher anders verhalten kann als die Datenbank in Produktion. Im Endeffekt müssen daher auch Tests gegen eine produktionsähnliche Datenbank ausgeführt werden. Dieser Aufgabe widmet sich der nächste Teil dieser Artikelserie.

Christian Langmann

Christian ist seit 2013 für codecentric als Solution Consultant unterwegs. Er begeistert sich für Craftsmanship im gesamten Lebenszyklus der Softwareentwicklung, insbesondere für Continuous Delivery und Software Architekturen. Das Zusammenbringen verschiedener Skills im Team ist für ihn eine der interessantesten Herausforderungen. Daneben sind Integrationstechnologien sein Steckenpferd.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.