Testen in Mule mit Datenbanken – Teil 3: Datenbanken mit Docker

Keine Kommentare

In den ersten zwei Teilen der Artikelserie haben wir einen einfachen REST-Service in Mule implementiert, der seine Informationen aus einer Datenbank bezieht. Zum Testen haben wir zunächst die Datenbank gemockt und in einem zweiten Schritt eine In-Memory-Datenbank verwendet.

Beide Maßnahmen sind gute Lösungen, um schnelle Unit-Tests (mit MUnit) zu schreiben, die zunächst die eigentliche Logik in den Flows und dann auch die Konfiguration des Endpoints testen. Die Maßnahmen greifen aber zu kurz, wenn Features der Datenbank genutzt werden sollen, die nicht kompatibel mit der durch Mulesoft verwendeten In-Memory-Datenbank H2 sind.

Ein Ausweg ist natürlich, die Tests gegen eine produktionsähnliche Datenbank in einer speziellen Testumgebung zu fahren. Häufig wird dazu eine Integrations- oder Akzeptanztestumgebung bereitgestellt. Dieser Weg hat allerdings den Nachteil, dass sich mehrere Entwickler bzw. im Endeffekt die Tests eine Umgebung teilen müssen und sich dadurch in die Quere kommen können, weil parallele Zugriffe und gemeinsame Testdaten zu Konflikten führen. Für automatisierte Tests ist das eine eher problematische Lösung.

Docker-Container erlauben es, Server in kurzer Zeit und mit relativ wenigen Ressourcen für einzelne Tests oder Test-Suiten zu starten, initialisieren und anschliessend wieder wegzuwerfen. Die Struktur ist dabei grundsätzlich ähnlich zur Verwendung von In-Memory-Datenbank, wie ich im letzten Artikel beschrieben habe.

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

Docker-Datenbanken mit testcontainers.org

Um Docker-Container in Java-Unittests zu verwenden gibt es eine Library testcontainers.org. Neben generischen Modulen gibt es spezielle für verschiedene relationale und nicht-relationale Datenbanken.

Eine Anforderung aus Sicht einer Mule-Anwendung ist, dass die Parameter für den Endpoint statisch vorgegeben werden müssen. Das ist eine wesentliche Einschränkung bei der Verwendung der testcontainers-Bibliothek, weil diese beispielsweise das Paradigma vertritt, dass Ports für Testinstanzen dynamisch vergeben werden und von der Anwendung abgefragt und dann verwendet werden (ein Pattern, das grundsätzlich Sinn macht und im Java-Umfeld auch leicht umzusetzen ist). Leider müssen die Endpoints in Mule in der Regel fest definiert werden, so dass diese Art der Verwendung von testcontainers-Modulen nicht möglich ist.

Die per JDBC-benutzbaren Datenbanken erlauben allerdings die Vorgabe aller relevanten Parameter per URL. Es gibt eine spezielle Klasse (org.testcontainers.jdbc.ContainerDatabaseDriver), die automatisch anhand der URL das passende Image lädt und startet und dann die Verbindung aufbaut. Die URL muss lediglich im Vergleich zur Original-URL um den Einschub :tc zwischen jdbc und dem Datenbank-Typen ergänzt werden. Zusätzlich gibt es testcontainers-spezifische Query-Parameter, mit denen die URL erweitert werden kann um die Container zu steuern (z.B. um Initialisierungsskripte mitzugeben).

Technisch gesehen verwendet die Klasse ContainerDatabaseDriver die URL, um das passende Image zu erkennen und mit Docker zu starten. Bei der ersten Verwendung einer URL wird dann das gestartete Image in einen Cache gesteckt, so dass bei mehrfachem Zugriff nicht jedes Mal ein neuer Container gestartet wird.

Leider setzt das die Verwendung der URL als Zugriffsmethode auf die Datenbank voraus. Aus Mule-Sicht bedeutet das, dass die URL-Connection in den Connector-Konfigurationen verwendet werden muss, nicht die einzeln spezifizierten Bestandteile wie Host, Port, Database oder die Spring-Bean.

Verwendung von db-testcontainer

Die Steuerung der testcontainer wird durch die Verwendung eines Mule-Konnektors vereinfacht: munit-db-testcontainers-connector. Dieser Konnektor bietet ähnlich wie die dbserver-Komponente von MUnit die Möglichkeit, einen DB-Server zu konfigurieren, zu starten und zu stoppen.

Für die Verwendung müssen zwei Abhängigkeiten definiert werden (in der pom.xml in mavenized-Projekten bzw. durch Hinzufügen der Libraries in das Projekt). In der pom sind das die Abhängigkeit zum Konnektor und des benutzten Datenbank-Module aus testcontainers, in unserem Fall des Postgres-Testcontainers (zu beachten sind natürlich die passenden bzw. aktuellen Versionen):

