Testen Ihres Java-Codes mit Mocks und echtem Elasticsearch

Lernen Sie, wie Sie Ihre automatisierten Tests für Elasticsearch mithilfe von Mocks und Testcontainers schreiben.

Testen Sie Elasticsearch: Sehen Sie sich unsere Beispiel-Notebooks an, starten Sie eine kostenlose Cloud-Testversion oder testen Sie Elastic jetzt auf Ihrem lokalem Gerät.

In diesem Beitrag stellen wir zwei Methoden zum Testen von Software vor, bei denen Elasticsearch als externe Systemabhängigkeit verwendet wird, und erläutern diese. Wir werden sowohl Tests mit Mock-Objekten als auch Integrationstests behandeln, einige praktische Unterschiede zwischen ihnen aufzeigen und Hinweise geben, wo man sich für den jeweiligen Teststil entscheiden kann.

Gute Tests für das Systemvertrauen

Ein guter Test ist ein Test, der das Vertrauen aller am Prozess der Erstellung und Wartung eines IT-Systems Beteiligten stärkt. Tests sollen nicht cool oder schnell sein oder die Codeabdeckung künstlich erhöhen. Tests spielen eine entscheidende Rolle, um Folgendes sicherzustellen:

  • Unser Ziel ist es, ein Produkt zu liefern, das in der Produktion funktioniert.
  • Das System erfüllt die Anforderungen und die Verträge.
  • Es wird in Zukunft keine Rückschritte mehr geben.
  • Die Entwickler (und andere beteiligte Teammitglieder) sind zuversichtlich, dass das, was sie geschaffen haben, funktionieren wird.

Das heißt natürlich nicht, dass Tests nicht cool, schnell oder leistungsfähiger sein oder die Codeabdeckung erhöhen können. Je schneller wir unsere Testsuite ausführen können, desto besser. Es geht einfach darum, dass wir bei dem Bestreben, die Gesamtdauer der Testsuite zu verkürzen, nicht die Zuverlässigkeit, Wartbarkeit und das Vertrauen in die automatisierten Tests beeinträchtigen sollten.

Gute automatisierte Tests stärken das Selbstvertrauen der verschiedenen Teammitglieder:

  • Entwickler: Sie können sich vergewissern, dass das, was sie tun, auch funktioniert (noch bevor der Code, an dem sie arbeiten, ihren Rechner verlässt).
  • Qualitätssicherungsteam: Sie haben weniger manuelle Tests durchzuführen.
  • Systembetreiber und SREs: sind entspannter, da die Systeme einfacher zu implementieren und zu warten sind.

Zu guter Letzt: die Architektur eines Systems. Wir lieben es, wenn Systeme gut organisiert und wartungsfreundlich sind und eine klare Architektur aufweisen, die ihren Zweck erfüllt. Manchmal sieht man jedoch eine Architektur, die zu viel opfert für die Ausrede, dass es so besser testbar sei. Es ist nichts Schlechtes daran, sehr gut testbar zu sein – nur wenn das System in erster Linie so geschrieben wird, dass es testbar ist, anstatt den Bedürfnissen zu dienen, die seine Existenz rechtfertigen, sehen wir eine Situation, in der der Schwanz mit dem Hund wedelt.

Zwei Arten von Tests: Mocks und Abhängigkeiten

Es gibt viele Möglichkeiten, die Tests zu betrachten und somit zu klassifizieren. In diesem Beitrag werde ich mich nur auf einen Aspekt der Aufteilung der Tests konzentrieren: die Verwendung von Mocks (oder Stubs, oder Fakes, oder ...) im Vergleich zur Verwendung echter Abhängigkeiten. In unserem Fall ist die Abhängigkeit Elasticsearch.

