Testautomatisierung von REST-APIs und Microservices

Services oder Microservices mit REST-API können mit wenigen und einfachen Tools entwicklungsbegleitend getestet werden. Mit dem Spring Boot Standard Technologie-Stack bestehend aus JUnit, Mockito und Maven können EntwicklerInnen direkt loslegen und automatisierte Unit- und Integrationstests schreiben. In diesem Artikel werde ich dies demonstrieren und Tipps zur Integration in eine CICD Pipeline geben.

Die Testpyramide bzw. Testarten

Wenn ihr nach der Testpyramide sucht, findet ihr relativ viele verschiedene Varianten. Das Prinzip ist aber immer ähnlich:
  • die meisten Tests werden auf der untersten Ebene benötigt, wo die kleinsten Einheiten möglichst umfangreich getestet werden. Das ist typischer Weise die Testart Unit Tests zum Testen von einzelnen Klassen.
  • Je höher man in den Ebenen der Testpyramide aufsteigt, desto weniger Tests werden benötigt, da diese Tests einen deutlich größeren Bereich des Systems durchlaufen.
Für Services oder Microservices mit einer REST-API könnte die Testpyramide beispielsweise so aussehen.

Microservice Code-Schichtung

Betrachten wir einen typischen Microservice, so finden wir meistens eine Strukturierung in Schichten.
Im folgenden Schaubild gibt es 3 Schichten:
  • Unten die Persistenz-Schicht zur Datenhaltung (meist in einer Datenbank)
  • In der Mitte die Business-Logik-Schicht, welche die Implementierung der Geschäftslogik des Microservices enthält
  • Oben die API-Schicht, welche HTTP Requests annimmt, JSON-Daten parst und evtl. validiert und dann die passende Funktion in der Business-Logik-Schicht aufruft, um die Verarbeitung zu starten.

Die zuvor gezeigt Testpyramide lässt sich nun auf die Schichten anwenden. Die Klassen oder Spring Beans in der unteren und mittleren Schicht sollten mit Unit-Tests getestet werden.

Die Klassen der API-Schicht haben im Idealfall keine Geschäftslogik. Sie nehmen nur den REST-Request an und sorgen dafür, dass der JSON-Body oder die Request-Parameter geparst und validiert als Objekte im Code der Controller-Klassen ankommen. Dort werden die Datenobjekte an die passende Funktion in der Business-Logik-Schicht weitergegeben. Wenn wir hierzu passende Bibliotheken verwenden (z.B. Jackson und Hibernate, siehe validation-with-spring.html), dann können wir hier evtl. sogar auf Unit Tests verzichten. Ungetestet dürfen unsere Controller-Klassen natürlich nicht bleiben, deshalb verwenden wir hier die Integrationstests der 2. Ebene unserer Testpyramide.

Die dritte Ebene der Testpyramide testet die Außenkante unseres Microservices. Bei den Ende zu Ende Tests (E2E) rufen wir im Idealfall Requests so auf, wie es auch die Clients unserer Microservice REST-API tun würden. Im Schaubild habe ich in dieser Rolle exemplarisch ein Beispiel Portal und ein Beispiel Service eingezeichnet.

In den folgenden Abschnitten zeige ich einen einfachen Technologie-Stack, der diese Ebenen abdecken kann. Es gibt hier zahlreiche Alternative Technologie-Stacks, die ich hier aber nicht miteinander vergleichen oder bewerten möchte.

Video zum Blog-Artikel

Unit Tests mit JUnit 5 und Mockito

JUnit ist ein weit verbreitetes, klassischen Java Framework für Unit Tests. Dabei befinden wir uns auf Klassenebene und erzeugen für jede Klasse in unserem Code eine Testklasse, welche im Idealfall den kompletten Code in dieser Klasse testet. Für generierte Modell-Klassen oder klassische Datenklassen, die nur generierte Getter- und Setter-Methoden enthalten, spare ich mir häufig die Unit Tests, wenn ich in die Qualität des Code-Generators vertraue.

Im folgenden zeige ich exemplarisch einen Unit Test, der die Klasse InMemoryItemStore aus der Persistenz-Schicht testet. Die Klasse selbst beschreibe ich in diesem Blog-Artikel genauer kernkonzepte-von-spring.

const val DEFAULT_ID = "123"

