Dos und Dont's

Wohl oder übel, es muss gemacht werden: Datenbank-Tests

Colin Vipurs

© Shutterstock / vchal

Es gibt zumindest eine Sache, über die wir uns wohl alle einig sind, wenn es um Datenbank-Tests geht: Es ist nicht einfach. In diesem Beitrag erläutert der Testing-Guru Colin Vipurs, der im Rahmen der JAX London einen Talk zu Unit-Tests halten wird, die Stärken und Schwächen der üblichen Ansätze für Datenbank-Tests.

Während meiner vielen Jahre in der Softwareentwicklung musste ich Tests auf vielen verschiedenen Levels und auf diversen Datenbank-Instanzen durchführen, inklusive RDBMS und NoSQL. Eine Sache bleibt dabei immer gleich – es ist schwer.

Es gibt ein paar Ansätze, aus denen Sie wählen können, wenn Sie die Datenbank-Schicht ihres Codes testen möchten. Ich möchte einige davon mit ihren jeweiligen Stärken und Schwächen vorstellen.

Mocking

Das ist eine Technik, die ich früher verwendet habe, bei der ich mich heute aber deutlich dagegen ausspreche. In meinem Buch „Tests Need Love Too“ setze ich mich damit auseinander, warum man niemals Third Party Interfaces mocken sollte. Aber falls Sie das Buch nicht gelesen haben sollten (das sollten Sie wirklich tun!), erkläre ich es noch einmal.

Wenn es um das Mocken von Code geht, der Ihnen nicht gehört, validieren Sie, sodass sie den Code auf jene Weise aufrufen, von der Sie wahrscheinlich denken, dass Sie die richtige ist. Aber – und das ist der wichtige Punkt – das könnte falsch sein. So lange Sie keine Tests auf höheren Levels durchführen, die Ihren Code abdecken, werden Sie nichts wissen, bis Ihr Code in die Produktion geht. Daneben ist es schwer, raw JDBC zu mocken, wirklich sehr schwer. Schauen Sie sich zum Beispiel den Test-Codeschnipsel in Listing 1 an.

Colin-listing-1

In diesem Test gibt es nicht nur eine große Menge von Ausnahmen, die eingefügt werden müssen, sondern es ist auch eine umfangreiche Nutzung von JMock „states“ notwendig um zu verifizieren, dass alle Aufrufe in der richtigen Reihenfolge durchgeführt werden. Wegen der Arbeitsweise von JDBC verletzt dieser Test auch die Richtlinie, dass Mocks nie Mocks ausgeben dürfen und geht dabei sogar einige Level in die Tiefe! Selbst wenn man das alles zum Laufen bekommt, kann etwas Einfaches wie ein Tippfehler im SQL dazu führen, dass zwar alle Tests grün sind, aber es immer noch zu schwerwiegenden Fehlern kommt, wenn der Code in die Produktion geht.

Ein letzter Gedanke zum Mocking: Kein geistig gesunder Entwickler würde heutzutage raw JDBC benutzen, sondern zu einem der verfügbaren höheren Abstraktionslevel greifen. Gleiches gilt auch hier. Stellen Sie sich eine Reihe von Tests vor, die aufgesetzt wurden, um gegen JDBC zu mocken und außerdem, dass Ihr Code zum Spring JdbcTemplate, zu jOOQ oder zu Hibernate wechselt. Die Tests müssen nun umgeschrieben werden um gegen diese Frameworks zu mocken – keine perfekte Lösung.

Testen gegen echte Datenbanken

Es klingt vielleicht doof, aber der beste Weg um zu verifizieren, dass die Interaktion zwischen Ihrem Code und der Datenbank wie erwartet funktioniert, ist, ihn wirklich mit der Datenbank interagieren zu lassen. Abgesehen davon, dass so sichergestellt wird, dass Sie die API Ihrer Wahl richtig verwenden, kann dieses Vorgehen Dinge verifizieren, die per Mocking nicht verifizierbar sind, zum Beispiel, dass Ihr SQL syntaktisch korrekt ist und tut, was Sie sich erhoffen.