Tests mit Mocks sind sehr schnell, da keine externen Abhängigkeiten gestartet werden müssen und alles ausschließlich im Speicher stattfindet. Beim Mocking in automatisierten Tests werden gefälschte Objekte anstelle von realen Objekten verwendet, um Teile eines Programms zu testen, ohne die tatsächlichen Abhängigkeiten zu nutzen. Aus diesem Grund werden sie benötigt und deshalb glänzen sie in Schnellerkennungsnetztests, z. B. Validierung der Eingabe. Es ist beispielsweise nicht nötig, eine Datenbank zu starten und sie aufzurufen, nur um zu überprüfen, ob negative Zahlen in einer Anfrage nicht zulässig sind.

Die Einführung von Mock-Objekten hat jedoch mehrere Konsequenzen:

  • Nicht alles und nicht alles lässt sich jederzeit einfach simulieren, daher haben Simulationen Auswirkungen auf die Architektur des Systems (was manchmal gut, manchmal weniger gut ist).
  • Tests, die auf simulierten Systemen laufen, sind zwar schnell, aber die Entwicklung solcher Tests kann recht lange dauern, da die simulierten Systeme, die die simulierten Systeme genau widerspiegeln, in der Regel nicht kostenlos zur Verfügung gestellt werden. Wer weiß, wie das System funktioniert, muss die Mocks auf die richtige Weise schreiben, und dieses Wissen kann aus praktischer Erfahrung, dem Studium der Dokumentation usw. stammen.
  • Mockups müssen gepflegt werden. Wenn Ihr System von einer externen Abhängigkeit abhängt und Sie diese Abhängigkeit aktualisieren müssen, muss jemand dafür sorgen, dass auch die Mock-Objekte, die die Abhängigkeit nachahmen, mit allen Änderungen aktualisiert werden: einschließlich solcher, die zu Inkompatibilitäten führen, dokumentierter und undokumentierter Änderungen (die sich auch auf unser System auswirken können). Dies wird besonders ärgerlich, wenn man eine Abhängigkeit aktualisieren möchte, die (nur mit Mocks arbeitende) Testsuite aber keine Gewissheit darüber bietet, dass alle getesteten Fälle garantiert funktionieren.
  • Es erfordert Disziplin, sicherzustellen, dass die Anstrengungen der Entwicklung und dem Testen des Systems und nicht den Mockups zugutekommen.

Aus diesen Gründen plädieren viele dafür, genau den umgekehrten Weg zu gehen: niemals Mocks (oder Stubs usw.) zu verwenden, sondern sich ausschließlich auf reale Abhängigkeiten zu verlassen. Dieser Ansatz funktioniert sehr gut in Demos oder wenn das System winzig ist und nur wenige Testfälle eine große Testabdeckung erzeugen. Bei solchen Tests kann es sich um Integrationstests handeln (grob gesagt: die Überprüfung eines Teils eines Systems anhand realer Abhängigkeiten) oder um End-to-End-Tests (bei denen alle realen Abhängigkeiten gleichzeitig verwendet und das Verhalten des Systems an allen Enden überprüft wird, während Benutzer-Workflows simuliert werden, die das System als nutzbar und erfolgreich definieren). Ein klarer Vorteil dieses Ansatzes ist, dass wir (oft unbeabsichtigt) auch unsere Annahmen über die Abhängigkeiten und deren Integration in das System, an dem wir arbeiten, überprüfen.

Wenn Tests jedoch ausschließlich reale Abhängigkeiten verwenden, müssen wir folgende Aspekte berücksichtigen:

  • Manche Testszenarien benötigen die eigentliche Abhängigkeit nicht (z. B. um die statischen Invarianten einer Anfrage zu überprüfen).
  • Solche Tests werden üblicherweise nicht als komplette Testreihen auf den Rechnern der Entwickler ausgeführt, da das Warten auf Feedback zu viel Zeit in Anspruch nehmen würde.
  • Sie benötigen mehr Ressourcen auf den CI-Maschinen, und es kann mehr Zeit in Anspruch nehmen, die Dinge so einzustellen, dass keine Zeit und Ressourcen verschwendet werden.
  • Es könnte sich als nicht trivial erweisen, Abhängigkeiten mit Testdaten zu initialisieren.
  • Tests mit realen Abhängigkeiten eignen sich hervorragend, um Code vor größeren Refaktorierungen, Migrationen oder Abhängigkeitsaktualisierungen abzugrenzen.
  • Es handelt sich dabei eher um undurchsichtige Tests, bei denen nicht detailliert auf die internen Abläufe des zu testenden Systems eingegangen wird, dafür aber auf die Ergebnisse geachtet wird.