internal class InMemoryItemStoreTest {

private lateinit var itemStore : InMemoryItemStore
private val idGeneratorMock = Mockito.mock(IdGenerator::class.java)
private val firstItem = Item("Schuh", "333")

@BeforeEach
fun setUp() {
Mockito.`when`(idGeneratorMock.generateId()).thenReturn(DEFAULT_ID)
itemStore = InMemoryItemStore(idGeneratorMock)
itemStore.items.add(firstItem)
}

@Test
fun saveItemWithIdNull() {
assertEquals(DEFAULT_ID, saveItemWithId(null))
assertEquals(2, itemStore.items.size)
}

@Test
fun saveItemWithNewId() {
assertEquals("567", saveItemWithId("567"))
assertEquals(2, itemStore.items.size)
}

@Test
fun saveItemWithExistingId() {
assertEquals(firstItem.id, saveItemWithId(firstItem.id))
assertEquals(1, itemStore.items.size)
}

fun saveItemWithId(id: String?) : String {
val item = Item("Ball", id)
return itemStore.saveItem(item)
}
...
}
  • Der hier gezeigte Code ist in der Programmiersprache Kotlin geschrieben. Da Kotlin auf der JVM basiert, kann ich die Java-Frameworks JUnit und Mockito verwenden.
  • Die 3 hier gezeigten Tests (markiert mit der JUnit @Test Annotation) testen die saveItem Methode der InMemoryItemStore Klasse. Jeder Test durchläuft dabei einen anderen Pfad in der zu testenden Methode.
  • Um wirklich einen Unit Test zu schreiben, habe ich die IdGenerator Instanz, die im zu testenden InMemoryItemStore Objekt verwendet wird, durch einen Mockito Mock ersetzt. Würde ich das nicht tun, würde dieser Test den Code von 2 Klassen testen und somit die Unit Test Grenze von einer Unit bzw. Klasse verlassen. Weitere Infos zu Mockito als Mocking Framework gibt es hier spring-and-mockito.html.
  • Ansonsten habe ich bei diesem Test versucht die Clean Code Regeln für Unit Tests so gut wie möglich zu befolgen, deshalb komme ich in jeder Methode auch mit 2-3 Zeilen Code aus. Für weitere Informationen zu Clean Code in JUnit Tests schaut euch diesen Blog-Artikel an JUnit5andSpringBootTest.html.

Integrationstests mit JUnit 5 und Spring Boot

Die mittlere Ebene der hier gezeigten Testpyramide können wir auch noch mit unserem einfachen Technologie-Stack abdecken, indem wir Spring Boot Tests erstellen und in diesen HTTP-Requests gegen die REST-API schicken, die von unseren Spring Rest-Controller bereitgestellt wird.

Der Spring Boot Test kann die ganze Spring Anwendung (oder auch nur Teile davon) starten und somit auch einen HTTP-Endpunkt bereitstellen. Diesen rufen wir mit einem HTTP Client (hier WebTestClient) auf, um den Rest-Controller und die darunter liegende Logik zu testen. Wir durchlaufen also deutlich mehr Code in unterschiedlichen Klassen des Microservice als beim zuvor gezeigten Unit-Test. Hier ist der Integrationstest für die durch den ItemController bereitgestellte REST-API:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
internal class ItemControllerWithWebTestClientTest(
@Autowired val store: InMemoryItemStore) {

private val client = WebTestClient.bindToServer()
.baseUrl("http://localhost:8080/item").build()

@BeforeEach
fun setup() {
store.items.clear()
store.items.add(Item("Kotlin", "1.5"))
store.items.add(Item("Spring", "5"))
assertEquals(2, store.items.size)
}

@Test
fun getRequest() {
client.get().uri("?id=1.5").exchange()
.expectStatus().is2xxSuccessful
.expectBody()
.jsonPath("$.name").isEqualTo("Kotlin")
}

@Test
fun deleteRequest() {
client.delete().uri("/5").exchange()
.expectStatus().isNoContent
assertEquals(1, store.items.size)
}

@Test
fun postRequest() {
client.post().bodyValue(Item("Java", "11")).exchange()
.expectStatus().isCreated
assertEquals(3, store.items.size)
}

@Test
fun getBadRequest() {
client.get().uri("?id=5").exchange()
.expectStatus().isBadRequest

}
...
}
  • Durch die @SpringBootTest mit webEnvironment Attribut werden alle Spring Beans und somit der komplette Spring Microservice instanziiert und mit einem Application-Server (Tomcat oder Netty je nach Maven-Konfiguration) bereitgestellt.
  • Im Testklassen Attribut client erstelle ich eine Instanz des Spring WebTestClient. Mit dieser kann ich HTTP-Requests gegen den im Spring Boot Test erzeugten Application Server schicken. Der WebTestClient bietet der EntwicklerIn praktische Methoden zum Testen der HTTP-Response vom Server, siehe expectStatus und expectBody im hier gezeigten Test.
  • Für jede HTTP-Operation habe ich hier mindestens einen positiven Test gezeigt, siehe die Funktionen getRequest, deleteRequest und postRequest. Exemplarisch habe ich auch einen negativen Testfall getBadRequest gezeigt, bei dem der Parameter id=5 an der Validierung scheitert.

Ende zu Ende Tests mit Postman

Für die E2E Tests unsere REST-API verwende ich sehr gerne Postman. Mit Postman kann man Testsuite erstellen, die eine Reihe von verschiedenen HTTP-Requests abschicken, um so die UseCases der Clients unseres Microservices nachzustellen. Für unsere hier gezeigte Item-API könnte ein Portal zum Beispiel folgenden UseCase haben:
  1. Zeige alle Items an (GET /item)
  2. Erzeuge ein neues Item (POST /item mit Item-JSON als Request Body)
  3. Zeige alle Items inklusive dem neu erzeugtem Item (GET /item)
Mit Postman kann ich diese 3 HTTP-Requests der Reihe nach ausführen. Ich kann mit einfachem JavaScript die HTTP-Responses der 3 Requests auswerten und sogar Zwischenergebnisse (z.B. die Id des im 2. POST-Request erzeugten Items) in den nächsten Schritten verwenden.

Test Automatisierung in der CICD Pipeline

Ein Vorteil des hier gezeigten Technologie-Stacks (ohne Postman) ist, dass die JUnit Tests mit unserem Build-Tool (Maven oder Gradle) ausgeführt werden können. Spring Boot unterstützt beim Konfigurieren des hier gezeigten Technologie-Stacks, da Mockito und der WebTestClient standardmäßig in jedem neu generierten Spring Boot Projekt als Bibliothek verfügbar sind. Deshalb können wir einfach den Build starten, um alle Tests automatisch auszuführen.

Unsere CICD Pipeline braucht in jedem Fall ein Build-Tool zum Bauen unseres Services. Das ist in jeder CICD Pipeline einer der ersten Schritte. Wenn wir das haben, ist es auch sehr leicht die JUnit-Tests mit jeder Pipeline-Ausführung zu starten. In der standardmäßigen Startkonfiguration führt z.B. Maven alle JUnit-Tests automatisch aus. Aus meiner Sicht ist es eine gute Idee mit jedem Commit ins Sourcecode-Repository die CICD-Pipeline automatisch zu starten und mindestens bis nach dem Schritt "JUnit Tests ausführen" laufen zu lassen. Auf diese Weise bekommt jeder Entwickler direkt Feedback, ob sein Commit (aus Versehen) einen der Unit oder Integrationstests fehlschlagen lässt - das sollte der Entwickler dann so schnell wie möglich analysieren und korrigieren, um andere Entwicklerinnen nicht zu stören.

Ende zu Ende Tests mit Postman können ebenfalls automatisch durch die CICD Pipeline ausgeführt werden. Das ist aber etwas komplizierter einzurichten als die JUnit Test Ausführung mit Maven. Hier ist es z.B. erklärt: https://dev.to/.../hello-newman 

Fazit

Die Test-Pyramide gilt auch für Microservices oder andere Services mit REST-API. Es gibt hier viele verschiedene Tools zur Testautomatisierung. Ich habe hier die Standard-Tools vorgestellt, weil sie einen Großteil der Pyramide abdecken können und leicht in eine automatisierte CICD Pipeline integriert werden können. Ein weiterer Vorteil ist, dass die meisten EntwicklerInnen diese Tools kennen oder leicht lernen können.

Damit gehören Code und ein Großteil der Testautomatisierung zusammen und wir müssen sicherstellen, dass Code und Tests wartbar sind. Ich habe beobachtet, dass sich Entwickler beim schreiben wartbarer JUnit-Tests schwerer tun als beim Schreiben von sauberem Code. Daher ist es extrem wichtig, dass ihr auch bei den JUnit-Tests die Clean Code Regeln zum Schreiben von sauberem und wartbarem Code befolgt.

Solltet ihr euch dafür interessieren, wie der Microservice in Kotlin geschrieben wurde, empfehle ich euch dazu meinen Udemy Kurs: https://www.udemy.com/course/api-und-kotlin

Kommentare

Beliebte Posts aus diesem Blog

OpenID Connect mit Spring Boot 3

CronJobs mit Spring

Kernkonzepte von Spring: Beans und Dependency Injection