In-Memory-Datenbanken: Einer der einfachsten und schnellsten Wege um eine Datenbank aufzusetzen, gegen die dann getestet wird, ist die Verwendung einer der verfügbaren In-Memory-Versionen, z. B. H2, HSQL oder Derby. Wenn es Sie nicht stört, eine Spring Dependency in Ihren Code einzufügen, kann das Test-Setup einfach genau darin bestehen (Listing 2).

Colin-listing-2

Dieser Code erstellt eine Instanz der H2-Datenbank, lädt das in schema.sql definierte Schema und alle Tests in test-data.sql. Das zurückgegebene Objekt implementiert javax.sql.DataSource und kann somit direkt in jede Klasse injiziert werden, die es benötigt.

Einer der großen Vorteile dieses Ansatzes ist, dass er schnell ist. Sie können eine neue Datenbankinstanz für wirklich jeden Test erstellen, der eine benötig, wodurch Sie eine absolute Garantie haben, dass die Daten sauber sind. Sie brauchen auch keine extra Infrastruktur auf ihrem Entwicklungsrechner dafür, weil alles in der JVM erledigt wird. Dieser Weg hat aber auch Schattenseiten.

Wenn Sie nicht auf derselben In-Memory-Datenbank deployen, die Sie in Ihrem Test nutzen, bekommen sie automatisch Probleme mit der Kompatibilität, die sich nicht zeigen, bevor Sie ein höheres Level der Tests erreichen – oder, Gott bewahre, die Produktion! Weil Sie eine andere Datenquelle für ihre Produktionsinstanz verwenden, passiert es ganz schnell, Optionen in der Konfiguration zu übersehen, die aber notwendig sind, damit der Treiber korrekt läuft. Kürzlich ist mir ein Setup untergekommen, wo H2 so konfiguriert wurde, dass es mit einer DATETIME-Spalte arbeitete, die eine Präzision auf die Millisekunde genau erforderte. Das gleiche Definitionsschema wurde in der Produktion mit einer MySQL-Instanz verwendet, die aber nicht nur voraussetze, dass es sich um DATETIME(3) handelt, sondern auch erforderte, dass useFractionalSeconds=true dem Treiber zur Verfügung steht. Das Problem wurde erst entdeckt, nachdem die Tests von einer H2-Instanz zu einer echten MySQL-Instanz migriert wurden.

Echte Datenbanken: Wo möglich, empfehle ich, gegen eine Datenbank zu testen, die so nah wie möglich an der ist, die in der Produktionsumgebung verwendet wird. Eine Menge verschiedener Faktoren kann das schwer oder sogar unmöglich machen, zum Beispiel, dass Lizenzen für kommerzielle Datenbanken Geld kosten, wodurch es ungeheuer teuer wird, sie auf jedem Entwicklungsrechner zu installieren.

Ein klassischer Weg, um dieses Problem herum zu kommen, ist die Verwendung einer einzigen Entwicklungsdatenbank, mit der sich dann alle verbinden. Das kann allerdings selbst auch wieder zu verschiedenen Arten von Problemen führen, wovon die Performance nicht das kleinste ist (diese Datenbanken scheinen immer auf der ältesten und langsamsten Hardware installiert zu werden). Auch die Wiederholbarkeit der Tests gehört dazu. Das Problem damit, eine Datenbank mit mehreren Entwicklern zu teilen, ist, dass mehrere parallel ausgeführte Tests zu uneinheitlichen Ergebnissen und zum unvorhergesehenen Verschieben von Daten führen können. Je größer die Zahl der Nutzer der Datenbank wird, desto schlimmer werden diese Probleme – bringt man den CI-Server noch mit ins Spiel, kann man viel Zeit damit verbringen, Tests zu wiederholen und herauszufinden, ob jemand anders gerade Tests durchführt, um einen sauberen Aufbau zu bekommen.