<dependency>
    <groupId>com.unittesters</groupId>
    <artifactId>munit-db-testcontainers-connector</artifactId>
    <version>0.0.6-SNAPSHOT</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.10.5</version>
    <scope>test</scope>
</dependency>

Die Tests

Die Verwendung der testcontainer-Konnektoren entspricht von der Struktur her den Tests unter Verwendung der dbserver-Komponente aus dem letzten Artikel. Zunächst wird die Konfiguration erstellt (die Properties werden aus einer für diesen Test angelegten .properties-Datei geladen):

<context:property-placeholder location="api.testcontainer-connector.properties" />
 
<db-testcontainers:config name="DB_TestContainers_Configuration" 
    url="${db.url}" 
    username="${db.user}" password="${db.password}" 
    doc:name="DB TestContainers: Configuration" dbInitScript="USERS.sql"/>

Die wesentlichen Properties aus der Datei sind die folgenden: Die wesentliche Einstellung ist die db.url und dabei der tc Bestandteil. Diese Properties werden auch von der Datenbank-Konfiguration genutzt, die im eigentlichen Flow verwendet wird. So ist sichergestellt, dass auch dort auf die Docker-Datenbank referenziert wird.

db.database=mule
db.user=test
db.password=test
db.port=5432
db.host=localhost
db.version=9.6.8
db.url=jdbc:tc:postgresql:${db.version}://${db.host}:${db.port}/${db.database}

In dem before-suite-Abschnitt der Test-Suite wird der Konnektor benutzt, um den Container zu starten und in after-suite wird er gestoppt. Das in der Konfiguration angegebene Init-Script wird einmalig beim Starten ausgeführt. In diesem Test wird es verwendet, um das Schema zu erstellen.

<munit:before-suite name="testcontainerBefore_Suite" description="MUnit Test">
    <db-testcontainers:start-db-container config-ref="DB_TestContainers_Configuration" doc:name="DB TestContainers" runInitScript="true"/>
</munit:before-suite>
 
<munit:after-suite name="testcontainerAfter_Suite" description="Ater suite actions">
    <db-testcontainers:stop-db-container config-ref="DB_TestContainers_Configuration" doc:name="DB TestContainers"/>
</munit:after-suite>

Ab jetzt kann der normale Datenbank-Prozessor von Mule verwendet werden, um die Datenbank in einen passenden Zustand zu bringen. Für unseren Test muss ein User geladen werden. Dies geschieht in der Before-Test-Phase:

<munit:before-test name="testcontainerBefore_Test" description="Before tests actions">
    <db:update config-ref="Generic_Database_Configuration" doc:name="Trunc users">
        <db:dynamic-query><![CDATA[TRUNCATE TABLE users;]]></db:dynamic-query>
    </db:update>
    <db:insert config-ref="Generic_Database_Configuration" doc:name="Insert user">
        <db:parameterized-query><![CDATA[INSERT INTO users (id, username, lastname, firstname) VALUES(1234, 'jdoe', 'Doe', 'John');]]>
        </db:parameterized-query>
    </db:insert>
</munit:before-test>

Die Tests selber sehen jetzt genauso aus wie in den letzten Artikeln:

<munit:test name="get_users_idTest-happy-path" description="Test">
    <set-variable variableName="id" value="#[1234]" doc:name="set id"/>
    <flow-ref name="get_users_id" doc:name="get_users_id"/>
    <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"/>
    <flow-ref name="get_users_id" doc:name="get_users_id"/>
</munit:test>

Zusammenfassung

Es ist in der Regel nicht ausreichend, die Datenbanken in Tests zu mocken. Zu viel wird dabei dann nicht getestet. Häufig reicht es aber auch nicht, eine In-Memory-(H2)-Datenbank zu nutzen. Es bestehen zuviele mögliche Inkompatibilitäten, die man gerne frühzeitig finden möchte. Andererseits ist die Verwendung von dedizierten Testumgebungen teuer, führt zu Abhängigkeiten und benötigt extra Abstimmungsprozeduren. Mit Docker-Containern steht eine Alternative für viele Einsatzgebiete zur Verfügung, die auch in MUnit-Tests für Mule ohne großen Aufwand verwendet werden kann.

Nutzt man die vorhandenen Frameworks und ein paar einheitliche Verfahren, unterscheiden sich die Tests selber dann nicht mehr großartig von den gemockten oder In-Memory-DB-Tests. Integrationstests lassen sich auf diese Art relativ einfach und gut verstehbar implementieren und immer noch mit guter Performanz ausführen.

Im nächsten Artikel dieser Serie wird die gesamte Thematik nochmals mit der neuer Mule Version 4 umgesetzt.

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.