Der optimale Punkt: Verwenden Sie beide Tests.

Anstatt Ihr System nur mit einer Art von Test zu prüfen, können Sie, wo sinnvoll, auf beide Arten zurückgreifen und versuchen, die Nutzung beider zu verbessern.

  • Führen Sie zuerst Mock-basierte Tests durch, da diese viel schneller sind, und erst wenn alle erfolgreich waren, führen Sie langsamere Abhängigkeitstests durch.
  • Wählen Sie Mocks für Szenarien, in denen externe Abhängigkeiten nicht wirklich benötigt werden: Wenn das Mocken zu viel Zeit in Anspruch nehmen würde, sollte der Code nur dafür massiv geändert werden; verlassen Sie sich auf externe Abhängigkeiten.
  • Es spricht nichts dagegen, einen Codeabschnitt mit beiden Ansätzen zu testen, solange es sinnvoll ist.

Beispiel für das zu testende System

In den nächsten Abschnitten werden wir ein Beispiel verwenden, das Sie hier finden. Es handelt sich um eine winzige Demo-Anwendung, die in Java 21 geschrieben wurde, Maven als Build-Tool verwendet, auf dem Elasticsearch-Client basiert und die neueste Erweiterung von Elasticsearch nutzt, nämlich ES|QL (die neue prozedurale Abfragesprache von Elastic). Auch wenn Java nicht Ihre Programmiersprache ist, sollten Sie die im Folgenden besprochenen Konzepte verstehen und auf Ihre Systemarchitektur übertragen können. Anhand eines realen Codebeispiels lassen sich manche Dinge einfach leichter erklären.

Die BookSearcher hilft uns bei der Suche und Analyse von Daten, in unserem Fall Büchern (wie in einem der vorherigen Beiträge gezeigt).

  • Es benötigt Elasticsearch exakt in Version 8.15.x als einzige Abhängigkeit (siehe isCompatibleWithBackend()), z. B. weil wir nicht sicher sind, ob unser Code vorwärtskompatibel ist, und sicher sind, dass er nicht rückwärtskompatibel ist. Bevor wir Elasticsearch in der Produktionsumgebung auf eine neuere Version aktualisieren, werden wir es zunächst in den Tests aktualisieren, um sicherzustellen, dass das Verhalten des zu testenden Systems unverändert bleibt.
  • Wir können es verwenden, um die Anzahl der in einem bestimmten Jahr veröffentlichten Bücher zu ermitteln (siehe numberOfBooksPublishedInYear).
  • Wir könnten es auch verwenden, wenn wir unseren Datensatz analysieren und die 20 meistveröffentlichten Autoren zwischen zwei gegebenen Jahren ermitteln müssen (siehe mostPublishedAuthorsInYears).

Testen Sie zunächst mit Mock-Objekten.

Für die Erstellung der in unseren Tests verwendeten Mocks werden wir Mockito verwenden, eine sehr beliebte Mocking-Bibliothek im Java-Ökosystem.

Wir könnten folgendermaßen vorgehen, um die Mock-Objekte vor jedem Test zurückzusetzen:

Wie bereits erwähnt, lässt sich nicht alles einfach mit Mocks testen. Aber manche Dinge können wir (und sollten wir wahrscheinlich sogar). Lassen Sie uns überprüfen, ob derzeit nur die Version 8.15.x von Elasticsearch unterstützt wird (zukünftig werden wir den Bereich möglicherweise erweitern, sobald wir bestätigt haben, dass unser System mit zukünftigen Versionen kompatibel ist):

Wir können auf ähnliche Weise überprüfen (einfach durch die Rückgabe einer anderen Nebenversion), dass unsere BookSearcher noch nicht mit 8.16.x funktionieren wird, da wir uns nicht sicher sind, ob sie damit kompatibel sein wird:

