Contract-Testing mit SoapUi und Groovy

Bei der Modernisierung oder gar Neuentwicklung bestehender Services kann vieles schief gehen: Da bereits Anwendungen implementiert worden sind, die sich auf die existierenden Schnittstellen verlassen, muss besonderes Augenmerk darauf liegen, dass ein REST-Endpunkt vor und nach der Modernisierung exakt die gleichen Daten im gleichen Format liefert.

Im Rahmen eines Kundenprojektes, in dem wir bestehende Microservices auf Spring Boot migriert und einen vom Kunden eigens entwickelten OR-Mapper durch Spring Data JPA ersetzt haben, bestand so nun die Anforderung, dass sich der Service nach der Migration/Modernisierung exakt wie zuvor zu verhalten habe. Der Kunde wünschte sich zudem Integrationstests in Form von SoapUI-Tests, da dieses Tool in einem vorigen Projekt bereits erfolgreich eingesetzt worden war. Bestehende Schnittstellentests/Contract Tests gab es nicht.

Glücklicherweise bietet SoapUI die Möglichkeit, per Groovy-Scripts dynamisch Einfluss auf das aktuelle Projekt zu nehmen. So waren wir in der Lage, Beispielaufrufe an die bestehenden Services abzusetzen, daraus einen “Snapshot” der Daten in Form von Assertions zu erstellen, und mit diesen Assertions schließlich den modernisierten Service zu testen, um Ungereimtheiten zu finden.

In diesem Artikel wollen wir einige der Konzepte im Groovy-Scripting für SoapUI beleuchten.

Dieser Artikel präsentiert eine “abgespeckte” Version des fertigen Scripts, die als Grundlage für weitere Entwicklungen dienen kann. Keine Angst: Wer Erfahrung mit Java oder Kotlin hat, wird sich in Groovy schnell zurechtfinden und hat bereits die richtigen Instinkte.

Groovy-Scripte anlegen

Leider stellt es sich als etwas umständlich heraus, Code wiederverwertbar in SoapUI Test-Suiten zu hinterlegen. Glücklicherweise gibt es einen etwas ungewöhnlichen Weg, um Codeteile auszulagern und an anderer Stelle einzubinden.

Wir können somit die gemeinsame Logik für die Erstellung unserer Assertions (oder beliebigen anderen wiederverwendbaren Code) in einem zentralen Test Step anlegen. Als Beispiel legen wir einen Test Case namens groovy an, der einen Groovy Test Step namens assertions enthält. Den Test Case können wir dabei deaktivieren, da er keine tatsächlichen Tests enthalten wird und nur als Container dient. Der kompletten Quellcode kann am Ende dieses Artikels heruntergeladen werden; wir werden ihn im Laufe des Artikels aber auch gemeinsam entwickeln. Für den Start legen wir hier nur eine Klasse AssertionUtilities an und hinterlegen eine Instanz dieser Klasse im context-Objekt:

				
					import org.apache.logging.log4j.core.Logger
import com.eviware.soapui.model.testsuite.TestCaseRunner

// Diese Imports werden wir später noch benötigen
import com.eviware.soapui.impl.wsdl.teststeps.RestTestRequestStep
import com.eviware.soapui.impl.wsdl.teststeps.PropertyTransfersTestStep
import com.eviware.soapui.impl.wsdl.teststeps.JdbcRequestTestStep

import groovy.json.JsonSlurper
import groovy.json.JsonException

class AssertionUtilities {
	Logger log
	Object context
	TestCaseRunner testRunner
	JsonSlurper jsonSlurper

	def AssertionUtilities(Logger log, Object context, TestCaseRunner testRunner) {
		this.log = log
		this.context = context
		this.testRunner = testRunner

		this.jsonSlurper = new JsonSlurper()
	}

	def createAssertionsForTestCase() {
		log.info("dummy")
	}
}

context.assertions = new AssertionUtilities(log, context, testRunner)
				
			

Die tatsächlichen Testfälle können anschließend ebenfalls als Test Cases und Test Steps angelegt werden. Jeder Test Case wird dann einen zusätzlichen (wiederum deaktivierten) Groovy Test Step enthalten, der dafür zuständig ist, die gewünschten Assertions zu generieren. Diese Steps sehen alle sehr ähnlich aus: Sie importieren zunächst den gemeinsam genutzten Code und rufen anschließend nur die Funktion createAssertionsForTestCase auf, der wir in späteren Artikeln noch Parameter geben werden, die die genaue Ausgestaltung der Assertions kontrollieren.

Es ist wichtig, den jeweiligen Test Step zu deaktivieren, damit er tatsächlich nur manuell ausgeführt werden kann; ansonsten würden die Assertions der anderen Test Steps bei jedem Ausführen der Test Cases neu generiert – im besten Falle kostet dies lediglich etwas Zeit, im schlechtesten Falle können dadurch jedoch Fehler unentdeckt bleiben. Deaktivierte Test Steps werden bei der Ausführung eines kompletten Test Case oder sogar der kompletten Test Suite ignoriert, lassen sich aber über den grünen Pfeil im Script-Editor regulär ausführen.

				
					// Library importieren, via https://blog.sysco.no/testing/scriptlibrary-in-soapui/