Wenn man eine „freie“ Datenbank wie MySQL oder eine der vielen freien NoSQL-Optionen verwendet, kann es immer noch problematisch sein, sie in der lokalen Entwicklungsumgebung zu installieren – Probleme wie die Notwendigkeit, mehrere Versionen gleichzeitig zu betreiben oder das Problem, alle darüber informiert zu halten, was genau notwendig ist, um die Infrastruktur am Laufen zu halten und an welche Ports sie gebunden werden müssen. Dieses Modell erfordert, dass die Software in Betrieb ist, bevor ein Build durchgeführt werden kann, wodurch es zeitaufwändiger als nötig wird, Mitarbeiter auf ein neues Projekt anzusetzen.

Glücklicherweise sind in den letzten Jahren einige Tools herausgekommen, die diesen Vorgang vereinfachen. Die wichtigsten davon sind Vagrant und Docker. Eine lokale MySQL-Version aufzusetzen, kann mit Docker einfach nur über das Ausführen des folgenden Kommandos erledigt werden:

 $ docker run -p 3306:3306 -e MYSQL_ROOT_PASSWORD=bob mysql

Dadurch wird eine self-contained Version des neusten MySQL auf den Local Port 3306 gemapped, unter Verwendung des eingegebenen Root-Passworts. Selbst auf meinem 4 Jahre alten MacBook Pro hat das nur 12 Sekunden gedauert, nachdem der anfängliche Image-Download beendet war. Wenn Sie Redis 2.8 benötigen, können Sie auch das zu Docker sagen:

 $ docker run -p 6389:6389 redis:2.8

Oder die neuste Version, auf einem anderen Local Port laufend:

 $ docker run -p 6390:6389 redis:latest

Das kann einfach in Ihr Build-System eingefügt werden um den gesamten Prozess zu automatisieren, wodurch Ihre Entwickler auf ihren lokalen Rechnern nur noch Docker (oder Vagrant) brauchen und die für den Build benötigte Infrastruktur in das Script des Build gepackt werden kann.

Testing Ansatz: Jetzt, wo Ihre Datenbank in Betrieb ist, stellt sich die Frage „Wie soll ich testen?“. Die Antwort darauf wird variieren, abhängig davon, was Sie machen. Ein neues Projekt wird vermutlich noch viele Veränderungen am relationalen Schema erleben, während ein etabliertes Projekt sich mehr um das Lesen der existierenden Daten kümmert. Sind die Daten kurz- oder langlebig? Die meisten* Redis-Anwendungen benutzen Daten in Redis wie Cache, weshalb sie sich weniger Gedanken darüber machen müssen, existierende Daten zu lesen.

*Die meisten, nicht alle. Ich habe mit ein paar Systemen gearbeitet, in denen Redis der primäre Datenspeicher ist.

Zuerst muss festgehalten werden, dass es für funktionale Tests am besten ist, mit einer sauberen, leeren Datenbank anzufangen. Wiederholbarkeit ist der Schlüssel und eine leere Datenbank ist ein todsicherer Weg, das zu gewährleisten. Ich bevorzuge es, den Test selbst dafür sorgen  zu lassen, indem er alle Daten vor Beginn des Tests löscht, nicht am Ende. Wenn der Test schief geht, ist eine immer noch beschriebene Datenbank der einfachste Weg, um das Problem zu finden. Wenn am Ende des Tests automatisch alle Daten aufgeräumt werden, bleibt keine Spur zurück und nur wenn wirklich alle Tests diesem Muster folgen, ist alles gut.

Eine beliebte Technik, um Testdaten anzulegen, ist die Verwendung von Tools wie DbUnit, die Ihnen erlauben, Ihre Daten in Dateien auszudrücken und leicht zu laden. Ich habe zwei Probleme damit: Das erste ist, dass bei der Verwendung einer relationalen Datenbank eine Dopplung zwischen dem DB-Schema selbst und den Testdaten entsteht. Eine Änderung des Schemas erfordert nicht nur die Veränderung der Dataset-Dateien, sondern bedeutet, dass die Testdaten nicht mehr in der Test-Klasse selbst enthalten sind, was einen Context-Switch zwischen Tests und Daten bedeutet. Ein Beispiel für ein DbUnit XML file finden sie in Listing 3.