Schauen wir uns nun an, wie wir etwas Ähnliches erreichen können, wenn wir mit einem echten Elasticsearch testen. Hierfür verwenden wir das Elasticsearch-Modul von Testcontainers, das nur eine Voraussetzung hat: Es benötigt Zugriff auf Docker, da es Docker-Container für Sie ausführt. Aus einer bestimmten Perspektive ist Testcontainers einfach eine Möglichkeit, Docker-Container zu betreiben. Anstatt dies jedoch in Ihrem Docker Desktop (oder einem ähnlichen System), in Ihrer Befehlszeile oder in Skripten zu tun, können Sie Ihre Anforderungen in der Programmiersprache ausdrücken, die Sie kennen. Dadurch wird es möglich, Images abzurufen, Container zu starten, sie nach Tests automatisch zu löschen, Dateien hin und her zu kopieren, Befehle auszuführen, Protokolle zu untersuchen usw. – und zwar direkt aus Ihrem Testcode heraus.

Der Stub könnte folgendermaßen aussehen:

In diesem Beispiel verlassen wir uns auf die JUnit-Integration von Testcontainers mit @Testcontainers und @Container, was bedeutet, dass wir uns keine Gedanken darüber machen müssen, Elasticsearch vor unseren Tests zu starten und danach zu stoppen. Wir müssen lediglich den Client vor jedem Test erstellen und ihn nach jedem Test wieder schließen (um Ressourcenlecks zu vermeiden, die sich auf größere Testsuiten auswirken könnten).

Die Annotation eines nicht-statischen Feldes mit @Container bedeutet, dass für jeden Test ein neuer Container gestartet wird, sodass wir uns keine Gedanken über veraltete Daten oder das Zurücksetzen des Containerzustands machen müssen. Bei vielen Tests könnte sich dieser Ansatz jedoch als ungeeignet erweisen. Daher werden wir ihn in einem der nächsten Beiträge mit Alternativen vergleichen.

Notiz:

Durch die Nutzung von docker.elastic.co (dem offiziellen Docker-Image-Repository von Elastic) vermeiden Sie, Ihre Limits auf Docker Hub zu überschreiten.

Es wird außerdem empfohlen, in der Test- und Produktionsumgebung dieselbe Version Ihrer Abhängigkeit zu verwenden, um maximale Kompatibilität zu gewährleisten. Wir empfehlen außerdem, bei der Auswahl der Version genau zu sein. Aus diesem Grund gibt es kein latest -Tag für Elasticsearch-Images.

Verbindung zu Elasticsearch in Tests

Der Elasticsearch Java-Client kann auch dann eine Verbindung zu Elasticsearch herstellen, wenn in einem Testcontainer Sicherheitseinstellungen und SSL/TLS aktiviert sind (was bei Versionen 8.x Standard ist, weshalb wir in der Containerdeklaration nichts in Bezug auf die Sicherheit angeben mussten). Sofern bei der in der Produktion verwendeten Elasticsearch-Instanz ebenfalls TLS und einige Sicherheitsfunktionen aktiviert sind, empfiehlt es sich, den Integrationstest so nah wie möglich an das Produktionsszenario anzupassen und diese daher in den Tests nicht zu deaktivieren.