if(context.assertions == null) {
	testRunner.testCase.testSuite
		.getTestCaseByName("groovy")
		.getTestStepByName("assertions")
		.run(testRunner, context)
}

context.assertions.createAssertionsForTestCase()
				
			

Die finale Struktur unseres Testprojektes sollte nun in etwa so aussehen; zu beachten sind insbesondere der deaktivierte Testfall groovy sowie der deaktivierte Testschritt Assertions generieren.

Testfälle und -schritte ausführen

Zum Start ein kurzer Refresher zur Terminologie: Schnittstellen-Tests bilden in SoapUI eine Baumstruktur bestehend aus folgenden Ebenen:

  • Projekt
    • Test Suite
      • Test Case
        • Test Step

Der Test Case kann dabei theoretisch alles mögliche sein: Ein fachlicher Testfall (etwa ein “Passwort zurücksetzen”-Flow) mit mehreren aufeinander aufbauenden Schritten ist ebenso denkbar wie eine Liste nicht zusammenhängender Tests mit unterschiedlichen Eingabedaten. Ein Test Case kann aus einer beliebigen Kombination von Test Steps bestehen, beispielsweise könnte ein JDBC Request zunächst einen definierten Zustand in einer Datenbank herstellen; ein REST Request führt dann eine Aktion aus, ein Property Transfer überträgt einen Teil der Antwort (etwa eine ID) in einen zweiten REST Request und zum Abschluss wird per JDBC Request wieder der Ausgangszustand wiederhergestellt.

Die Aufgabe unserer Funktion createAssertionsForTestCase wird es zunächst sein, alle Steps des übergeordneten Test Case des Groovy-Scripts zu durchlaufen. Wir können an dieser Stelle auf unterschiedliche Arten von Test Steps reagieren, beispielsweise könnten wir eine gesonderte Behandlung von JDBC Request– und Property Transfer-Steps implementieren oder die Generierung von Assertions für REST Request-Steps gegen bestimmte Services (beispielsweise einen Login-Service) unterdrücken. In unserem Fall ist die Logik vergleichsweise einfach: Ist ein Test Step deaktiviert, wird er übersprungen, ansonsten wird er ausgeführt.

Der TestCaseRunner, den wir im Konstruktor unserer AssertionUtilities-Klasse übergeben haben (und der wiederum vom jeweils ausgeführten Test Case befüllt wird), bietet uns bereits Zugriff auf den aktuellen TestCase (über die testCase-Property). Auf dessen Kind-Elemente (die einzelnen Testschritte für einen Testfall) können wir wiederum per testStepList-Property zugreifen.

Auch einen Testschritt auszuführen ist denkbar einfach: Wir rufen die run-Methode des Schritts auf und übergeben unseren TestCaseRunner und den context.

Zum Abschluss prüfen wir, ob es sich beim aktuellen Testschritt um einen RestTestRequestStep handelt: Nur dann werden anschließend auch Assertions generiert.

				
					def createAssertionsForTestCase() {
	for(step in testRunner.testCase.testStepList) {
		if(step.disabled) {
			continue
		}

		step.run(testRunner, context)

		// Nur für Rest Request Steps wollen wir Assertions generieren
		if(step instanceof RestTestRequestStep) {
			def response = step.httpRequest.response

			log.info("[${step.label}] Assertions werden erzeugt...")
			createHttpStatusAssertion(step, response.statusCode)
			createContentAssertions(step, response.responseContent)

			log.info("[${step.label}] Assertions erfolgreich erzeugt.")
		}
	}
}

def createContentAssertions(step, responseContent) {
	try {
		def data = jsonSlurper.parseText(responseContent)
		generateAssertionsForValue(step, "\$", data)
	} catch(JsonException e) {
		testRunner.fail("${step.label} hat kein JSON geliefert; fehlt ein Accept-Header?")
		log.info(e.message)
	}
}
				
			

Erstellung von Assertions: step.addAssertion

Einem Test Step Assertions hinzuzufügen passiert über die Methode addAssertion, die gleichzeitig auch als Factory dient: Sie gibt die zu verwendende Instanz der Assertion zurück, die dann konfiguriert werden kann. Ein String-Parameter, der dem Namen der Assertion in der Benutzeroberfläche entspricht (etwa “Valid HTTP Status Codes”) gibt dabei an, welche Art von Assertion erzeugt werden soll. Diese API ist zugegebenermaßen gewöhnungsbedürftig, den HTTP-Statuscode zu validieren fällt damit dennoch einfach:

				
					def createHttpStatusAssertion(step, statusCode) {
	def name = ":status = $statusCode"

	// Die Namen von Assertions müssen innerhalb eines Testschritts
	// immer eindeutig sein; zudem wollen wir verhindern, dass
	// mehrfache Aufrufe unseres Scripts (etwa, nachdem neue Felder
	// hinzugefügt wurden) Duplikate bestehender Assertions anlegen.
	if(step.getAssertionByName(name) == null) {
		log.debug("Erstelle $name")

		def assertion = step.addAssertion("Valid HTTP Status Codes")
		assertion.name = name
		assertion.codes = statusCode
	} else {
		log.info("Überspringe $name, da sie bereits existiert")
	}
}
				
			

Die Validierung unserer eigentlichen Daten ist allerdings etwas komplizierter. Es wäre natürlich trivial, den Textinhalt der Antwort 1:1 zu vergleichen, jedoch würde hier bereits eine Abweichung in der Formatierung oder der Reihenfolge von Feldern – Dinge, die auf die konsumierenden Services keinen Einfluss haben sollten – dazu führen, dass die Ergebnisse nicht mehr gleich sind. Zudem wäre eine Fehlermeldung im Sinne von “irgendwo im JSON gibt es eine Abweichung” nicht sehr hilfreich. An dieser Stelle können wir uns mit JsonPath Match Assertions behelfen: Sie prüfen einen Wert an einer bestimmten Stelle des JSON-Dokuments auf einen vorgegebenen Wert. In unserem Falle werden wir rekursiv die gesamte Antwort durchlaufen und für jedes Feld eine Assertion generieren.

JSON-Felder validieren mit JsonPath Match Assertions

Um nun alle Felder unserer Antwort zu validieren, müssen wir zunächst durch die Antwort iterieren: Für jeden Wert müssen wir zwischen einem JSON-Objekt, einem JSON-Array und “nativen” Werten (Strings, Booleans, Numbers, null) unterscheiden. Kommen wir bei einem “nativen” Wert an, generieren wir einen JsonPath und legen eine Assertion an, um den entsprechenden Wert zu überprüfen.

Zunächst prüfen wir den konkreten Typen des aktuellen Werts. Da wir mit einem untypisierten Modell der Daten arbeiten, gibt uns der JsonSlurper für komplexe Werte (Arrays und Objekte) Instanzen von List und Map zurück. Wir durchlaufen in beiden Fällen die einzelnen Elemente und erzeugen einen “laufenden” JsonPath-Pfad mithilfe der Indexer-Syntax ($.liste[0] für Arrays, $.objekt['key'] für Objekte). Für alle anderen Werte erzeugen wir dann tatsächlich eine JsonPath Match-Assertion mit dem bis dorthin gefundenen Pfad.

				
					def generateAssertionsForValue(step, path, value) {
	if(value instanceof List) {
		return generateAssertionsForList(step, path, value)
	} else if(value instanceof Map) {
		return generateAssertionsForMap(step, path, value)
	} else {
		return createJsonPathContentAssertion(step, path, value)
	}
}

def generateAssertionsForList(step, path, list) {
	list.eachWithIndex { entry, i ->
		generateAssertionsForValue(step, "$path[$i]", entry)
	}
}

def generateAssertionsForMap(step, path, map) {
	for(entry in map) {
		generateAssertionsForValue(step, "$path['${entry.key}']", entry.value)
	}
}

def createJsonPathContentAssertion(step, path, value) {
	def name = "$path = $value"

	if(step.getAssertionByName(name) == null) {
		log.debug("Erstelle $name")

		def assertion = step.addAssertion("JsonPath Match")
		assertion.name = name
		assertion.path = path
		assertion.expectedContent = value != null ? value : "null"
	} else {
		log.info("Überspringe $name, da sie bereits existiert")
	}
}
				
			

An dieser Stelle haben wir bereits ein funktionierendes Framework, um unsere Assertions komplett automatisiert zu erzeugen.

Fazit und Ausblick

Obwohl die API von SoapUI mitunter gewöhnungsbedürftig ist, waren wir dank Groovy in der Lage, mit überschaubarem Aufwand eine solide Grundlage aufzubauen und haben die Grundlagen des Scriptings mit Groovy kennengelernt.

Im weiteren Verlauf kann es nun hilfreich sein, folgende Features umzusetzen:

  • Abweichungen in der Sortierung von Listen erlauben
  • Bestimmte Felder vom Vergleich ausschließen
  • Neben dem HTTP-Statuscode und dem Inhalt des Body auch Header vergleichen
  • Requests automatisiert erst gegen den Referenz- und dann gegen den Test-Endpunkt ausführen
  • Helfermethoden definieren, um bestehende Assertions wieder zu löschen/sie komplett neu zu generieren

Diese Features sind mit vergleichbar geringem Aufwand umsetzbar, würden im Rahmen dieses Artikels allerdings zu sehr vom Kern ablenken. Wir werden sie daher in einem späteren Artikel näher beleuchten.

Zum Abschluss können hier ein Testprojekt sowie der Quellcode zur Erzeugung der Assertions in der in diesem Artikel vorgestellten Fassung heruntergeladen werden: