In diesem Artikel beschreiben wir gesammelte Best Practices für das Integration Testing von Microservices. Zu diesem Zweck haben wir ein Projekt namens toti-example-service erstellt und auf GitHub veröffentlicht. Wir werden uns in diesem Beitrag immer wieder auf dieses Projekt und auf den Quelltext beziehen.
Kurzer Überblick über das Demo-Projekt
Das Projekt haben wir mit Kotlin und dem Spring-Boot-Framework erstellt. Es enthält beispielhaft zwei REST-Endpunkte, um die Interaktion mit einer Datenbank sowie einem anderen Microservice zu demonstrieren. Wie üblich bei Spring haben wir im Projekt verschiedene Schichten implementiert, wie Controller, Services, Repositories und Web-Clients.
Im Folgenden sind die Funktionalitäten der Beispielanwendung zu sehen:
Wie auf der Abbildung zu erkennen ist, wurde eine Funktionalität für Blogposts implementiert. Diese kann man in einer DynamoDB-Tabelle verwalten, und es steht eine einfache CRUD-API zur Verfügung.
Die zweite Funktionalität demonstriert die Kommunikation mit einem anderen Microservice. Hierbei ist es möglich, einen oder mehrere Fakten über Katzen (CatFacts) abzurufen. Diese werden im Hintergrund über einen WebClient von einer öffentlichen API abgefragt.
Warum Integration Testing?
Wir stellen immer wieder fest, dass Integrationstests in Projekten zu wenig Beachtung finden und dementsprechend selten genutzt werden. Dabei hat sich gezeigt, dass sie einen großen Beitrag zur Sicherheit und Stabilität der Schnittstellen leisten können, da sie den Service als Black Box betrachten. Diese Sichtweise führt dazu, dass der Service aus der Sicht eines externen Systems getestet wird, was zur Verbesserung der Schnittstellen-Stabilität beiträgt.
Durch die Gewährleistung der Stabilität der Schnittstelle bieten Integrationstests bei Refactorings eine große Sicherheit, da der API-Vertrag garantiert unverändert bleibt. Dies erleichtert es, sowohl die interne Datenstruktur als auch die Implementierung eines Microservices zu ändern.
Im Projektalltag hat es sich mittlerweile etabliert, Fehler zunächst mit einem Integrationstest zu reproduzieren, bevor man sie behebt. Dadurch wird sichergestellt, dass der Fehler nicht erneut auftritt, und Sonderfälle sowie Ausnahmen sind im Quellcode dokumentiert – gegebenenfalls inklusive der Ticketnummer. Integrationstests helfen somit nicht nur bei der Stabilität der Software, sondern auch bei der Dokumentation von Ausnahmefällen.
Was wir unter einem Integrationstest verstehen
Es kommt häufig vor, dass es Missverständnisse darüber gibt, was ein Integrationstest umfasst und was ihn ausmacht. Wir verstehen darunter einen Test, bei dem der Microservice unter möglichst realistischen Bedingungen getestet wird. Das bedeutet, dass der gesamte Quellcode, der auch in Produktion ausgeführt wird, getestet werden sollte – und auch ausschließlich dieser. Konkret bedeutet dies:
- Keine Beans überschreiben
- Keine Extra-Konfiguration, keine TestConfiguration
- keine Mocks des Quelltexts
- echte Http-Aufrufe
- echte Persistenz in einer Datenbank (z. B. PostgreSQL ausgeführt in Docker, …)
Abgrenzung zu anderen Testarten
Abgrenzung zu Unit-Tests:
- Unit-Tests konzentrieren sich auf individuelle Komponenten, während Integrationstests die Zusammenarbeit zwischen Komponenten prüfen.
- Unit-Tests sind in der Regel schneller, da sie in Isolation ausgeführt werden, während Integrationstests mehr Zeit in Anspruch nehmen können, da sie die Interaktion zwischen Komponenten testen.
Abgrenzung zu End-to-End-(E2E-)Tests:
- E2E-Tests testen das gesamte System aus der Perspektive des Benutzers, während sich Integrationstests auf die Interaktionen zwischen Komponenten konzentrieren.
- E2E-Tests sind in der Regel langsamer als Integrationstests, da sie das gesamte System testen und oft komplexere Testumgebungen und -szenarien erfordern.
Abgrenzung zu Performance-Tests:
- Performance-Tests messen Leistungskennzahlen wie Antwortzeiten, Durchsatz und Ressourcenverbrauch, während Integrationstests die Funktionalität und Zusammenarbeit zwischen Komponenten testen.
- Performance-Tests haben den Fokus auf die Leistung und nicht auf die Funktionalität, während sich Integrationstests darauf konzentrieren, ob die Komponenten korrekt zusammenarbeiten.
Wie sieht das in unserem Beispiel aus?
Um die zuvor genannten Anforderungen zu erfüllen, nutzen wir verschiedene Frameworks wie zum Beispiel Wiremock, Greenmail SMTP Server, Datenbanken in Docker oder AWS Localstack. Auf diese Weise stellen wir sicher, dass die produktive Umgebung im Test möglichst genau nachgebildet wird. Konkret bedeutet dies:
In der Grafik wird deutlich, dass unser Microservice als Black Box betrachtet wird (grüne Box). Die Testumgebung wird möglichst realitätsnah nachgebildet, wie an den Boxen Wiremock und Docker(DynamoDb) zu erkennen ist. Der Test nutzt den HTTP-Server Wiremock und startet den Docker-Container, bevor die Testfälle ausgeführt werden. Anschließend ruft der Test unseren Microservice über die Rest-API auf, um beispielsweise Entitäten zu erstellen und abzurufen. Dies ist ein Beispiel für ein Integrationstest-Setup.
Hier eine Liste möglicher Frameworks zum Mocken des Microservice-Umfelds:
- Testcontainer: Postgres Dockerfile
- Testcontainer: Oracle Dockerfile
- Testcontainer: InfluxDb Dockerfile
- Testcontainer: Local DynamoDB Dockerfile
- Testcontainer: AWS Localstack (z. B. für SQS, SNS)
- Wiremock (leichtgewichtiger HTTP-Server, um andere Microservices zu mocken)
- GreenMail - SMTP Server Mock
- UnboundID LDAP Server Mock
- Eigene Extensions, um bspw. eine UDP Verbindung zu mocken
Testcontainer am Beispiel DynamoDB als JUnit 5 Extension
Um das Testcontainer-Framework vor dem Start aller Tests zu initialisieren und hochzufahren, müssen die Daten für die Verbindung zum Container (Host, Port) in der Spring-Umgebung bekannt gemacht werden. Wir haben dabei gute Erfahrungen mit der Kapselung dieser Schritte in einer JUnit 5 Extension gemacht:
1class DynamoDbLocalExtension : BeforeAllCallback {
2
3 companion object {
4 @JvmStatic
5 var container = DynamoDbLocalContainer()
6
7 @JvmStatic
8 var initialized = false
9 }
10
11 override fun beforeAll(context: ExtensionContext) {
12 if (!initialized) {
13 container.withExposedPorts(8000)
14 .start()
15 System.setProperty(
16 "dynamo.endpoint",
17 "http://${container.containerIpAddress}:${container.getMappedPort(port)}"
18 )
19
20 )
21
22 createTables(context)
23 }
24 }
25
26 private fun createTables(context: ExtensionContext) {
27 val blogPostRepository = SpringExtension.getApplicationContext(context).getBean(BlogPostRepository::class.java)
28 blogPostRepository.blogPostTable().createTable()
29 }
30
31 class DynamoDbLocalContainer : GenericContainer<DynamoDbLocalContainer>("amazon/dynamodb-local:latest")
32}
Die JUnit 5 Extension erweitert den BeforeAllCallback, um sicherzustellen, dass die Initialisierung vor dem Hochfahren des Spring-Contexts stattfindet. Dabei werden die Konfigurationsparameter gesetzt und beim Hochfahren des Spring-Contexts dynamisch geladen.
Es ist jedoch zu beachten, dass die Methode beforeAll vor jeder Testklasse aufgerufen wird. Um sicherzustellen, dass der Container nur einmal initialisiert wird, verwenden wir die statische initialized-Property. Daher verzichten wir auch auf die automatisierte Verwaltung des Frameworks, die über die Annotation @Container gesteuert wird. Diese würde dazu führen, dass der Container nach jedem Test heruntergefahren wird.
Performance-Überlegungen
In verschiedenen Projekten wurde argumentiert, dass das Starten mehrerer Docker-Container viele Ressourcen des Host-Systems verbraucht und daher nicht praktikabel ist. Heutzutage ist dies jedoch kein Problem mehr, da in der Regel alle Computer, insbesondere die von Entwicklern, mit ausreichenden Ressourcen ausgestattet sind. Im praktischen Betrieb ergeben sich in der Regel keine Probleme.
Sollte es dennoch zu Engpässen kommen, besteht die Möglichkeit, Testcontainers in der Cloud von atomicjar zu verwenden.
Durch die Möglichkeit, alle Umgebungssysteme eines Services durch Container abzubilden, entsteht im Integrationstest eine zusätzliche Umgebung. So ist es unserer Meinung nach möglich, mit nur zwei Umgebungen (zum Beispiel Staging und Production) zu arbeiten und die Tests als Development-Umgebung zu betrachten.
Beispiel für einen Integrationstest
Abschließend möchten wir ein kurzes Beispiel vorstellen, wie man einen Test konkret implementieren kann. Wir wollen hier die Schnittstelle zum Anlegen eines Blogposts ansprechen und testen, ob diese den Post korrekt anlegt und zurückgibt:
1@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
2@ExtendWith(DynamoDbLocalExtension::class)
3class BlogPostIntegrationTest {
4 companion object {
5 @JvmStatic
6 @RegisterExtension
7 val rest = WebTestClientExtention()
8 }
9
10 @Test
11 internal fun `should create blog post and return it`() {
12 val id = IdGenerator.nextAsLong()
13 rest.webTestClient.put()
14 .uri { uriBuilder ->
15 uriBuilder.path("/blogposts/{id}")
16 .build(id)
17 }.bodyValue(
18 """
19 {
20 "id": $id,
21 "title": "some title 2",
22 "content": "the content 2"
23 }
24 """.trimIndent()
25 )
26 .headers { headers -> headers.contentType = MediaType.APPLICATION_JSON }
27 .exchange()
28 .expectStatus().is2xxSuccessful
29 .expectBody().json("""
30 {
31 "id": $id,
32 "title": "some title 2",
33 "content": "the content 2"
34 }
35 """.trimIndent())
36 }
37
38}
In dem obigen Test Case wird deutlich, dass die zuvor angesprochene JUnit Extension mithilfe der @ExtendWith-Annotation verwendet wird, um sicherzustellen, dass die lokale DynamoDB vor dem Test hochgefahren und initialisiert wird. Anschließend rufen wir den Service über den WebTestClient von Spring Boot wie in einem externen System auf. In diesem Beispiel wird die Antwort nur auf das JSON-Dokument und den HTTP-Status geprüft, aber es könnten auch umfangreichere Validierungen durchgeführt werden. Ein bewährtes Framework für Validierungen ist beispielsweise assertJ, das sich hier gut einfügt.
Takeaways:
- Integrationstests sind wichtig für stabile und sichere Schnittstellen in Microservices.
- Der Test sollte den Service als Black Box betrachten und unter realistischen Bedingungen durchführen.
- Frameworks wie Wiremock, SMTP Server, Datenbanken in Docker oder AWS Localstack können ein praktikables Integrationstest-Setup bereitstellen.
- Die Abbildung aller Umgebungssysteme eines Services durch Container schafft im Integrationstest eine zusätzliche Umgebung.
- Parallelisierung von Integrationstests kann Testdauer verkürzen und Testabdeckung verbessern.
- Integrationstests sollten automatisiert in den Entwicklungsprozess durch Continuous Integration/Continuous Delivery integriert werden.
- Integrationstests geben ein gutes Gefühl, da die Schnittstellen nach außen garantiert funktionieren.
Wir bedanken uns für das Lesen dieses Beitrags und hoffen, einige Denkanstöße zum Thema Integration Testing gegeben zu haben. Wir werden diese Gedanken in weiteren Beiträgen vertiefen; der nächste wird sich mit dem Thema der Parallelisierung dieser Tests befassen.
Weitere Beiträge
von Tobias Dittrich & Till Voß
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*innen
Tobias Dittrich
Senior IT Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Till Voß
IT Consultant and Software Engineer
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.