Wie man die für die Verbindung notwendigen Daten erhält, vorausgesetzt, der Container ist dem Feld oder der Variablen elasticsearch zugewiesen:

  • elasticsearch.getHost() wird Ihnen den Host anzeigen, auf dem der Container läuft (was in den meisten Fällen wahrscheinlich "localhost" sein wird, aber bitte legen Sie dies nicht fest im Code fest, da es je nach Ihrer Konfiguration manchmal einen anderen Namen haben kann; daher sollte der Host immer dynamisch ermittelt werden).
  • elasticsearch.getMappedPort(9200) liefert den Host-Port, den Sie verwenden müssen, um eine Verbindung zu Elasticsearch herzustellen, das innerhalb des Containers läuft (da sich der externe Port bei jedem Start des Containers ändert, muss dies ebenfalls ein dynamischer Aufruf sein).
  • Sofern sie nicht überschrieben wurden, lauten der Standardbenutzername und das Standardpasswort "elastic" bzw. "changeme" .
  • Wenn bei der Container-Einrichtung kein SSL/TLS-Zertifikat angegeben wurde und die sichere Verbindung nicht deaktiviert ist (was ab Version 8.x das Standardverhalten ist), wird ein selbstsigniertes Zertifikat generiert. Dem zu vertrauen (z. B. wie es cURL kann) kann das Zertifikat mit elasticsearch.caCertAsBytes() (was Optional<byte[]> zurückgibt) abgerufen werden, oder eine andere bequeme Möglichkeit besteht darin, SSLContext mit createSslContextFromCa() zu erhalten.

Das Gesamtergebnis könnte folgendermaßen aussehen:

Ein weiteres Beispiel für die Erstellung einer Instanz von ElasticsearchClient finden Sie im Demo-Projekt.

Notiz:

Informationen zur Erstellung von Clients in Produktionsumgebungen finden Sie in der Dokumentation.

Erster Integrationstest

Unser allererster Test, mit dem wir überprüfen, ob wir BookSearcher mit Elasticsearch Version 8.15.x erstellen können, könnte folgendermaßen aussehen:

Wie Sie sehen, müssen wir nichts weiter einrichten. Wir müssen die von Elasticsearch zurückgegebene Version nicht simulieren. Wir müssen lediglich BookSearcher einen Client bereitstellen, der mit einer realen Elasticsearch-Instanz verbunden ist, die von Testcontainers für uns gestartet wurde.

Integrationstests kümmern sich weniger um die Interna.

Machen wir ein kleines Experiment: Nehmen wir an, wir müssen aufhören, Daten aus dem Ergebnissatz mithilfe von Spaltenindizes zu extrahieren, und uns stattdessen auf Spaltennamen verlassen. Also in der Methode isCompatibleWithBackend anstelle von

Wir werden Folgendes haben:

Wenn wir beide Tests erneut ausführen, werden wir feststellen, dass der Integrationstest mit dem echten Elasticsearch weiterhin problemlos verläuft. Allerdings funktionierten die Tests mit Mocks nicht mehr, da wir Aufrufe wie rs.getInt(int) und nicht wie rs.getInt(String) simuliert hatten. Damit sie erfolgreich sind, müssen wir sie nun entweder simulieren oder beide simulieren, abhängig von anderen Anwendungsfällen in unserer Testsuite.

Integrationstests können wie eine Kanone sein, die eine Fliege tötet.

Integrationstests sind in der Lage, das Verhalten des Systems zu überprüfen, selbst wenn keine externen Abhängigkeiten benötigt werden. Allerdings ist die Verwendung auf diese Weise in der Regel eine Verschwendung von Ausführungszeit und Ressourcen. Betrachten wir die Methode mostPublishedAuthorsInYears(int minYear, int maxYear). Die ersten beiden Zeilen lauten wie folgt:

Die erste Anweisung prüft eine Bedingung, die in keiner Weise von Elasticsearch (oder irgendeiner anderen externen Abhängigkeit) abhängt. Daher ist es nicht nötig, irgendwelche Container zu starten, um lediglich zu überprüfen, ob eine Ausnahme ausgelöst wird, wenn minYear größer als maxYear ist.

Ein einfacher Mock-Test, der zudem schnell und ressourcenschonend ist, genügt vollkommen, um dies sicherzustellen. Nachdem wir die Mock-ups eingerichtet haben, können wir einfach Folgendes tun:

Das Starten einer Abhängigkeit anstatt des Mockens wäre in diesem Testfall verschwenderisch, da es keine Möglichkeit gibt, einen sinnvollen Aufruf für diese Abhängigkeit durchzuführen.

Um jedoch das Verhalten ab String query = ... zu überprüfen, ob die Abfrage korrekt geschrieben ist, werden die erwarteten Ergebnisse geliefert: Die Clientbibliothek ist in der Lage, korrekte Anfragen und Antworten zu senden, es gibt keine Syntaxänderungen, und daher ist es viel einfacher, einen Integrationstest zu verwenden, z. B.:

Auf diese Weise können wir sicher sein, dass unsere Abfrage uns genau das liefert, was wir erwarten, wenn wir unsere Daten an Elasticsearch senden (in dieser oder einer zukünftigen Version, zu der wir migrieren): Das Datenformat hat sich nicht geändert, die Abfrage ist weiterhin gültig und die gesamte Middleware (Clients, Treiber, Sicherheit usw.) funktioniert weiterhin. Wir müssen uns keine Gedanken darüber machen, die Mockups aktuell zu halten; die einzige Änderung, die erforderlich ist, um die Kompatibilität mit z. B. 8.15 würde dies ändern:

Dasselbe passiert, wenn Sie sich beispielsweise entscheiden, Verwenden Sie das gute alte QueryDSL anstelle von ES|QL: Die Ergebnisse der Abfrage (unabhängig von der Sprache) sollten immer noch die gleichen sein.

Verwenden Sie bei Bedarf beide Ansätze.

Das Beispiel der Methode mostPublishedAuthorsInYears veranschaulicht, dass eine einzelne Methode mit beiden Methoden getestet werden kann. Und vielleicht sollte es das sogar sein.

  • Die ausschließliche Verwendung von Mock-Objekten bedeutet, dass wir die Mock-Objekte pflegen müssen und keinerlei Vertrauen in die Systemaktualisierung haben.
  • Die ausschließliche Verwendung von Integrationstests würde bedeuten, dass wir eine Menge Ressourcen verschwenden, die wir überhaupt nicht benötigen.

Fassen wir zusammen

  • Die Verwendung von Mocking- und Integrationstests mit Elasticsearch ist möglich.
  • Verwenden Sie Mocking-Tests wie fast-detection-net und starten Sie Tests mit Abhängigkeiten erst, wenn diese erfolgreich durchlaufen werden (z. B. mit ./mvnw test '-Dtest=!TestInt*' && ./mvnw test '-Dtest=TestInt*' oder den Plugins Failsafe und Surefire ).
  • Verwenden Sie Mocks, wenn Sie das Verhalten Ihres Systems testen ("Codezeilen"), bei denen die Integration mit externen Abhängigkeiten keine Rolle spielt (oder sogar übersprungen werden kann).
  • Nutzen Sie Integrationstests, um Ihre Annahmen über die Integration mit externen Systemen zu überprüfen.
  • Scheuen Sie sich nicht, beide Ansätze auszuprobieren – sofern dies gemäß den oben genannten Punkten sinnvoll ist.

Man könnte anmerken, dass es übertrieben ist, die Version (in unserem Fall 8.15.x) so streng zu handhaben. Die alleinige Verwendung des Versionskennzeichens wäre möglich, aber bitte beachten Sie, dass es in diesem Beitrag stellvertretend für alle anderen Funktionen steht, die sich zwischen den Versionen ändern könnten.

Im nächsten Teil dieser Serie werden wir uns mit Möglichkeiten zur Initialisierung von Elasticsearch in einem Testcontainer mit Testdatensätzen befassen. Teilt uns mit, ob ihr etwas auf Basis dieses Blogs gebaut habt oder ob ihr Fragen in unseren Diskussionsforen und im Community-Slack-Kanal habt.

Zugehörige Inhalte

Sind Sie bereit, hochmoderne Sucherlebnisse zu schaffen?

Eine ausreichend fortgeschrittene Suche kann nicht durch die Bemühungen einer einzelnen Person erreicht werden. Elasticsearch wird von Datenwissenschaftlern, ML-Ops-Experten, Ingenieuren und vielen anderen unterstützt, die genauso leidenschaftlich an der Suche interessiert sind wie Sie. Lasst uns in Kontakt treten und zusammenarbeiten, um das magische Sucherlebnis zu schaffen, das Ihnen die gewünschten Ergebnisse liefert.

Probieren Sie es selbst aus