Eine Frage, die ich häufig von Neulingen im DB-Testing höre, ist, ob sie die Daten round-trippen sollten oder die Datenbank direkt für die Verifikation ansprechen. Round-Tripping ist ein wichtiger Bestandteil des Testzyklus, weil es wirklich wichtig ist zu wissen, dass die geschriebenen Daten auch gelesen werden können. Ein Problem damit ist aber, dass Sie im Wesentlichen dadurch zwei Dinge auf einmal testen, sodass es schwer wird, ein Problem auf der einen Seite genau einzugrenzen. Wenn Sie TDD verwenden (natürlich tun Sie das), wird es vermutlich sehr unangenehm werden, das Problem zu finden, weil die Zeit zwischen rot und grün sehr hoch sein kann und Sie nicht das schnelle Feedback bekommen, an das sie gewöhnt sind.

Die Technik, die ich mir angewöhnt habe, ist ein gemischter Ansatz, der es mir erlaubt, das Beste aus beiden Methoden zu bekommen, während ich gleichzeitig die Nachteile vermeide. Der erste Test, den ich schreibe, ist immer ein reiner Lese-Test, der Daten von Hand in den Test selbst einfügt. Obwohl das etwas wie eine Verdopplung wirkt, und auch tatsächlich ein wenig ist, wird der Testcode jede Logik umgehen, die der Schreib-Pfad erzeugt. Ein Einschub zum Beispiel, der eine „ON DUPLICATE KEY“-Klausel enthält, wird das nicht tun und der Annahme folgen, dass diese Angabe nicht existiert, weil der Test die komplette Kontrolle über den Status der Daten hat. Der Test wird dann den Produktions-Code verwenden um zurück zu lesen, was der Test eingefügt hat, und schwupps, ist das zurücklesen verifiziert. Ein Beispiel für einen Lese-Test finden Sie in Listing 4.

Colin-listing-3-4

Sobald der Pfad grün ist, wird der Test die Daten round-trippen und den Produktions-Code sowohl zum Lesen als auch zum Schreiben verwenden. Da schon bekannt ist, dass der Lese-Pfad in Ordnung ist, muss man sich nun nur noch über den Schreib-Pfad Sorgen machen. Ein Fehler im Lese-Pfad irgendwann in der Zukunft wird zwar beide Tests-Sets zum Scheitern bringen, aber ein Fehler, der nur im Schreib-Pfad auftritt, hilft dabei, zu isolieren wo das Problem sich befindet. Wenn Sie ein Test-DSL zur Verifizierung des Lese-Pfads verwenden, kann es hier wiederverwendet werden und spart so Zeit, die für das Schreiben dieses lästigen Zeugs drauf geht. Ein Beispiel für einen Round-Trip Test finden Sie in Listing 5.

Colin-listing-5

Aufmacherbild: Debugging binary code with bug inside magnifying glass von Shutterstock.com / Urheberrecht: vchal

Geschrieben von
Colin Vipurs
Colin Vipurs
Colin Vipurs hat 1998 angefangen, in der professionellen Softwareentwicklung zu arbeiten und kurz darauf schon seinen ersten Bug veröffentlicht. Im Laufe seiner Karriere war er in diversen Geschäftsfeldern tätig, wobei er eine große Vielfalt an Technologien verwendet hat, immer mit dem Ziel, Bug-freien Code zu veröffentlichen. Seinen MSc hat er an der Universität von Liverpool gemacht. Aktuell arbeitet er bei Shazam als Entwickler/Evangelist. Er hat auf zahlreichen Konferenzen weltweit gesprochen.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: